Compare commits

..

70 Commits

Author SHA1 Message Date
google-labs-jules[bot] f80e957036 I've replaced the unit tests for the Rich Text Editor with end-to-end tests.
Here's a summary of the changes:
- I removed the existing unit tests for the RichTextEditor component.
- I created new end-to-end tests that cover:
    - Navigating to the cuesheet and opening an event.
    - Activating the rich text editor for the 'note' field.
    - Testing bold, link, text color, and background color functionalities.
    - Verifying that rich text formatting is saved correctly and persists after reopening an event.

This change helps ensure the RichTextEditor works as expected within the overall application.
2025-06-14 19:41:42 +00:00
google-labs-jules[bot] fb93063b47 refactor: style tweaks to edit css modal 2025-06-13 10:36:48 +02:00
google-labs-jules[bot] 24d3bf05ca refactor: remove usages of framer-motion 2025-06-13 10:36:26 +02:00
Alex Christoffer Rasmussen a4b15970de Upgrade expressjs (#1633)
* upgrade expressjs

* migration

* reenable test

* extend timeout on download test

* fixup! migration

* move empty body test from controller to validator

* enusre not empty

* extract validation function

* fixup! reenable test

* remove thin controllers

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

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

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

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

refactor: strict custom field parsing

refactor: remove rundown cache utilities

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

* refactor: migrate edit mutations to transaction

* refactor: migrate delete mutation to transaction

* refactor: migrate reorder mutation to transaction

* refactor: migrate apply delay to transaction

* refactor: migrate swapEvents to transaction

* refactor: migrate clone to transaction

* refactor: migrate group/ungroup transactions

* refactor: simplify mutations

* refactor: migrate getters

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

* loadProject should not generate new names

* update comments

* extract and test getProjectNumber

* spell

* finish jsdoc

* use getProjectNumber

* cleanup loadProject

* create a `incrementProjectNumber` function

* spelling

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

---------

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

* remove unused types

* create batch

* merge patch into eventStore

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

---------

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

* change it in UI

* update comment

* rename variables

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

* add comment
2025-06-09 14:12:55 +02:00
Carlos Valente f356afef43 fix: rebase master 2025-06-09 14:12:55 +02:00
Carlos Valente eba5252e40 chore: correct test path 2025-06-09 14:12:54 +02:00
Carlos Valente 72c4ad789a refactor: extract rundown parsing
refactor: implement groups in editor
2025-06-09 14:12:54 +02:00
Carlos Valente 3118c20719 chore: improve convention entry <> event 2025-06-09 14:12:54 +02:00
Carlos Valente 3754c374c3 refactor: maintain flat orders 2025-06-09 14:12:54 +02:00
Carlos Valente dd6d74121c refactor: implement operations on nested events 2025-06-09 14:12:54 +02:00
Carlos Valente 6fb42b989e refactor: process events in rundown 2025-06-09 14:12:54 +02:00
Carlos Valente 82dc1bc56b chore: improve convention entry <> event 2025-06-09 14:12:54 +02:00
Carlos Valente d2c7b34142 chore: rename currentBlock > parent 2025-06-09 14:12:53 +02:00
Carlos Valente 918f8d1b08 refactor: fix delay positioning in gaps 2025-06-09 14:12:53 +02:00
Carlos Valente fcbcf70e01 refactor(e2e): skip flaky test 2025-06-09 14:12:53 +02:00
Carlos Valente 5c6b06a0d4 refactor: improve project loading 2025-06-09 14:12:53 +02:00
Carlos Valente fc4c1bf22e refactor: gather group metadata 2025-06-09 14:12:53 +02:00
Carlos Valente 84fac739f0 refactor: swap maintains schedule 2025-06-09 14:12:53 +02:00
Carlos Valente ef73c87e2f chore: rename files 2025-06-09 14:12:53 +02:00
Carlos Valente 26ee5768a5 refactor: restructure model to contain an object of rundowns 2025-06-09 14:12:53 +02:00
Carlos Valente 27dcf06c73 refactor: clearer relationship on rundown elements 2025-06-09 14:12:52 +02:00
Carlos Valente d48b2ae506 refactor: use strict typing 2025-06-09 14:12:52 +02:00
Carlos Valente 14a7c04d3b refactor: remove stop as a possible end action 2025-06-09 14:12:52 +02:00
Carlos Valente abd9b127db refactor: restructure model to contain an object of rundowns 2025-06-09 14:12:51 +02:00
Carlos Valente 0a80d6db31 chore: remove IDE files 2025-06-09 14:12:51 +02:00
Carlos Valente 977c99e587 refactor: remove unused and legacy code
- remove legacy migrations
- remove unused server code
- remove unused UI code
2025-06-09 14:12:51 +02:00
jwetzell fe8bbd5003 add electron and esbuild to allow list for postinstall builds 2025-06-06 20:25:46 +02:00
jwetzell c6e38a2b4a upgrade vite to v6 (#1626) 2025-06-05 06:27:59 +02:00
jwetzell 56f18fe71c update node and pnpm versions in README 2025-06-05 06:13:04 +02:00
jwetzell 0b9306de82 update pnpm to latest v10 2025-06-05 06:13:04 +02:00
jwetzell 3b1e6cf33d update node version in workflows 2025-06-05 06:13:04 +02:00
jwetzell dda219ebbe update NodeJS to v22 2025-06-05 06:13:04 +02:00
jwetzell 8bb771e5c6 upgrade electron and electron-builder 2025-06-05 06:13:04 +02:00
Shobhit Nagpal 8723bcbd33 refactor: block event xlsx import (#1623) 2025-06-02 06:56:46 +02:00
Shobhit Nagpal 0dcea4f2d7 fix: pass empty string fallback when parsing fields for export (#1616) 2025-05-25 19:58:41 +02:00
Shobhit Nagpal 696c016c90 feat: custom data for projects (#1571) 2025-05-23 09:04:57 +02:00
Shobhit Nagpal eed6373dbf refactor: change offset classes depending on playback state (#1611)
* refactor: assign offsetClasses based on playback state
2025-05-23 09:04:57 +02:00
196 changed files with 9366 additions and 6028 deletions
+6 -6
View File
@@ -14,12 +14,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -56,12 +56,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -89,12 +89,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
+2 -2
View File
@@ -20,13 +20,13 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
+4 -4
View File
@@ -17,12 +17,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -73,12 +73,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
+1 -1
View File
@@ -1 +1 @@
v20.15.1
v22.15.1
+2 -2
View File
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup
- __node__ (~20)
- __pnpm__ (~9)
- __node__ (~22)
- __pnpm__ (~10)
- __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT
+3 -3
View File
@@ -1,13 +1,13 @@
FROM node:20-bullseye AS builder
FROM node:22-bullseye AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@9.5.0
RUN npm install -g pnpm@10.11.0
COPY . /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 run build:docker
FROM node:20-alpine
FROM node:22-alpine
# Set environment variables
# Environment Variable to signal that we are running production
+10 -7
View File
@@ -12,6 +12,8 @@
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28",
"@lexical/react": "^0.16.1",
"@lexical/rich-text": "^0.16.1",
"@mantine/hooks": "^7.17.2",
"@sentry/react": "^8.43.0",
"@table-nav/react": "^0.0.7",
@@ -19,7 +21,7 @@
"@tanstack/react-query-devtools": "^5.62.7",
"@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1",
"axios": "^1.2.0",
"axios": "^1.9.0",
"color": "^4.2.3",
"csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0",
@@ -34,7 +36,8 @@
"react-router-dom": "^6.3.0",
"react-simple-code-editor": "^0.14.1",
"web-vitals": "^3.1.1",
"zustand": "^5.0.3"
"zustand": "^5.0.3",
"lexical": "^0.16.1"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -72,7 +75,7 @@
"@types/react-dom": "^18.0.10",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "^4.2.1",
"@vitejs/plugin-react": "4.5.1",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0",
@@ -86,10 +89,10 @@
"prettier": "catalog:",
"sass": "^1.57.1",
"typescript": "catalog:",
"vite": "^5.2.11",
"vite-plugin-compression2": "^1.3.3",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^4.3.1",
"vite": "6.3.1",
"vite-plugin-compression2": "1.4.0",
"vite-plugin-svgr": "4.3.0",
"vite-tsconfig-paths": "5.1.4",
"vitest": "catalog:"
}
}
+5 -5
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import { CustomField, CustomFieldLabel, CustomFields } from 'ontime-types';
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
import { apiEntryUrl } from './constants';
@@ -24,15 +24,15 @@ export async function postCustomField(newField: CustomField): Promise<CustomFiel
/**
* Edits single custom field
*/
export async function editCustomField(label: CustomFieldLabel, newField: CustomField): Promise<CustomFields> {
const res = await axios.put(`${customFieldsPath}/${label}`, { ...newField });
export async function editCustomField(key: CustomFieldKey, newField: CustomField): Promise<CustomFields> {
const res = await axios.put(`${customFieldsPath}/${key}`, { ...newField });
return res.data;
}
/**
* Deletes single custom field
*/
export async function deleteCustomField(label: CustomFieldLabel): Promise<CustomFields> {
const res = await axios.delete(`${customFieldsPath}/${label}`);
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> {
const res = await axios.delete(`${customFieldsPath}/${key}`);
return res.data;
}
+1 -1
View File
@@ -11,7 +11,7 @@ export type HasUpdate = {
* HTTP request to get the latest version and url from github
*/
export async function getLatestVersion(): Promise<HasUpdate> {
const res = await axios.get(`${apiRepoLatest}`);
const res = await axios.get(apiRepoLatest);
return {
url: res.data.html_url as string,
version: res.data.tag_name as string,
+1 -1
View File
@@ -11,7 +11,7 @@ export const reportUrl = `${apiEntryUrl}/report`;
* HTTP request to fetch all reports
*/
export async function fetchReport(): Promise<OntimeReport> {
const res = await axios.get(`${reportUrl}/`);
const res = await axios.get(reportUrl);
return res.data;
}
+28 -7
View File
@@ -17,7 +17,7 @@ const rundownPath = `${apiEntryUrl}/rundown`;
* HTTP request to fetch a list of existing rundowns
*/
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
const res = await axios.get(`${rundownPath}/`);
const res = await axios.get(rundownPath);
return res.data;
}
@@ -51,20 +51,20 @@ type BatchEditEntry = {
/**
* HTTP request to edit multiple events
*/
export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<MessageResponse>> {
export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
return axios.put(`${rundownPath}/batch`, data);
}
export type ReorderEntry = {
eventId: string;
from: number;
to: number;
entryId: EntryId;
destinationId: EntryId;
order: 'before' | 'after' | 'insert';
};
/**
* HTTP request to reorder an entry
*/
export async function patchReorderEntry(data: ReorderEntry): Promise<AxiosResponse<OntimeEntry>> {
export async function patchReorderEntry(data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/reorder`, data);
}
@@ -83,10 +83,31 @@ export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<M
/**
* HTTP request to request application of delay
*/
export async function requestApplyDelay(delayId: string): Promise<AxiosResponse<MessageResponse>> {
export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/applydelay/${delayId}`);
}
/**
* HTTP request for cloning an entry
*/
export async function postCloneEntry(entryId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/clone/${entryId}`);
}
/**
* HTTP request for dissolving of a block
*/
export async function requestUngroup(blockId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/ungroup/${blockId}`);
}
/**
* HTTP request for grouping a list of entries into a block
*/
export async function requestGroupEntries(entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/group`, { ids: entryIds });
}
/**
* HTTP request to delete entries
*/
@@ -0,0 +1,310 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin';
import { TRANSFORMERS } from '@lexical/markdown';
import { $getRoot, $getSelection, EditorState, FORMAT_TEXT_COMMAND, LexicalEditor, $isRangeSelection, SELECTION_CHANGE_COMMAND, COMMAND_PRIORITY_LOW, $createParagraphNode, $createTextNode, $patchStyleText } from 'lexical';
import { useEffect, useState, useCallback, useRef } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { mergeRegister } from '@lexical/utils';
import { LinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
import { ListItemNode, ListNode } from '@lexical/list';
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
import { CodeNode, CodeHighlightNode } from '@lexical/code';
import { TableNode, TableCellNode, TableRowNode } from '@lexical/table';
// Define initial editor configuration
const editorConfig = {
namespace: 'MyEditor',
theme: {
ltr: 'ltr',
rtl: 'rtl',
placeholder: 'editor-placeholder',
paragraph: 'editor-paragraph',
quote: 'editor-quote',
heading: {
h1: 'editor-heading-h1',
h2: 'editor-heading-h2',
h3: 'editor-heading-h3',
h4: 'editor-heading-h4',
h5: 'editor-heading-h5',
},
list: {
nested: {
listitem: 'editor-nested-listitem',
},
ol: 'editor-list-ol',
ul: 'editor-list-ul',
listitem: 'editor-listitem',
},
link: 'editor-link',
text: {
bold: 'editor-text-bold',
italic: 'editor-text-italic',
underline: 'editor-text-underline',
strikethrough: 'editor-text-strikethrough',
underlineStrikethrough: 'editor-text-underlineStrikethrough',
code: 'editor-text-code',
},
code: 'editor-code',
codeHighlight: {
atrule: 'editor-tokenAttr',
attr: 'editor-tokenAttr',
boolean: 'editor-tokenProperty',
builtin: 'editor-tokenSelector',
cdata: 'editor-tokenComment',
char: 'editor-tokenSelector',
clike: 'editor-tokenComment',
comment: 'editor-tokenComment',
contentType: 'editor-tokenComment',
coord: 'editor-tokenComment',
deleted: 'editor-tokenProperty',
doctype: 'editor-tokenComment',
doi: 'editor-tokenComment',
entity: 'editor-tokenOperator',
function: 'editor-tokenFunction',
important: 'editor-tokenVariable',
inserted: 'editor-tokenSelector',
keyword: 'editor-tokenAttr',
markup: 'editor-tokenComment',
merged: 'editor-tokenComment',
namespace: 'editor-tokenVariable',
number: 'editor-tokenProperty',
operator: 'editor-tokenOperator',
prolog: 'editor-tokenComment',
property: 'editor-tokenProperty',
punctuation: 'editor-tokenPunctuation',
regex: 'editor-tokenVariable',
selector: 'editor-tokenSelector',
string: 'editor-tokenSelector',
style: 'editor-tokenComment',
symbol: 'editor-tokenProperty',
tag: 'editor-tokenProperty',
url: 'editor-tokenOperator',
variable: 'editor-tokenVariable',
},
},
onError(error: Error) {
throw error;
},
nodes: [
HeadingNode,
ListNode,
ListItemNode,
QuoteNode,
CodeNode,
CodeHighlightNode,
TableNode,
TableCellNode,
TableRowNode,
LinkNode,
],
};
interface RichTextEditorProps {
initialValue?: string;
onChange?: (value: string) => void;
}
export default function RichTextEditor({ initialValue, onChange }: RichTextEditorProps) {
const [isFocused, setIsFocused] = useState(false);
const editorRef = useRef<LexicalEditor | null>(null); // Use ref for editor instance
const editorWrapperRef = useRef<HTMLDivElement>(null); // Ref for the entire editor + toolbar wrapper
const handleEditorFocus = () => {
setIsFocused(true);
};
// This handleBlur is for the wrapper around the editor and toolbar
const handleWrapperBlur = (event: React.FocusEvent<HTMLDivElement>) => {
// Check if the new focused element is still within the editor wrapper
if (editorWrapperRef.current && !editorWrapperRef.current.contains(event.relatedTarget as Node)) {
setIsFocused(false);
}
};
const handleChange = useCallback((editorState: EditorState) => {
if (onChange) {
editorState.read(() => {
const root = $getRoot();
// For now, sending plain text. Could be HTML or Lexical's format.
onChange(root.getTextContent());
});
}
}, [onChange]);
// Placeholder for initial value loading
useEffect(() => {
const currentEditor = editorRef.current;
if (currentEditor && initialValue && isFocused) { // only update if focused and has initial value
currentEditor.update(() => {
const root = $getRoot();
root.clear();
const paragraph = $createParagraphNode();
paragraph.append($createTextNode(initialValue));
root.append(paragraph);
root.selectEnd(); // Move cursor to the end
});
}
}, [initialValue, isFocused]); // Rerun when isFocused changes or initialValue changes (though initialValue should be stable)
if (!isFocused) {
return (
<div
onClick={handleEditorFocus}
onFocus={handleEditorFocus}
tabIndex={0}
style={{ border: '1px solid #ccc', padding: '10px', minHeight: '50px', cursor: 'text' }}
role="textbox" // for accessibility
aria-placeholder="Click to edit..."
>
{initialValue || 'Click to edit...'}
</div>
);
}
// Prepare initial config for when the editor mounts
const localEditorConfig = {
...editorConfig,
editorState: initialValue && !editorRef.current // Only set initial state if editor hasn't been initialized yet with a value
? () => {
const root = $getRoot();
const paragraph = $createParagraphNode();
paragraph.append($createTextNode(initialValue));
root.append(paragraph);
}
: undefined,
};
return (
<div ref={editorWrapperRef} onBlur={handleWrapperBlur} tabIndex={-1} style={{ border: '1px solid #ccc', position: 'relative', marginTop: '5px' }}>
<LexicalComposer initialConfig={localEditorConfig}>
<EditorInitializer editorRef={editorRef} />
<ToolbarPlugin />
<div style={{position: 'relative'}}> {/* Container for ContentEditable and Placeholder */}
<RichTextPlugin
contentEditable={<ContentEditable style={{ minHeight: '150px', padding: '10px', outline: 'none', resize: 'vertical', userSelect: 'text' }} />}
placeholder={<div style={{ position: 'absolute', top: '10px', left: '10px', color: '#aaa', pointerEvents: 'none', userSelect: 'none' }}>Enter text...</div>}
ErrorBoundary={LexicalErrorBoundary}
/>
</div>
<HistoryPlugin />
<OnChangePlugin onChange={handleChange} />
<LinkPlugin /> {/* LinkPlugin provides TOGGLE_LINK_COMMAND */}
<ListPlugin />
<MarkdownShortcutPlugin transformers={TRANSFORMERS} />
</LexicalComposer>
</div>
);
}
// Helper component to get editor instance and attach it to a ref
function EditorInitializer({ editorRef }: { editorRef: React.MutableRefObject<LexicalEditor | null> }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
editorRef.current = editor;
return () => {
editorRef.current = null;
};
}, [editor, editorRef]);
return null;
}
function ToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const [isLink, setIsLink] = useState(false);
const [isBold, setIsBold] = useState(false);
// Add states for other formats if needed, e.g., selectedTextColor, selectedBgColor
const updateToolbar = useCallback(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
setIsBold(selection.hasFormat('bold'));
// Update link state
const node = selection.anchor.getNode();
const parent = node.getParent();
setIsLink(parent?.getType() === 'link' || node.getType() === 'link');
// Could also get current text color/bg color here if needed
// const style = selection.style;
// setSelectedTextColor(style.color);
// setSelectedBgColor(style.backgroundColor);
}
}, [editor]); // editor dependency is implicit via useLexicalComposerContext
useEffect(() => {
return mergeRegister(
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
updateToolbar();
return false;
},
COMMAND_PRIORITY_LOW,
),
// Potentially other listeners here, e.g., for text format changes
);
}, [editor, updateToolbar]);
const toggleBold = () => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
};
const toggleLink = useCallback(() => {
if (isLink) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); // Remove link
} else {
const url = prompt('Enter link URL:');
if (url) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, url);
}
}
}, [editor, isLink]);
return (
<div style={{ padding: '8px', borderBottom: '1px solid #ccc', display: 'flex', gap: '8px', userSelect: 'none' }}
onMouseDown={(e) => e.preventDefault()} // Prevent editor blur when clicking toolbar
>
<button onClick={toggleBold} style={{ fontWeight: isBold ? 'bold' : 'normal' }}>B</button>
<button onClick={toggleLink} style={{ fontWeight: isLink ? 'bold' : 'normal' }}>{isLink ? 'Unlink' : 'Link'}</button>
<button onClick={applyTextColor}>Color</button>
<button onClick={applyBackgroundColor}>BG Color</button>
{/* Add more buttons here */}
</div>
);
}
function applyStyleToSelection(editor: LexicalEditor, styleKey: string, styleValue: string) {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$patchStyleText(selection, { [styleKey]: styleValue });
}
});
}
function applyTextColor() {
const [editor] = useLexicalComposerContext();
const color = prompt('Enter text color (e.g., red, #FF0000):');
if (color) {
applyStyleToSelection(editor, 'color', color);
}
}
function applyBackgroundColor() {
const [editor] = useLexicalComposerContext();
const color = prompt('Enter background color (e.g., yellow, #FFFF00):');
if (color) {
// For background color, Lexical often uses 'highlight' format or direct background-color style
// Using FORMAT_TEXT_COMMAND for 'highlight' might be one way if a plugin handles it.
// Or, apply direct style:
applyStyleToSelection(editor, 'background-color', color);
}
}
+163 -52
View File
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
EntryId,
isOntimeBlock,
isOntimeEvent,
MaybeString,
OntimeBlock,
@@ -12,19 +13,23 @@ import {
TimeStrategy,
TransientEventPayload,
} from 'ontime-types';
import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, swapEventData } from 'ontime-utils';
import { moveDown, moveUp } from '../../features/rundown/rundown.utils';
import { RUNDOWN } from '../api/constants';
import {
deleteEntries,
patchReorderEntry,
postAddEntry,
postCloneEntry,
putBatchEditEvents,
putEditEntry,
ReorderEntry,
requestApplyDelay,
requestDeleteAll,
requestEventSwap,
requestGroupEntries,
requestUngroup,
SwapEntry,
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
@@ -74,10 +79,7 @@ export const useEntryActions = () => {
const _addEntryMutation = useMutation({
// TODO(v4): optimistic create entry
mutationFn: postAddEntry,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
/**
@@ -164,6 +166,29 @@ export const useEntryActions = () => {
],
);
/**
* Calls mutation to clone a selection
* @private
*/
const _cloneMutation = useMutation({
mutationFn: postCloneEntry,
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
/**
* Clone a selection
*/
const clone = useCallback(
async (entryId: EntryId) => {
try {
await _cloneMutation.mutateAsync(entryId);
} catch (error) {
logAxiosError('Error cloning entry', error);
}
},
[_cloneMutation],
);
/**
* Calls mutation to update existing entry
* @private
@@ -206,7 +231,6 @@ export const useEntryActions = () => {
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
@@ -365,13 +389,22 @@ export const useEntryActions = () => {
// Return a context with the previous rundown
return { previousRundown };
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
onSuccess: (response) => {
if (!response.data) return;
const { id, title, order, flatOrder, entries, revision } = response.data;
queryClient.setQueryData<Rundown>(RUNDOWN, {
id,
title,
order,
flatOrder,
entries,
revision,
});
},
onError: (_error, _newEvent, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousRundown);
},
networkMode: 'always',
});
const batchUpdateEvents = useCallback(
@@ -426,7 +459,6 @@ export const useEntryActions = () => {
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
@@ -480,7 +512,6 @@ export const useEntryActions = () => {
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
@@ -500,18 +531,30 @@ export const useEntryActions = () => {
*/
const _applyDelayMutation = useMutation({
mutationFn: requestApplyDelay,
onSuccess: (response) => {
if (!response.data) return;
const { id, title, order, flatOrder, entries, revision } = response.data;
queryClient.setQueryData<Rundown>(RUNDOWN, {
id,
title,
order,
flatOrder,
entries,
revision,
});
},
// Mutation finished, failed or successful
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
* Applies a given delay
*/
const applyDelay = useCallback(
async (delayEventId: string) => {
async (delayEventId: EntryId) => {
try {
await _applyDelayMutation.mutateAsync(delayEventId);
} catch (error) {
@@ -521,59 +564,101 @@ export const useEntryActions = () => {
[_applyDelayMutation],
);
/**
* Calls mutation to dissolve a block
* @private
*/
const _ungroupMutation = useMutation({
mutationFn: requestUngroup,
onSuccess: (response) => {
if (!response.data) return;
const { id, title, order, flatOrder, entries, revision } = response.data;
queryClient.setQueryData<Rundown>(RUNDOWN, {
id,
title,
order,
flatOrder,
entries,
revision,
});
},
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
/**
* Deletes a block and moves its events to the top level
*/
const ungroup = useCallback(
async (blockId: EntryId) => {
try {
await _ungroupMutation.mutateAsync(blockId);
} catch (error) {
logAxiosError('Error dissolving block', error);
}
},
[_ungroupMutation],
);
/**
* Calls mutation to create a block with a selection
* @private
*/
const _groupEntriesMutation = useMutation({
mutationFn: requestGroupEntries,
onSuccess: (response) => {
if (!response.data) return;
const { id, title, order, flatOrder, entries, revision } = response.data;
queryClient.setQueryData<Rundown>(RUNDOWN, {
id,
title,
order,
flatOrder,
entries,
revision,
});
},
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
/**
* Create a block with a selection
*/
const groupEntries = useCallback(
async (entryIds: EntryId[]) => {
try {
await _groupEntriesMutation.mutateAsync(entryIds);
} catch (error) {
logAxiosError('Error grouping entries', error);
}
},
[_groupEntriesMutation],
);
/**
* Calls mutation to reorder an entry
* @private
*/
const _reorderEntryMutation = useMutation({
mutationFn: patchReorderEntry,
// we optimistically update here
onMutate: async (data) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) {
// optimistically update object
const newOrder = reorderArray(previousData.order, data.from, data.to);
queryClient.setQueryData<Rundown>(RUNDOWN, {
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 { previousData };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _data, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
* Reorders a given entry
*/
const reorderEntry = useCallback(
async (entryId: string, from: number, to: number) => {
async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => {
try {
const reorderObject: ReorderEntry = {
eventId: entryId,
from,
to,
entryId,
destinationId,
order,
};
await _reorderEntryMutation.mutateAsync(reorderObject);
} catch (error) {
@@ -583,6 +668,30 @@ export const useEntryActions = () => {
[_reorderEntryMutation],
);
const move = useCallback(async (entryId: EntryId, direction: 'up' | 'down') => {
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.order) {
return;
}
const { destinationId, order } =
direction === 'up'
? moveUp(entryId, cachedRundown.order, cachedRundown.entries)
: moveDown(entryId, cachedRundown.order, cachedRundown.entries);
if (destinationId) {
try {
const reorderObject: ReorderEntry = {
entryId,
destinationId,
order: order as 'before' | 'after' | 'insert',
};
await _reorderEntryMutation.mutateAsync(reorderObject);
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
}
}, []);
/**
* Calls mutation to swap events
* @private
@@ -633,7 +742,6 @@ export const useEntryActions = () => {
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
@@ -654,9 +762,13 @@ export const useEntryActions = () => {
addEntry,
applyDelay,
batchUpdateEvents,
clone,
deleteEntry,
deleteAllEntries,
ungroup,
getEntryById,
groupEntries,
move,
reorderEntry,
swapEvents,
updateEntry,
@@ -679,12 +791,11 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
}
function deleteEntry(entry: OntimeEntry) {
if (isOntimeEvent(entry) && entry.parent) {
if (isOntimeBlock(entry) || !entry.parent) {
order = order.filter((id) => id !== entry.id);
} else {
const parent = entries[entry.parent] as OntimeBlock;
parent.events = parent.events.filter((event) => event !== entry.id);
parent.numEvents -= 1;
} else {
order = order.filter((id) => id !== entry.id);
}
delete entries[entry.id];
@@ -8,4 +8,5 @@ export const projectDataPlaceholder: ProjectData = {
backstageUrl: '',
backstageInfo: '',
projectLogo: null,
custom: [],
};
+11
View File
@@ -1,9 +1,20 @@
import { QueryClient } from '@tanstack/react-query';
import { isOntimeCloud } from '../externals';
export const ontimeQueryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 10, // 10 min
},
mutations: {
/**
* React Query detects whether the client is online
* However, web access is not required for the clients when deployed locally
* - use 'always' for clients that may be online
* - use 'online' for clients that are connected to the cloud
*/
networkMode: isOntimeCloud ? 'online' : 'always',
},
},
});
@@ -2,4 +2,4 @@
exports[`cx() > ignores falsy values 1`] = `""`;
exports[`cx() > merges styles 1`] = `"_test_98a1e0 _another_98a1e0"`;
exports[`cx() > merges styles 1`] = `"_test_d5d33f _another_d5d33f"`;
@@ -1,6 +1,6 @@
import { EndAction, EntryCustomFields, OntimeEvent, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
import { cloneEvent } from '../eventsManager';
import { cloneEvent } from '../clone';
describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
@@ -28,6 +28,7 @@ describe('cloneEvent()', () => {
delay: 0,
dayOffset: 0,
gap: 0,
triggers: [],
custom: {
lighting: '3',
} as EntryCustomFields,
@@ -36,6 +37,7 @@ describe('cloneEvent()', () => {
const cloned = cloneEvent(original);
expect(cloned).not.toBe(original);
expect(cloned.custom).not.toBe(original.custom);
expect(cloned.triggers).not.toBe(original.triggers);
expect(cloned).toMatchObject({
type: SupportedEntry.Event,
@@ -59,6 +61,8 @@ describe('cloneEvent()', () => {
gap: 0,
timeWarning: original.timeWarning,
timeDanger: original.timeDanger,
triggers: original.triggers,
custom: original.custom,
});
});
});
@@ -30,6 +30,7 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
gap: 0,
timeWarning: event.timeWarning,
timeDanger: event.timeDanger,
custom: { ...event.custom },
triggers: structuredClone(event.triggers),
custom: structuredClone(event.custom),
};
};
+1 -1
View File
@@ -119,7 +119,7 @@ export function formatDuration(duration: number, hideSeconds = true): string {
}
if (!hideSeconds) {
const seconds = Math.floor((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
const seconds = Math.ceil((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
if (seconds > 0) {
result += `${seconds}s`;
}
+1 -4
View File
@@ -49,10 +49,7 @@ function resolveUrl(protocol: 'http' | 'ws', path: string) {
url.pathname = baseURI ? `${baseURI}/${path}` : path;
// in development mode, we use the React port for UI, but need the requests to target the server
if (isDev) {
// this is used as a fallback port for development
url.port = '4001';
}
// this is done with a proxy in the vite config to avoid CORS issues in the dev environment
const result = url.toString();
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { IoPencil, IoTrash } from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react';
import { CustomField, CustomFieldLabel } from 'ontime-types';
import { CustomField, CustomFieldKey } from 'ontime-types';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
@@ -17,8 +17,8 @@ interface CustomFieldEntryProps {
label: string;
fieldKey: string;
type: 'string' | 'image';
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
onDelete: (label: CustomFieldLabel) => Promise<void>;
onEdit: (key: CustomFieldKey, patch: CustomField) => Promise<void>;
onDelete: (key: CustomFieldKey) => Promise<void>;
}
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import { CustomField, CustomFieldLabel } from 'ontime-types';
import { CustomField, CustomFieldKey } from 'ontime-types';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
import Info from '../../../../../common/components/info/Info';
@@ -31,14 +31,14 @@ export default function CustomFields() {
setIsAdding(false);
};
const handleEditField = async (label: CustomFieldLabel, customField: CustomField) => {
await editCustomField(label, customField);
const handleEditField = async (key: CustomFieldKey, customField: CustomField) => {
await editCustomField(key, customField);
refetch();
};
const handleDelete = async (label: string) => {
const handleDelete = async (key: CustomFieldKey) => {
try {
await deleteCustomField(label);
await deleteCustomField(key);
refetch();
} catch (_error) {
/** we do not handle errors here */
@@ -8,7 +8,5 @@
}
.column {
align-items: start;
display: flex;
flex-direction: column;
}
@@ -123,7 +123,7 @@ export default function ViewSettingsForm() {
onClick={onCodeEditorOpen}
variant='ontime-subtle'
size='sm'
isDisabled={!data.overrideStyles}
isDisabled={isSubmitting}
width='fit-content'
>
Edit CSS override
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
@@ -23,6 +24,7 @@ type ProjectCreateFormValues = {
publicUrl?: string;
backstageInfo?: string;
backstageUrl?: string;
custom?: { title: string; value: string }[];
};
export default function ProjectCreateForm(props: ProjectCreateFromProps) {
@@ -34,6 +36,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
const {
handleSubmit,
register,
control,
formState: { isSubmitting, isValid },
setFocus,
} = useForm<ProjectCreateFormValues>({
@@ -44,6 +47,11 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
});
// set focus to first field
useEffect(() => {
setFocus('title');
@@ -59,6 +67,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
...values,
filename,
});
await queryClient.invalidateQueries({ queryKey: PROJECT_LIST });
onClose();
} catch (error) {
@@ -66,6 +75,10 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
}
};
const handleAddCustom = () => {
append({ title: '', value: '' });
};
return (
<Panel.Section
as='form'
@@ -151,6 +164,42 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
{...register('backstageUrl')}
/>
</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>
);
@@ -1,6 +1,6 @@
import { ChangeEvent, useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { IoDownloadOutline, IoTrash } from 'react-icons/io5';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react';
import { type ProjectData } from 'ontime-types';
@@ -25,6 +25,7 @@ export default function ProjectData() {
formState: { isSubmitting, isValid, isDirty, errors },
setError,
watch,
control,
setValue,
} = useForm({
defaultValues: data,
@@ -32,6 +33,12 @@ export default function ProjectData() {
resetOptions: {
keepDirtyValues: true,
},
mode: 'onChange',
});
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
});
// reset form values if data changes
@@ -77,6 +84,10 @@ export default function ProjectData() {
});
};
const handleAddCustom = () => {
append({ title: '', value: '' });
};
const onSubmit = async (formData: ProjectData) => {
try {
await postProjectData(formData);
@@ -231,6 +242,69 @@ export default function ProjectData() {
{...register('backstageUrl')}
/>
</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.Card>
</Panel.Section>
@@ -57,3 +57,18 @@
height: auto;
}
}
.customDataItem {
display: contents;
width: 100%;
.titleRow{
display: flex;
gap: 1rem;
align-items: end;
label {
flex: 1;
}
}
}
+1 -8
View File
@@ -10,7 +10,6 @@ import AppSettings from '../app-settings/AppSettings';
import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation';
import { EditorOverview } from '../overview/Overview';
import Finder from './finder/Finder';
import WelcomePlacement from './welcome/WelcomePlacement';
import styles from './Editor.module.scss';
@@ -22,7 +21,6 @@ const MessageControl = lazy(() => import('../control/message/MessageControlExpor
export default function Editor() {
const { isOpen: isSettingsOpen, setLocation, close } = useAppSettingsNavigation();
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isFinderOpen, onToggle: onFinderToggle, onClose: onFinderClose } = useDisclosure();
useWindowTitle('Editor');
@@ -46,16 +44,11 @@ export default function Editor() {
}
}, [close, isSettingsOpen, setLocation]);
useHotkeys([
['mod + ,', toggleSettings],
['mod + f', onFinderToggle],
['Escape', onFinderClose],
]);
useHotkeys([['mod + ,', toggleSettings]]);
return (
<div className={styles.mainContainer} data-testid='event-editor'>
<WelcomePlacement />
<Finder isOpen={isFinderOpen} onClose={onFinderClose} />
<NavigationMenu isOpen={isMenuOpen} onClose={onClose} />
<EditorOverview>
<IconButton
@@ -45,6 +45,10 @@
@include ellipsis-overflow;
}
.offset {
color: $muted-gray;
}
.ahead {
color: $playback-ahead;
}
@@ -1,4 +1,5 @@
import { memo, PropsWithChildren, ReactNode, useMemo } from 'react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
@@ -163,10 +164,10 @@ function ProgressOverview() {
}
function RuntimeOverview() {
const { clock, offset } = useRuntimePlaybackOverview();
const { clock, offset, playback } = useRuntimePlaybackOverview();
const offsetText = getOffsetText(offset);
const offsetClasses = offset === null ? undefined : offset <= 0 ? style.behind : style.ahead;
const offsetClasses = cx([style.offset, playback !== Playback.Stop && (offset < 0 ? style.behind : style.ahead)]);
return (
<>
@@ -2,3 +2,10 @@
padding-block: 1.5rem;
text-align: center;
}
.inline {
display: flex;
align-items: center;
justify-content: center;
gap: 2rem;
}
+52 -28
View File
@@ -37,12 +37,12 @@ import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { cloneEvent } from '../../common/utils/eventsManager';
import { cloneEvent } from '../../common/utils/clone';
import BlockBlock from './block-block/BlockBlock';
import BlockEnd from './block-block/BlockEnd';
import QuickAddBlock from './quick-add-block/QuickAddBlock';
import { makeRundownMetadata, makeSortableList } from './rundown.utils';
import { makeRundownMetadata, makeSortableList, moveDown, moveUp } from './rundown.utils';
import RundownEmpty from './RundownEmpty';
import { useEventSelection } from './useEventSelection';
@@ -55,10 +55,10 @@ interface RundownProps {
}
export default function Rundown({ data }: RundownProps) {
const { order, flatOrder, entries, id } = data;
const { order, entries, id } = data;
// we create a copy of the rundown with a data structured aligned with what dnd-kit needs
const featureData = useRundownEditor();
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(flatOrder, entries));
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries));
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
// we ensure that this is unique to the rundown
key: `rundown.${id}-editor-collapsed-groups`,
@@ -78,7 +78,7 @@ export default function Rundown({ data }: RundownProps) {
useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: appMode === AppMode.Run });
// DND KIT
const sensors = useSensors(useSensor(PointerSensor));
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
const deleteAtCursor = useCallback(
(cursor: string | null) => {
@@ -188,19 +188,26 @@ export default function Rundown({ data }: RundownProps) {
);
const moveEntry = useCallback(
(cursor: string | null, direction: 'up' | 'down') => {
if (order.length < 2 || cursor == null) {
(cursor: EntryId | null, direction: 'up' | 'down') => {
if (sortableData.length < 2 || cursor == null) {
return;
}
const { index } =
direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor);
if (index !== null) {
const offsetIndex = direction === 'up' ? index + 1 : index - 1;
reorderEntry(cursor, offsetIndex, index);
const { destinationId, order, isBlock } =
direction === 'up' ? moveUp(cursor, sortableData, entries) : moveDown(cursor, sortableData, entries);
if (!destinationId) {
return;
}
// if we are moving into a block, we need to make sure it is expanded
if (isBlock) {
handleCollapseGroup(false, destinationId);
}
reorderEntry(cursor, destinationId, order as 'before' | 'after' | 'insert');
},
[order, reorderEntry, entries],
[sortableData, reorderEntry],
);
// shortcuts
@@ -237,8 +244,8 @@ export default function Rundown({ data }: RundownProps) {
// we copy the state from the store here
// to workaround async updates on the drag mutations
useEffect(() => {
setSortableData(makeSortableList(flatOrder, entries));
}, [flatOrder, entries]);
setSortableData(makeSortableList(order, entries));
}, [order, entries]);
// in run mode, we follow selection
useEffect(() => {
@@ -285,18 +292,33 @@ export default function Rundown({ data }: RundownProps) {
const handleOnDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over?.id) {
if (active.id !== over?.id) {
const fromIndex = active.data.current?.sortable.index;
const toIndex = over.data.current?.sortable.index;
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
setSortableData((currentEntries) => {
return reorderArray(currentEntries, fromIndex, toIndex);
});
reorderEntry(String(active.id), fromIndex, toIndex);
}
if (!over?.id || active.id === over.id) {
return;
}
const fromIndex = active.data.current?.sortable.index;
const toIndex = over.data.current?.sortable.index;
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
setSortableData((currentEntries) => {
return reorderArray(currentEntries, fromIndex, toIndex);
});
let destinationId = over.id as EntryId;
let order: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
/**
* We need to specially handle the end blocks
* Dragging before and end block will add the entry to the end of the block
* Dragging after an end block will add the event after the block itself
*/
if (destinationId.startsWith('end-')) {
destinationId = destinationId.replace('end-', '');
// if we are moving before the end, we use the insert operation
order = 'insert';
}
reorderEntry(active.id as EntryId, destinationId, order);
};
/**
@@ -329,7 +351,7 @@ export default function Rundown({ data }: RundownProps) {
};
if (sortableData.length < 1) {
return <RundownEmpty handleAddNew={() => insertAtId({ type: SupportedEntry.Event }, cursor)} />;
return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />;
}
// 1. gather presentation options
@@ -362,6 +384,8 @@ export default function Rundown({ data }: RundownProps) {
if (isBlockCollapsed && isEditMode && isLast) {
return <QuickAddBlock key={entryId} previousEventId={parentId} parentBlock={null} />;
} else if (isBlockCollapsed) {
return null;
} else {
const parentColour = (entries[parentId] as OntimeBlock | undefined)?.colour;
// if the previous element is selected, it will have its own QuickAddBlock
@@ -372,7 +396,7 @@ export default function Rundown({ data }: RundownProps) {
<Fragment key={entryId}>
{showPrependingQuickAdd && (
<QuickAddBlock
previousEventId={rundownMetadata.previousEntryId}
previousEventId={rundownMetadata.thisId}
parentBlock={parentId}
backgroundColor={parentColour}
/>
@@ -1,12 +1,13 @@
import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import { SupportedEntry } from 'ontime-types';
import Empty from '../../common/components/state/Empty';
import style from './Empty.module.scss';
interface RundownEmptyProps {
handleAddNew: () => void;
handleAddNew: (type: SupportedEntry) => void;
}
export default function RundownEmpty(props: RundownEmptyProps) {
@@ -14,10 +15,16 @@ export default function RundownEmpty(props: RundownEmptyProps) {
return (
<div className={style.empty}>
<Empty style={{ marginTop: '7vh', marginBottom: '1.5rem' }} />
<Button onClick={handleAddNew} variant='ontime-filled' leftIcon={<IoAdd />}>
Create Event
</Button>
<Empty style={{ marginTop: '5vh', marginBottom: '3rem' }} />
<div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='ontime-filled' leftIcon={<IoAdd />}>
Create Event
</Button>
<Button onClick={() => handleAddNew(SupportedEntry.Block)} variant='ontime-filled' leftIcon={<IoAdd />}>
Create Block
</Button>
</div>
</div>
);
}
@@ -12,25 +12,24 @@ import {
import { useEntryActions } from '../../common/hooks/useEntryAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
import { useEmitLog } from '../../common/stores/logger';
import { cloneEvent } from '../../common/utils/eventsManager';
import { cloneEvent } from '../../common/utils/clone';
import DelayBlock from './delay-block/DelayBlock';
import EventBlock from './event-block/EventBlock';
import { useEventSelection } from './useEventSelection';
export type EventItemActions =
| 'set-cursor'
| 'event'
| 'event-before'
| 'delay'
| 'delay-before'
| 'block'
| 'block-before'
| 'swap'
| 'delete'
| 'clone'
| 'update'
| 'swap'
| 'clear-report';
| 'group'
| 'update';
interface RundownEntryProps {
type: SupportedEntry;
@@ -66,7 +65,7 @@ export default function RundownEntry(props: RundownEntryProps) {
isLinkedToLoaded,
} = props;
const { emitError } = useEmitLog();
const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, swapEvents } = useEntryActions();
const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions();
const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection();
const removeOpenEvent = useCallback(() => {
@@ -129,6 +128,13 @@ export default function RundownEntry(props: RundownEntryProps) {
addEntry(newEvent, { after: data.id });
break;
}
case 'group': {
if (selectedEvents.size > 1) {
clearMultiSelection();
return groupEntries(Array.from(selectedEvents));
}
break;
}
case 'update': {
// Handles and filters update requests
const { field, value } = payload as FieldValue;
@@ -8,11 +8,14 @@ import { cx } from '../../common/utils/styleUtils';
import { Corner } from '../editors/editor-utils/EditorUtils';
import RundownEventEditor from './event-editor/RundownEventEditor';
import FinderPlacement from './placements/FinderPlacement';
import RundownWrapper from './RundownWrapper';
import style from './RundownExport.module.scss';
const RundownExport = () => {
export default memo(RundownExport);
function RundownExport() {
const isExtracted = window.location.pathname.includes('/rundown');
const appMode = useAppMode((state) => state.mode);
const hideSideBar = isExtracted && appMode === 'run';
@@ -21,6 +24,7 @@ const RundownExport = () => {
return (
<div className={classes} data-testid='panel-rundown'>
<FinderPlacement />
<div className={style.rundown}>
<div className={style.list}>
<ErrorBoundary>
@@ -40,6 +44,4 @@ const RundownExport = () => {
</div>
</div>
);
};
export default memo(RundownExport);
}
@@ -1,6 +1,6 @@
import { OntimeBlock, OntimeEvent, RundownEntries, SupportedEntry } from 'ontime-types';
import { EntryId, OntimeBlock, OntimeDelay, OntimeEvent, RundownEntries, SupportedEntry } from 'ontime-types';
import { makeRundownMetadata, makeSortableList } from '../rundown.utils';
import { makeRundownMetadata, makeSortableList, moveDown, moveUp } from '../rundown.utils';
describe('makeRundownMetadata()', () => {
it('processes nested rundown data', () => {
@@ -21,7 +21,7 @@ describe('makeRundownMetadata()', () => {
block: {
id: 'block',
type: SupportedEntry.Block,
events: ['11', '12', '13'],
events: ['11', 'delay', '12', '13'],
colour: 'red',
} as OntimeBlock,
'11': {
@@ -36,6 +36,12 @@ describe('makeRundownMetadata()', () => {
skip: false,
linkStart: false,
} as OntimeEvent,
delay: {
id: 'delay',
type: SupportedEntry.Delay,
parent: 'block',
duration: 0,
} as OntimeDelay,
'12': {
id: '12',
type: SupportedEntry.Event,
@@ -136,10 +142,25 @@ describe('makeRundownMetadata()', () => {
groupColour: 'red',
});
expect(process(demoEvents['delay'])).toMatchObject({
previousEvent: demoEvents['11'],
latestEvent: demoEvents['11'],
previousEntryId: demoEvents['11'].id,
thisId: demoEvents['delay'].id,
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'block',
groupColour: 'red',
});
expect(process(demoEvents['12'])).toMatchObject({
previousEvent: demoEvents['11'],
latestEvent: demoEvents['12'],
previousEntryId: demoEvents['11'].id,
previousEntryId: demoEvents['delay'].id,
thisId: demoEvents['12'].id,
eventIndex: 3,
isPast: false,
@@ -265,7 +286,7 @@ describe('makeRundownMetadata()', () => {
describe('makeSortableList()', () => {
it('generates a list with block ends', () => {
const flatOrder = ['block-1', '11', '2', 'block-3', '31', 'block-4'];
const order = ['block-1', '2', 'block-3', 'block-4'];
const entries: RundownEntries = {
'block-1': { type: SupportedEntry.Block, id: 'block-1', events: ['11'] } as OntimeBlock,
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
@@ -275,8 +296,8 @@ describe('makeSortableList()', () => {
'block-4': { type: SupportedEntry.Block, id: 'block-4', events: [] as string[] } as OntimeBlock,
};
const sortableList = makeSortableList(flatOrder, entries);
expect(sortableList).toEqual([
const sortableList = makeSortableList(order, entries);
expect(sortableList).toStrictEqual([
'block-1',
'11',
'end-block-1',
@@ -290,25 +311,89 @@ describe('makeSortableList()', () => {
});
it('closes dangling blocks', () => {
const flatOrder = ['block', '11', '12'];
const order = ['block'];
const entries: RundownEntries = {
block: { type: SupportedEntry.Block, id: 'block-1', events: ['11', '12'] } as OntimeBlock,
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
'12': { type: SupportedEntry.Event, id: '12', parent: 'block-1' } as OntimeEvent,
};
const sortableList = makeSortableList(flatOrder, entries);
const sortableList = makeSortableList(order, entries);
expect(sortableList).toStrictEqual(['block-1', '11', '12', 'end-block-1']);
});
it('handles a list with a with just blocks', () => {
const flatOrder = ['block-1', 'block-2'];
const order = ['block-1', 'block-2'];
const entries: RundownEntries = {
'block-1': { type: SupportedEntry.Block, id: 'block-1', events: [] as string[] } as OntimeBlock,
'block-2': { type: SupportedEntry.Block, id: 'block-2', events: [] as string[] } as OntimeBlock,
};
const sortableList = makeSortableList(flatOrder, entries);
const sortableList = makeSortableList(order, entries);
expect(sortableList).toStrictEqual(['block-1', 'end-block-1', 'block-2', 'end-block-2']);
});
});
describe('moveUp()', () => {
const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3'];
const entries = {
event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent,
event2: { type: 'event', id: 'event2', parent: null }as OntimeEvent,
block1: { type: 'block', id: 'block1', events: ['event3'] } as OntimeBlock,
event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent,
block2: { type: 'block', id: 'block2', events: [] as EntryId[] } as OntimeBlock,
event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent,
};
it('moves an event up in the list', () => {
const result = moveUp('event2', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'event1', order: 'before', isBlock: false });
})
it.todo('disallows nesting blocks', () => {
const result = moveUp('block2', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'block1', order: 'before', isBlock: false });
})
it('moves an event into a block', () => {
const result = moveUp('event3', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'block2', order: 'insert', isBlock: true });
})
it('moving up from top is noop', () => {
const result = moveUp('event1', sortableData, entries);
expect(result).toMatchObject({ destinationId: null });
})
});
describe('moveDown()', () => {
const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3'];
const entries = {
event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent,
event2: { type: 'event', id: 'event2', parent: null }as OntimeEvent,
block1: { type: 'block', id: 'block1', events: ['event11'] } as OntimeBlock,
event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent,
block2: { type: 'block', id: 'block2', events: [] as EntryId[] } as OntimeBlock,
event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent,
};
it('moves an event down in the list', () => {
const result = moveDown('event1', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'event2', order: 'after', isBlock: false });
})
it.todo('disallows nesting blocks', () => {
const result = moveDown('block1', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'block2', order: 'before', isBlock: false });
})
it('moves an event into a block', () => {
const result = moveDown('event2', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'event11', order: 'before', isBlock: true });
})
it('moving down from bottom is noop', () => {
const result = moveDown('event3', sortableData, entries);
expect(result).toMatchObject({ destinationId: null });
})
});
@@ -1,9 +1,19 @@
import { useRef } from 'react';
import { IoChevronDown, IoChevronUp, IoReorderTwo } from 'react-icons/io5';
import {
IoChevronDown,
IoChevronUp,
IoDuplicateOutline,
IoFolderOpenOutline,
IoReorderTwo,
IoTrash,
} from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { EntryId, OntimeBlock } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time';
import EditableBlockTitle from '../common/EditableBlockTitle';
@@ -21,6 +31,27 @@ interface BlockBlockProps {
export default function BlockBlock(props: BlockBlockProps) {
const { data, hasCursor, collapsed, onCollapse } = props;
const handleRef = useRef<null | HTMLSpanElement>(null);
const { clone, ungroup, deleteEntry } = useEntryActions();
const [onContextMenu] = useContextMenu<HTMLDivElement>([
{
label: 'Clone Block',
icon: IoDuplicateOutline,
onClick: () => clone(data.id),
},
{
label: 'Ungroup',
icon: IoFolderOpenOutline,
onClick: () => ungroup(data.id),
isDisabled: data.events.length === 0,
},
{
label: 'Delete Block',
icon: IoTrash,
onClick: () => deleteEntry([data.id]),
withDivider: true,
},
]);
const {
attributes: dragAttributes,
@@ -53,6 +84,7 @@ export default function BlockBlock(props: BlockBlockProps) {
<div
className={cx([style.block, hasCursor && style.hasCursor, !collapsed && style.expanded])}
ref={setNodeRef}
onContextMenu={onContextMenu}
style={{
...(binderColours ? { '--user-bg': binderColours.backgroundColor } : {}),
...dragStyle,
@@ -71,9 +103,15 @@ export default function BlockBlock(props: BlockBlockProps) {
<div className={style.header}>
<div className={style.titleRow}>
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
<button onClick={() => onCollapse(!collapsed, data.id)}>
<IconButton
aria-label='Collapse'
onClick={() => onCollapse(!collapsed, data.id)}
color='#e2e2e2' // $gray-200
variant='ontime-ghosted'
size='sm'
>
{collapsed ? <IoChevronUp /> : <IoChevronDown />}
</button>
</IconButton>
</div>
<div className={style.metaRow}>
<div className={style.metaEntry}>
@@ -90,7 +128,7 @@ export default function BlockBlock(props: BlockBlockProps) {
</div>
<div className={style.metaEntry}>
<div>Events</div>
<div>{data.numEvents}</div>
<div>{data.events.length}</div>
</div>
</div>
</div>
@@ -2,6 +2,7 @@ import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react'
import {
IoAdd,
IoDuplicateOutline,
IoFolder,
IoLink,
IoPeople,
IoPeopleOutline,
@@ -26,7 +27,7 @@ import RundownIndicators from './RundownIndicators';
import style from './EventBlock.module.scss';
interface EventBlockProps {
eventId: string;
eventId: EntryId;
cue: string;
timeStart: number;
timeEnd: number;
@@ -144,6 +145,7 @@ export default function EventBlock(props: EventBlockProps) {
value: false,
}),
},
{ withDivider: true, label: 'Group', icon: IoFolder, onClick: () => actionHandler('group') },
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
]
: [
@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
import { OntimeEvent } from 'ontime-types';
import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
@@ -14,7 +14,8 @@ import EventEditorEmpty from './EventEditorEmpty';
import style from './EventEditor.module.scss';
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel;
// any of the titles + custom field labels
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | string;
interface EventEditorProps {
event: OntimeEvent;
@@ -5,8 +5,8 @@ import { sanitiseCue } from 'ontime-utils';
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
import * as Editor from '../../../editors/editor-utils/EditorUtils';
import { type EditorUpdateFields } from '../EventEditor';
import RichTextEditor from '../../../../common/components/input/rich-text-editor/RichTextEditor';
import EventTextArea from './EventTextArea';
import EventTextInput from './EventTextInput';
import style from '../EventEditor.module.scss';
@@ -49,7 +49,10 @@ const EventEditorTitles = (props: EventEditorTitlesProps) => {
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
</div>
<EventTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<EventTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
<div>
<Editor.Label>Note</Editor.Label>
<RichTextEditor initialValue={note} onChange={(newNote) => handleSubmit('note', newNote)} />
</div>
</div>
);
};
@@ -14,12 +14,12 @@ import style from './EventEditorTriggers.module.scss';
interface EventEditorTriggersProps {
eventId: string;
triggers?: Trigger[];
triggers: Trigger[];
}
export default function EventEditorTriggers(props: EventEditorTriggersProps) {
const { triggers, eventId } = props;
const showTriggers = triggers !== undefined && triggers.length > 0;
const showTriggers = triggers.length > 0;
return (
<>
@@ -72,7 +72,6 @@ function EventTriggerForm(props: EventTriggerFormProps) {
variant='ontime'
value={cycleValue}
onChange={(e) => setCycleValue(e.target.value as TimerLifeCycle)}
defaultValue={TimerLifeCycle.onStart}
>
<option disabled>Lifecycle Trigger</option>
{eventTriggerOptions.map((cycle) => (
@@ -1,46 +0,0 @@
import { type CSSProperties, useCallback, useRef } from 'react';
import { AutoTextArea } from '../../../../common/components/input/auto-text-area/AutoTextArea';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
import * as Editor from '../../../editors/editor-utils/EditorUtils';
import { EditorUpdateFields } from '../EventEditor';
interface CountedTextAreaProps {
className?: string;
field: EditorUpdateFields;
label: string;
initialValue: string;
style?: CSSProperties;
submitHandler: (field: EditorUpdateFields, value: string) => void;
}
export default function EventTextArea(props: CountedTextAreaProps) {
const { className, field, label, initialValue, style: givenStyles, submitHandler } = props;
const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
submitOnCtrlEnter: true,
});
return (
<div>
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
{label}
</Editor.Label>
<AutoTextArea
id={field}
inputref={ref}
rows={1}
size='sm'
resize='none'
variant='ontime-filled'
data-testid='input-textarea'
value={value}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
/>
</div>
);
}
@@ -0,0 +1,22 @@
import { memo } from 'react';
import { useDisclosure } from '@chakra-ui/react';
import { useHotkeys } from '@mantine/hooks';
import Finder from '../../editors/finder/Finder';
export default memo(FinderPlacement);
function FinderPlacement() {
const { isOpen, onToggle, onClose } = useDisclosure();
useHotkeys([
['mod + f', onToggle],
['Escape', onClose],
]);
if (isOpen) {
return <Finder isOpen={isOpen} onClose={onClose} />;
}
return null;
}
+114 -29
View File
@@ -126,44 +126,29 @@ function processEntry(
* Due to limitations in dnd-kit we need to flatten the list of entries
* This list should also be aware of any elements that are sortable (ie: block ends)
*/
export function makeSortableList(flatOrder: EntryId[], entries: RundownEntries): EntryId[] {
const entryIds: EntryId[] = [];
let lastSeenBlock: MaybeString = null;
export function makeSortableList(order: EntryId[], entries: RundownEntries): EntryId[] {
const flatIds: EntryId[] = [];
for (let i = 0; i < flatOrder.length; i++) {
const entry = entries[flatOrder[i]];
for (let i = 0; i < order.length; i++) {
const entry = entries[order[i]];
if (!entry) {
continue;
}
if (isOntimeBlock(entry)) {
// close any previous blocks
if (lastSeenBlock !== null) {
entryIds.push(`end-${lastSeenBlock}`);
}
lastSeenBlock = entry.id;
// inside a block there are delays and events
// there is no need for special handling
flatIds.push(entry.id);
flatIds.push(...entry.events);
// close the block
flatIds.push(`end-${entry.id}`);
} else {
flatIds.push(entry.id);
}
if (isOntimeEvent(entry)) {
// Close the previous block if the parent changes
if (lastSeenBlock !== null && entry.parent !== lastSeenBlock) {
entryIds.push(`end-${lastSeenBlock}`);
}
lastSeenBlock = entry.parent;
}
entryIds.push(entry.id);
}
// double check that we close any dangling blocks
// - if the last element is a block
// - if a rundown only has a top level block
if (lastSeenBlock !== null) {
entryIds.push(`end-${lastSeenBlock}`);
}
return entryIds;
return flatIds;
}
/**
@@ -178,3 +163,103 @@ export function canDrop(targetType?: SupportedEntry, targetParent?: EntryId | nu
// we can swap places with other blocks
return targetType == 'block';
}
/**
* Calculates destinations for an entry moving one position up in the rundown
* - Handles noops
* - Handles moving in and out of blocks
* TODO: handle moving blocks
*/
export function moveUp(entryId: EntryId, sortableData: EntryId[], entries: RundownEntries) {
const previousEntryId = getPreviousId(entryId, sortableData);
// the user is moving up at the top of the list
if (!previousEntryId) {
return { destinationId: null, order: 'before', isBlock: false };
}
if (previousEntryId.startsWith('end-')) {
const entry = entries[entryId];
if (isOntimeBlock(entry)) {
// if we are moving a block, we cannot insert it
return { destinationId: previousEntryId.replace('end-', ''), order: 'before', isBlock: false };
}
// insert in the block ID will add to the end of the block events
return { destinationId: previousEntryId.replace('end-', ''), order: 'insert', isBlock: true };
}
// @ts-expect-error -- we safeguard the entry not having a parent property
return { destinationId: previousEntryId, order: 'before', isBlock: Boolean(entries[previousEntryId]?.parent) };
}
/**
* Calculates destinations for an entry moving one position down in the rundown
* - Handles noops
* - Handles moving in and out of blocks
* TODO: handle moving blocks
*/
export function moveDown(entryId: EntryId, sortableData: EntryId[], entries: RundownEntries) {
const nextEntryId = getNextId(entryId, sortableData);
// the user is moving down at the end of the list
if (!nextEntryId) {
return { destinationId: null, order: 'after', isBlock: false };
}
if (nextEntryId.startsWith('end-')) {
// move outside the block
return { destinationId: nextEntryId.replace('end-', ''), order: 'after', isBlock: false };
}
/**
* If the next entry is a block
* - 1. blocks need to skip over it
* - 2. if the block has children, we insert before the first child
* - 3. if the block is empty, we insert into the block
*/
if (isOntimeBlock(entries[nextEntryId])) {
const entry = entries[entryId];
if (isOntimeBlock(entry)) {
// 1. if we are moving a block, we cannot insert it
return { destinationId: nextEntryId, order: 'after', isBlock: false };
}
const firstBlockChild = entries[nextEntryId].events.at(0);
if (firstBlockChild) {
// 2. add before the first child of the block
return { destinationId: firstBlockChild, order: 'before', isBlock: true };
} else {
// 3. or insert into an empty block
return { destinationId: nextEntryId, order: 'insert', isBlock: true };
}
}
return { destinationId: nextEntryId, order: 'after', isBlock: Boolean(entries[nextEntryId]?.parent) };
}
/**
* Utility function gets the ID if the next entry in the list
* returns null if none is found
*/
function getNextId(entryId: EntryId, sortableData: EntryId[]): EntryId | null {
const currentIndex = sortableData.indexOf(entryId);
if (currentIndex === -1 || currentIndex === sortableData.length - 1) {
// No next ID if not found or at the end
return null;
}
return sortableData[currentIndex + 1];
}
/**
* Utility function gets the ID if the previous entry in the list
* returns null if none is found
*/
function getPreviousId(entryId: EntryId, sortableData: EntryId[]): EntryId | null {
const currentIndex = sortableData.indexOf(entryId);
if (currentIndex < 1) {
// No previous ID found or at the beginning
return null;
}
return sortableData[currentIndex - 1];
}
@@ -9,13 +9,13 @@ import { isMacOS } from '../../common/utils/deviceUtils';
type SelectionMode = 'shift' | 'click' | 'ctrl';
interface EventSelectionStore {
selectedEvents: Set<string>;
selectedEvents: Set<EntryId>;
anchoredIndex: MaybeNumber;
cursor: MaybeString;
setSelectedEvents: (selectionArgs: { id: string; index: number; selectMode: SelectionMode }) => void;
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
clearMultiSelect: () => void;
unselect: (id: string) => void;
unselect: (id: EntryId) => void;
}
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
+1
View File
@@ -27,6 +27,7 @@ export const ontimeModal = {
footer: {
padding: '1rem',
display: 'flex',
alignItems: 'left',
gap: '0.5rem',
},
};
@@ -96,11 +96,12 @@ describe('makeTable()', () => {
"Is Public? (x)",
"Skip?",
"lighting",
"Type",
],
[
"00:00:00",
"00:00:00",
"...",
"",
"",
"",
"",
@@ -109,6 +110,7 @@ describe('makeTable()', () => {
"x",
"",
"",
"",
],
]
`);
@@ -3,7 +3,7 @@ import { MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
import { isOntimeEvent, SupportedEntry } from 'ontime-types';
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { cloneEvent } from '../../../../common/utils/eventsManager';
import { cloneEvent } from '../../../../common/utils/clone';
interface CuesheetTableMenuActionsProps {
eventId: string;
@@ -13,7 +13,7 @@ interface CuesheetTableMenuActionsProps {
export default function CuesheetTableMenuActions(props: CuesheetTableMenuActionsProps) {
const { eventId, entryIndex, showModal } = props;
const { addEntry, getEntryById, reorderEntry, deleteEntry } = useEntryActions();
const { addEntry, getEntryById, move, deleteEntry } = useEntryActions();
const handleCloneEvent = () => {
const currentEvent = getEntryById(eventId);
@@ -45,14 +45,10 @@ export default function CuesheetTableMenuActions(props: CuesheetTableMenuActions
Clone event
</MenuItem>
<MenuDivider />
<MenuItem
isDisabled={entryIndex < 1}
icon={<IoArrowUp />}
onClick={() => reorderEntry(eventId, entryIndex, entryIndex - 1)}
>
<MenuItem isDisabled={entryIndex < 1} icon={<IoArrowUp />} onClick={() => move(eventId, 'up')}>
Move up
</MenuItem>
<MenuItem icon={<IoArrowDown />} onClick={() => reorderEntry(eventId, entryIndex, entryIndex + 1)}>
<MenuItem icon={<IoArrowDown />} onClick={() => move(eventId, 'down')}>
Move down
</MenuItem>
<MenuItem icon={<IoTrash />} onClick={() => deleteEntry([eventId])}>
@@ -20,7 +20,7 @@ type CsvHeaderKey = OntimeEntryCommonKeys | keyof CustomFields;
export const parseField = (field: CsvHeaderKey, data: unknown): string => {
if (field === 'timeStart' || field === 'timeEnd' || field === 'duration') {
return millisToString(data as MaybeNumber);
return millisToString(data as MaybeNumber, { fallback: '' });
}
if (field === 'isPublic' || field === 'skip') {
@@ -55,6 +55,7 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeEntry[], custo
'isPublic',
'skip',
...customFieldKeys,
'type',
];
const fieldTitles = [
@@ -69,6 +70,7 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeEntry[], custo
'Is Public? (x)',
'Skip?',
...customFieldLabels,
'Type',
];
// add header row to data
@@ -25,7 +25,9 @@
flex: 1;
max-height: 100%;
overflow-y: auto;
width: min(calc(100vw - 4rem), 800px);
width: min(calc(100vw - 4rem), 960px);
padding-bottom: 10vh;
}
.info__label {
@@ -33,11 +35,15 @@
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
margin-top: $view-element-gap;
white-space: pre;
}
.info__value {
white-space: break-spaces;
}
a.info__value {
color: $action-text-color;
&:hover {
color: $ontime-color;
}
@@ -8,6 +8,7 @@ import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { useTranslation } from '../../translation/TranslationProvider';
import BackstageInfo from './backstage-info/BackstageInfo';
import CustomInfo from './custom-info/CustomInfo';
import PublicInfo from './public-info/PublicInfo';
import { projectInfoOptions } from './projectInfo.options';
@@ -66,6 +67,7 @@ export default function ProjectInfo(props: ProjectInfoProps) {
)}
<BackstageInfo general={general} />
<PublicInfo general={general} />
<CustomInfo general={general} />
</div>
</div>
);
@@ -0,0 +1,36 @@
import { Fragment } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ProjectData } from 'ontime-types';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
interface CustomInfoProps {
general: ProjectData;
}
export default function CustomInfo(props: CustomInfoProps) {
const { general } = props;
const [searchParams] = useSearchParams();
const showCustom = isStringBoolean(searchParams.get('showCustom'));
if (!showCustom || general.custom === undefined || general.custom.length === 0) {
return null;
}
return (
<>
{general.custom.map((info, idx) => {
if (!info.title || !info.value) {
return null;
}
return (
<Fragment key={`${info.title}-${idx}`}>
<div className='info__label'>{info.title}</div>
<div className='info__value'>{info.value}</div>
</Fragment>
);
})}
</>
);
}
@@ -9,14 +9,21 @@ export const projectInfoOptions: ViewOption[] = [
{
id: 'showBackstage',
title: 'Show backstage Data',
description: 'Weather to show fields related to the backstage views',
description: 'Whether to show fields related to the backstage views',
type: 'boolean',
defaultValue: false,
},
{
id: 'showPublic',
title: 'Show Public Data',
description: 'Weather to show fields related to the public views',
description: 'Whether to show fields related to the public views',
type: 'boolean',
defaultValue: false,
},
{
id: 'showCustom',
title: 'Show Custom Data',
description: 'Whether to show fields related to the custom data',
type: 'boolean',
defaultValue: false,
},
+3 -33
View File
@@ -1,4 +1,3 @@
import { AnimatePresence } from 'framer-motion';
import {
CustomFields,
MessageState,
@@ -11,6 +10,7 @@ import {
import { FitText } from '../../common/components/fit-text/FitText';
import MultiPartProgressBar from '../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import TitleCard from '../../common/components/title-card/TitleCard';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
@@ -21,7 +21,6 @@ import SuperscriptTime from '../../features/viewers/common/superscript-time/Supe
import { getFormattedTimer, getTimerByType } from '../../features/viewers/common/viewUtils';
import { useTranslation } from '../../translation/TranslationProvider';
import { MotionTitleCard, titleVariants } from './timer.animations';
import { getTimerOptions, useTimerOptions } from './timer.options';
import {
getCardData,
@@ -189,37 +188,8 @@ export default function Timer(props: TimerProps) {
{!hideCards && (
<>
<AnimatePresence>
{showNow && (
<MotionTitleCard
className='event now'
key='now'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
label='now'
title={nowMain}
secondary={nowSecondary}
/>
)}
</AnimatePresence>
<AnimatePresence>
{showNext && (
<MotionTitleCard
className='event next'
key='next'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
label='next'
title={nextMain}
secondary={nextSecondary}
/>
)}
</AnimatePresence>
{showNow && <TitleCard className='event now' label='now' title={nowMain} secondary={nowSecondary} />}
{showNext && <TitleCard className='event next' label='next' title={nextMain} secondary={nextSecondary} />}
</>
)}
</div>
@@ -1,20 +0,0 @@
import { motion } from 'framer-motion';
import TitleCard from '../../common/components/title-card/TitleCard';
export const titleVariants = {
hidden: {
x: -2500,
},
visible: {
x: 0,
transition: {
duration: 1,
},
},
exit: {
x: -2500,
},
};
export const MotionTitleCard = motion(TitleCard);
+32 -3
View File
@@ -39,6 +39,29 @@ export default defineConfig({
],
server: {
port: 3000,
proxy: {
'^/login*': {
target: 'http://localhost:4001/',
changeOrigin: true,
configure: logProxyRequests,
},
'^/data*': {
target: 'http://localhost:4001/',
changeOrigin: true,
configure: logProxyRequests,
},
'^/api*': {
target: 'http://localhost:4001/',
changeOrigin: true,
configure: logProxyRequests,
},
'^/ws*': {
target: 'http://localhost:4001/',
changeOrigin: true,
configure: logProxyRequests,
ws: true,
},
},
},
test: {
globals: true,
@@ -70,11 +93,17 @@ export default defineConfig({
preprocessorOptions: {
scss: {
additionalData: `
@use './src/theme/ontimeColours' as *;
@use './src/theme/ontimeStyles' as *;
@use './src/theme/mixins' as *;
@use '@/theme/ontimeColours' as *;
@use '@/theme/ontimeStyles' as *;
@use '@/theme/mixins' as *;
`,
},
},
},
});
function logProxyRequests(proxy) {
proxy.on('proxyReq', (_proxyReq, req, _res) => {
console.log('Proxy:', req.method, req.url);
});
}
+3 -5
View File
@@ -12,8 +12,8 @@
"license": "AGPL-3.0-only",
"main": "src/main.js",
"devDependencies": {
"electron": "^31.2.0",
"electron-builder": "^24.13.3",
"electron": "36.3.1",
"electron-builder": "26.0.12",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"prettier": "catalog:",
@@ -38,9 +38,7 @@
"icon": "icon.icns"
},
"mac": {
"notarize": {
"teamId": "MDAU6QK6R4"
},
"notarize": true,
"hardenedRuntime": true,
"gatekeeperAssess": false,
"entitlements": "./entitlements.plist",
+11 -11
View File
@@ -6,18 +6,18 @@
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cookie": "1.0.2",
"cookie-parser": "1.4.7",
"cors": "2.8.5",
"dotenv": "^16.0.1",
"express": "^4.21.1",
"express-static-gzip": "^2.2.0",
"express-validator": "^7.2.0",
"express": "5.1.0",
"express-static-gzip": "3.0.0",
"express-validator": "7.2.1",
"multer": "2.0.1",
"fast-equals": "^5.0.1",
"google-auth-library": "^9.4.2",
"got": "^14.4.5",
"lowdb": "^7.0.1",
"multer": "^1.4.5-lts.1",
"ontime-utils": "workspace:*",
"osc-min": "2.1.2",
"sanitize-filename": "^1.6.3",
@@ -26,10 +26,10 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.17",
"@types/multer": "^1.4.11",
"@types/cookie-parser": "1.4.9",
"@types/cors": "2.8.19",
"@types/express": "5.0.3",
"@types/multer": "1.4.13",
"@types/node": "catalog:",
"@types/websocket": "^1.0.5",
"@types/ws": "^8.5.10",
+1 -1
View File
@@ -5,7 +5,7 @@ import * as dgram from 'node:dgram';
import { logger } from '../classes/Logger.js';
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
import { isOntimeCloud } from '../externals.js';
import { isOntimeCloud } from '../setup/environment.js';
import { integrationPayloadFromPath } from './utils/parse.js';
import type { IAdapter } from './IAdapter.js';
+5
View File
@@ -1,5 +1,10 @@
import { socket } from './WebsocketAdapter.js';
export enum RefetchTargets {
Rundown = 'rundown',
Report = 'report',
}
/**
* Utility function to notify clients that the REST data is stale
* @param payload -- possible patch payload
@@ -1,41 +0,0 @@
import { defaultCss } from '../../user/styles/bundledCss.js';
import type { Request, Response } from 'express';
import { readCssFile, writeCssFile } from './assets.service.js';
/**
* Exposes the contents of the cssOverride.css file
*/
export async function getCssOverride(_req: Request, res: Response) {
try {
const data = await readCssFile();
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: error });
}
}
/**
* Allows modifying the cssOverride.css file
*/
export async function postCssOverride(req: Request, res: Response) {
const { css } = req.body;
try {
await writeCssFile(css);
res.status(204).send();
} catch (error) {
res.status(500).send({ message: error });
}
}
/**
* Restores the default cssOverride.css file
*/
export async function restoreCss(_req: Request, res: Response) {
try {
await writeCssFile(defaultCss);
res.status(200).send(defaultCss);
} catch (error) {
res.status(500).send({ message: error });
}
}
@@ -1,10 +1,40 @@
import express from 'express';
import { getCssOverride, postCssOverride, restoreCss } from './assets.controller.js';
import type { Request, Response } from 'express';
import type { ErrorResponse } from 'ontime-types';
import { validatePostCss } from './assets.validation.js';
import { readCssFile, writeCssFile } from './assets.service.js';
import { getErrorMessage } from 'ontime-utils';
import { defaultCss } from '../../user/styles/bundledCss.js';
export const router = express.Router();
router.get('/css', getCssOverride);
router.post('/css', validatePostCss, postCssOverride);
router.post('/css/restore', restoreCss);
router.get('/css', async (_req: Request, res: Response<string | ErrorResponse>) => {
try {
const data = await readCssFile();
res.status(200).send(data);
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
});
router.post('/css', validatePostCss, async (req: Request, res: Response<never | ErrorResponse>) => {
const { css } = req.body;
try {
await writeCssFile(css);
res.status(204).send();
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
});
router.post('/css/restore', async (_req: Request, res: Response<string | ErrorResponse>) => {
try {
await writeCssFile(defaultCss);
res.status(200).send(defaultCss);
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
});
@@ -1,12 +1,4 @@
import { Request, Response, NextFunction } from 'express';
import { body, validationResult } from 'express-validator';
import { body } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
export const validatePostCss = [
body('css').exists().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validatePostCss = [body('css').isString().trim(), requestValidationFunction];
@@ -1,4 +1,6 @@
import { TriggerDTO, TimerLifeCycle, AutomationDTO, Automation } from 'ontime-types';
import { TriggerDTO, TimerLifeCycle, AutomationDTO, Automation, EntryId } from 'ontime-types';
import { makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
import {
addTrigger,
@@ -12,6 +14,7 @@ import {
getAutomationTriggers,
getAutomations,
} from '../automation.dao.js';
import { makeOSCAction, makeHTTPAction } from './testUtils.js';
beforeAll(() => {
@@ -186,11 +189,9 @@ describe('editAutomation()', async () => {
});
describe('deleteAutomation()', () => {
// saving the ID of the added automation
let firstAutomation: Automation;
beforeEach(async () => {
await deleteAll();
firstAutomation = await addAutomation({
await addAutomation({
title: 'test-osc',
filterRule: 'all',
filters: [],
@@ -198,35 +199,15 @@ describe('deleteAutomation()', () => {
});
});
it('should remove m automation from the list', async () => {
it('should remove an automation from the list', async () => {
const automations = getAutomations();
expect(Object.keys(automations).length).toEqual(1);
await deleteAutomation(Object.keys(automations)[0]);
const rundown = makeRundown({});
const timedEventOrder: EntryId[] = [];
await deleteAutomation(rundown, timedEventOrder, Object.keys(automations)[0]);
const removed = getAutomations();
expect(Object.keys(removed).length).toEqual(0);
});
it('should not remove an automation which is in use', async () => {
const automations = getAutomations();
await addTrigger({
title: 'test-automation',
trigger: TimerLifeCycle.onLoad,
automationId: firstAutomation.id,
});
const automationKeys = Object.keys(automations);
const automationId = automationKeys[0];
expect(automationId).toEqual(firstAutomation.id);
expect(automationKeys.length).toEqual(1);
expect(automations[automationId]).toMatchObject({
id: automationId,
title: 'test-osc',
filterRule: 'all',
filters: expect.any(Array),
outputs: expect.any(Array),
});
await expect(deleteAutomation(automationId)).rejects.toThrowError();
});
});
@@ -1,7 +1,7 @@
import { PlayableEvent, TimerLifeCycle } from 'ontime-types';
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
import { makeOntimeEvent } from '../../../services/rundown-service/__mocks__/rundown.mocks.js';
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
import { deleteAllTriggers, addTrigger, addAutomation } from '../automation.dao.js';
import { testConditions, triggerAutomations } from '../automation.service.js';
@@ -1,4 +1,6 @@
import { parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
import { TimerLifeCycle } from 'ontime-types';
import { makeOntimeEvent, makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
import { isAutomationUsed, parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
describe('parseTemplateNested()', () => {
it('parses string with a single-level variable name', () => {
@@ -245,3 +247,53 @@ describe('test stringToOSCArgs()', () => {
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
});
describe('isAutomationUsed()', () => {
it('returns the first event which uses an automation', () => {
const rundown = makeRundown({
entries: {
'1': makeOntimeEvent({
id: '1',
triggers: [
{
id: 'trigger-1',
title: 'Trigger 1',
trigger: TimerLifeCycle.onClock,
automationId: 'test-automation',
},
],
}),
},
});
const timedEventOrder = ['1'];
const automationId = 'test-automation';
const result = isAutomationUsed(rundown, timedEventOrder, automationId);
expect(result).toBe('1');
});
it('returns returns undefined if there are no matches', () => {
const rundown = makeRundown({
entries: {
'1': makeOntimeEvent({
id: '1',
triggers: [
{
id: 'trigger-1',
title: 'Trigger 1',
trigger: TimerLifeCycle.onClock,
automationId: 'test-automation',
},
],
}),
},
});
const timedEventOrder = ['1'];
const automationId = 'does-not-exist';
const result = isAutomationUsed(rundown, timedEventOrder, automationId);
expect(result).toBeUndefined();
});
});
@@ -5,12 +5,14 @@ import type { Request, Response } from 'express';
import { oscServer } from '../../adapters/OscAdapter.js';
import { getCurrentRundown, getRundownMetadata } from '../rundown/rundown.dao.js';
import * as automationDao from './automation.dao.js';
import * as automationService from './automation.service.js';
import { parseOutput } from './automation.validation.js';
export function getAutomationSettings(_req: Request, res: Response<AutomationSettings>) {
res.json(automationDao.getAutomationSettings());
res.status(200).json(automationDao.getAutomationSettings());
}
export async function postAutomationSettings(req: Request, res: Response<AutomationSettings | ErrorResponse>) {
@@ -106,7 +108,10 @@ export async function editAutomation(req: Request, res: Response<Automation | Er
export async function deleteAutomation(req: Request, res: Response<void | ErrorResponse>) {
try {
await automationDao.deleteAutomation(req.params.id);
const rundown = getCurrentRundown();
const { timedEventOrder } = getRundownMetadata();
await automationDao.deleteAutomation(rundown, timedEventOrder, req.params.id);
res.status(204).send();
} catch (error) {
const message = getErrorMessage(error);
@@ -2,14 +2,17 @@ import type {
Automation,
AutomationDTO,
AutomationSettings,
EntryId,
NormalisedAutomation,
Rundown,
Trigger,
TriggerDTO,
} from 'ontime-types';
import { deleteAtIndex, generateId } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { getTimedEvents } from '../../services/rundown-service/rundownUtils.js';
import { isAutomationUsed } from './automation.utils.js';
/**
* Gets a copy of the stored automation settings
@@ -133,7 +136,7 @@ export async function editAutomation(id: string, newAutomation: AutomationDTO):
/**
* Deletes a automation given its ID
*/
export async function deleteAutomation(id: string): Promise<void> {
export async function deleteAutomation(rundown: Rundown, timedEventOrder: EntryId[], id: string): Promise<void> {
const automations = getAutomations();
// ignore request if automation does not exist
if (!Object.hasOwn(automations, id)) {
@@ -149,13 +152,9 @@ export async function deleteAutomation(id: string): Promise<void> {
}
// prevent deleting a automation that is in use in events
const events = getTimedEvents().filter(
(event) => event.triggers && event.triggers.some((trigger) => trigger.automationId === id),
);
if (events.length) {
throw new Error(
`Unable to delete automation used in event: ${events[0].id}${events.length > 1 ? ` and ${events.length - 1} more` : ''}`,
);
const isInUse = isAutomationUsed(rundown, timedEventOrder, id);
if (isInUse) {
throw new Error(`Unable to delete automation used in event with ID ${isInUse}`);
}
delete automations[id];
@@ -1,7 +1,7 @@
import { DatabaseModel, AutomationSettings, NormalisedAutomation, Trigger } from 'ontime-types';
import { dbModel } from '../../models/dataModel.js';
import type { ErrorEmitter } from '../../utils/parser.js';
import type { ErrorEmitter } from '../../utils/parserUtils.js';
interface LegacyData extends Partial<DatabaseModel> {
http?: unknown;
@@ -12,7 +12,6 @@ import {
testOutput,
} from './automation.controller.js';
import {
paramContainsId,
validateAutomationSettings,
validateAutomation,
validateAutomationPatch,
@@ -20,6 +19,7 @@ import {
validateTrigger,
validateTriggerPatch,
} from './automation.validation.js';
import { paramsWithId } from '../validation-utils/validationFunction.js';
export const router = express.Router();
@@ -28,10 +28,10 @@ router.post('/', validateAutomationSettings, postAutomationSettings);
router.post('/trigger', validateTrigger, postTrigger);
router.put('/trigger/:id', validateTriggerPatch, putTrigger);
router.delete('/trigger/:id', paramContainsId, deleteTrigger);
router.delete('/trigger/:id', paramsWithId, deleteTrigger);
router.post('/automation', validateAutomation, postAutomation);
router.put('/automation/:id', validateAutomationPatch, editAutomation);
router.delete('/automation/:id', paramContainsId, deleteAutomation);
router.delete('/automation/:id', paramsWithId, deleteAutomation);
router.post('/test', validateTestPayload, testOutput);
@@ -12,7 +12,7 @@ import { getPropertyFromPath } from 'ontime-utils';
import { logger } from '../../classes/Logger.js';
import { getState, type RuntimeState } from '../../stores/runtimeState.js';
import { isOntimeCloud } from '../../externals.js';
import { isOntimeCloud } from '../../setup/environment.js';
import { emitOSC } from './clients/osc.client.js';
import { emitHTTP } from './clients/http.client.js';
@@ -1,4 +1,4 @@
import { FilterRule, MaybeNumber, OntimeAction } from 'ontime-types';
import { EntryId, FilterRule, isOntimeEvent, MaybeNumber, OntimeAction, Rundown } from 'ontime-types';
import { millisToString, removeLeadingZero, splitWhitespace, getPropertyFromPath } from 'ontime-utils';
import type { OscArgOrArrayInput, OscArgInput } from 'osc-min';
@@ -195,3 +195,25 @@ export function isBooleanEquals(a: boolean, b: string): boolean {
}
return false;
}
/**
* Checks is an automation is used in a rundown
* TODO(v4): this currently only checks the current rundown, we will need to check all rundowns in the future
*/
export function isAutomationUsed(
rundown: Rundown,
timedEventOrder: EntryId[],
automationId: string,
): EntryId | undefined {
for (let i = 0; i < timedEventOrder.length; i++) {
const eventId = timedEventOrder[i];
const event = rundown.entries[eventId];
if (isOntimeEvent(event) && event.triggers) {
for (const trigger of event.triggers) {
if (trigger.automationId === automationId) {
return eventId;
}
}
}
}
}
@@ -10,84 +10,50 @@ import {
} from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
import type { Request, Response, NextFunction } from 'express';
import { body, oneOf, param, validationResult } from 'express-validator';
import { body, oneOf, param } from 'express-validator';
import * as assert from '../../utils/assert.js';
import { isFilterOperator, isFilterRule, isOntimeActionAction } from './automation.utils.js';
export const paramContainsId = [
param('id').exists(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
export const validateAutomationSettings = [
body('enabledAutomations').exists().isBoolean(),
body('enabledOscIn').exists().isBoolean(),
body('oscPortIn').exists().isPort(),
body('enabledAutomations').isBoolean(),
body('enabledOscIn').isBoolean(),
body('oscPortIn').isPort(),
body('triggers').optional().isArray(),
body('triggers.*.title').optional().isString().trim(),
body('triggers.*.trigger').optional().isIn(timerLifecycleValues),
body('triggers.*.automationId').optional().isString().trim(),
body('automations').optional().custom(parseAutomation),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
requestValidationFunction,
];
export const validateTrigger = [
body('title').exists().isString().trim(),
body('trigger').exists().isIn(timerLifecycleValues),
body('automationId').exists().isString().trim(),
body('title').isString().trim().notEmpty(),
body('trigger').isIn(timerLifecycleValues),
body('automationId').isString().trim().notEmpty(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
requestValidationFunction,
];
export const validateTriggerPatch = [
param('id').exists(),
body('title').optional().isString().trim(),
param('id').isString().notEmpty(),
body('title').optional().isString().trim().notEmpty(),
body('trigger').optional().isIn(timerLifecycleValues),
body('automationId').optional().isString().trim(),
body('automationId').optional().isString().trim().notEmpty(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
requestValidationFunction,
];
export const validateAutomation = [
body().custom(parseAutomation),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateAutomation = [body().custom(parseAutomation), requestValidationFunction];
export const validateAutomationPatch = [
param('id').exists(),
param('id').isString().notEmpty(),
body().custom(parseAutomation),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
requestValidationFunction,
];
/**
@@ -140,7 +106,7 @@ function validateOutput(output: Array<unknown>): output is AutomationOutput[] {
}
export const validateTestPayload = [
body('type').exists().isIn(['osc', 'http', 'ontime']),
body('type').isIn(['osc', 'http', 'ontime']),
// validation for OSC message
oneOf([
@@ -162,11 +128,7 @@ export const validateTestPayload = [
body('visible').if(body('type').equals('ontime')).optional().isString().trim(),
body('secondarySource').if(body('type').equals('ontime')).optional().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
requestValidationFunction,
];
/**
@@ -1,81 +1,6 @@
import { CustomFields, Settings, URLPreset } from 'ontime-types';
import { CustomFields } from 'ontime-types';
import {
parseCustomFields,
parseProject,
parseSettings,
parseUrlPresets,
parseViewSettings,
sanitiseCustomFields,
} from '../parserFunctions.js';
describe('parseProject()', () => {
it('returns an a base model if nothing is given', () => {
const errorEmitter = vi.fn();
const result = parseProject({}, errorEmitter);
expect(result).toBeTypeOf('object');
expect(errorEmitter).toHaveBeenCalledOnce();
expect(result).toMatchObject({
title: '',
description: '',
publicUrl: '',
publicInfo: '',
backstageUrl: '',
backstageInfo: '',
projectLogo: null,
});
});
});
describe('parseSettings()', () => {
it('throws if settings object does not exist', () => {
expect(() => parseSettings({})).toThrow();
});
it('returns an a base model as long as we have the app version', () => {
const result = parseSettings({ settings: { version: '1' } as Settings });
expect(result).toBeTypeOf('object');
expect(result).toMatchObject({
version: expect.any(String),
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
});
});
});
describe('parseViewSettings()', () => {
it('returns an a base model if nothing is given', () => {
const errorEmitter = vi.fn();
const result = parseViewSettings({}, errorEmitter);
expect(result).toBeTypeOf('object');
expect(errorEmitter).toHaveBeenCalledOnce();
});
});
describe('parseUrlPresets()', () => {
it('returns an a base model if nothing is given', () => {
const errorEmitter = vi.fn();
const result = parseUrlPresets({}, errorEmitter);
expect(result).toBeTypeOf('object');
expect(errorEmitter).toHaveBeenCalledOnce();
});
it('parses data, skipping invalid results', () => {
const errorEmitter = vi.fn();
const urlPresets = [{ enabled: true, alias: 'alias', pathAndParams: 'ss' }] as URLPreset[];
const result = parseUrlPresets({ urlPresets }, errorEmitter);
expect(result.length).toEqual(1);
expect(result.at(0)).toMatchObject({
enabled: true,
alias: 'alias',
pathAndParams: 'ss',
});
expect(errorEmitter).not.toHaveBeenCalled();
});
});
import { parseCustomFields, sanitiseCustomFields } from '../customFields.parser.js';
describe('parseCustomFields()', () => {
it('returns an a base model if nothing is given', () => {
@@ -1,51 +0,0 @@
import { CustomField, CustomFields, ErrorResponse } from 'ontime-types';
import type { Request, Response } from 'express';
import { getErrorMessage } from 'ontime-utils';
import {
createCustomField,
editCustomField,
getCustomFields as getCustomFieldsFromCache,
removeCustomField,
} from '../../services/rundown-service/rundownCache.js';
export async function getCustomFields(_req: Request, res: Response<CustomFields>) {
const customFields = getCustomFieldsFromCache();
res.json(customFields);
}
export async function postCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
try {
const newField = req.body as CustomField;
const allFields = await createCustomField(newField);
res.status(201).send(allFields);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function putCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
try {
const oldLabel = req.params.label;
const { colour, type, label } = req.body;
const newFields = await editCustomField(oldLabel, { label, colour, type });
res.status(200).send(newFields);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
// Expects { label: <label> }
export async function deleteCustomField(req: Request, res: Response<CustomFields | ErrorResponse>) {
try {
const fieldToDelete = req.params.label;
await removeCustomField(fieldToDelete);
res.sendStatus(204);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
@@ -0,0 +1,64 @@
import { DatabaseModel, CustomFields, CustomField } from 'ontime-types';
import { isAlphanumericWithSpace, customFieldLabelToKey } from 'ontime-utils';
import type { ErrorEmitter } from '../../utils/parserUtils.js';
/**
* Parse customFields entry
*/
export function parseCustomFields(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): CustomFields {
if (typeof data.customFields !== 'object') {
emitError?.('No data found to import');
return {};
}
console.log('Found Custom Fields, importing...');
const customFields = sanitiseCustomFields(data.customFields);
if (Object.keys(customFields).length !== Object.keys(data.customFields).length) {
emitError?.('Skipped invalid custom fields');
}
return customFields;
}
export function sanitiseCustomFields(data: object): CustomFields {
const newCustomFields: CustomFields = {};
for (const [_originalKey, field] of Object.entries(data)) {
if (!isValidField(field)) {
continue;
}
if (!isAlphanumericWithSpace(field.label)) {
continue;
}
// the key is always made from the label
const key = customFieldLabelToKey(field.label);
if (key in newCustomFields) {
continue;
}
newCustomFields[key] = {
type: field.type,
colour: field.colour,
label: field.label,
};
}
function isValidField(data: unknown): data is CustomField {
return (
typeof data === 'object' &&
data !== null &&
'label' in data &&
data.label !== '' &&
'colour' in data &&
typeof data.colour === 'string' &&
'type' in data &&
(data.type === 'string' || data.type === 'image')
);
}
return newCustomFields;
}
@@ -1,14 +1,49 @@
import express from 'express';
import { CustomField, CustomFields, ErrorResponse } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import express from 'express';
import type { Request, Response } from 'express';
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
import { createCustomField, editCustomField, deleteCustomField } from '../rundown/rundown.service.js';
import { deleteCustomField, getCustomFields, postCustomField, putCustomField } from './customFields.controller.js';
import { validateCustomField, validateDeleteCustomField, validateEditCustomField } from './customFields.validation.js';
export const router = express.Router();
router.get('/', getCustomFields);
router.get('/', async (_req: Request, res: Response<CustomFields>) => {
const customFields = getProjectCustomFields();
res.status(200).json(customFields);
});
router.post('/', validateCustomField, postCustomField);
router.post('/', validateCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
try {
const newFields = await createCustomField(req.body as CustomField);
res.status(201).send(newFields);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.put('/:label', validateEditCustomField, putCustomField);
router.put('/:key', validateEditCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
try {
const currentKey = req.params.key;
const { colour, type, label } = req.body;
const newFields = await editCustomField(currentKey, { label, colour, type });
res.status(200).send(newFields);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.delete('/:label', validateDeleteCustomField, deleteCustomField);
router.delete('/:key', validateDeleteCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
try {
const customFields = await deleteCustomField(req.params.key);
res.status(200).send(customFields);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
@@ -1,51 +1,35 @@
import { isAlphanumericWithSpace } from 'ontime-utils';
import type { Request, Response, NextFunction } from 'express';
import { body, param, validationResult } from 'express-validator';
import { body, param } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
export const validateCustomField = [
body('label')
.exists()
.isString()
.trim()
.notEmpty()
.custom((value) => {
return isAlphanumericWithSpace(value);
}),
body('type').exists().isIn(['string', 'image']),
body('colour').exists().isString().trim(),
body('type').isIn(['string', 'image']),
body('colour').isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
requestValidationFunction,
];
export const validateEditCustomField = [
param('label').exists().isString().trim(),
param('key').isString().trim().notEmpty(),
body('label')
.exists()
.isString()
.trim()
.notEmpty()
.custom((value) => {
return isAlphanumericWithSpace(value);
}),
body('type').exists().isIn(['string', 'image']),
body('colour').exists().isString().trim(),
body('type').isIn(['string', 'image']),
body('colour').isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
requestValidationFunction,
];
export const validateDeleteCustomField = [
param('label').exists().isString(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateDeleteCustomField = [param('key').isString().notEmpty(), requestValidationFunction];
@@ -0,0 +1,56 @@
/* eslint-disable no-console -- we are mocking the console */
import { demoDb } from '../../../models/demoProject.js';
import { parseDatabaseModel } from '../db.parser.js';
// mock data provider
beforeAll(() => {
vi.mock('../../classes/data-provider/DataProvider.js', () => {
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
setRundown: vi.fn().mockImplementation((newData) => newData),
setCustomFields: vi.fn().mockImplementation((newData) => newData),
};
}),
};
});
});
describe('test parseDatabaseModel() with demo project (valid)', () => {
const filteredDemoProject = structuredClone(demoDb);
const { data } = parseDatabaseModel(filteredDemoProject);
it('has 17 events with 12 top level events', () => {
expect(data.rundowns.default.order.length).toBe(12);
expect(Object.keys(data.rundowns.default.entries).length).toBe(17);
});
it('is the same as the demo project since all data is valid', () => {
// @ts-expect-error -- its ok
delete filteredDemoProject.settings.version;
// @ts-expect-error -- its ok
delete data.settings.version;
expect(data).toMatchObject(filteredDemoProject);
});
});
describe('test parseDatabaseModel() edge cases', () => {
it('skips unknown app and version settings', () => {
console.log = vi.fn();
const testData = {
settings: {
osc_port: 8888,
},
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
expect(() => parseDatabaseModel(testData)).toThrow();
});
it('fails with invalid JSON', () => {
// @ts-expect-error -- we know this is wrong, testing imports outside domain
expect(() => parseDatabaseModel('some random dataset')).toThrow();
});
});
+18 -9
View File
@@ -58,6 +58,7 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
backstageUrl: req.body?.backstageUrl ?? '',
backstageInfo: req.body?.backstageInfo ?? '',
projectLogo: req.body?.projectLogo ?? null,
custom: req.body?.custom ?? [],
},
});
@@ -105,7 +106,8 @@ export async function projectDownload(req: Request, res: Response) {
const { filename } = req.body;
const pathToFile = doesProjectExist(filename);
if (!pathToFile) {
return res.status(404).send({ message: `Project ${filename} not found.` });
res.status(404).send({ message: `Project ${filename} not found.` });
return;
}
res.download(pathToFile, filename, (error) => {
@@ -137,7 +139,8 @@ export async function postProjectFile(req: Request, res: Response<MessageRespons
} catch (error) {
const message = getErrorMessage(error);
if (message.startsWith('Project file')) {
return res.status(403).send({ message });
res.status(403).send({ message });
return;
}
res.status(400).send({ message });
}
@@ -194,7 +197,8 @@ export async function loadProject(req: Request, res: Response<MessageResponse |
} catch (error) {
const message = getErrorMessage(error);
if (message.startsWith('Project file')) {
return res.status(403).send({ message });
res.status(403).send({ message });
return;
}
res.status(500).send({ message });
}
@@ -203,7 +207,7 @@ export async function loadProject(req: Request, res: Response<MessageResponse |
/**
* Loads the demo project
*/
export async function loadDemo(req: Request, res: Response<MessageResponse | ErrorResponse>) {
export async function loadDemo(_req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const projectName = await projectService.loadDemoProject();
@@ -213,7 +217,8 @@ export async function loadDemo(req: Request, res: Response<MessageResponse | Err
} catch (error) {
const message = getErrorMessage(error);
if (message.startsWith('Project file')) {
return res.status(403).send({ message });
res.status(403).send({ message });
return;
}
res.status(500).send({ message });
}
@@ -244,7 +249,8 @@ export async function duplicateProjectFile(req: Request, res: Response<MessageRe
} catch (error) {
const message = getErrorMessage(error);
if (message.startsWith('Project file')) {
return res.status(403).send({ message });
res.status(403).send({ message });
return;
}
res.status(500).send({ message });
@@ -274,7 +280,8 @@ export async function renameProjectFile(req: Request, res: Response<MessageRespo
} catch (error) {
const message = getErrorMessage(error);
if (message.startsWith('Project file')) {
return res.status(403).send({ message });
res.status(403).send({ message });
return;
}
res.status(500).send({ message });
@@ -302,10 +309,12 @@ export async function deleteProjectFile(req: Request, res: Response<MessageRespo
} catch (error) {
const message = getErrorMessage(error);
if (message === 'Cannot delete currently loaded project') {
return res.status(403).send({ message });
res.status(403).send({ message });
return;
}
if (message === 'Project file not found') {
return res.status(404).send({ message });
res.status(404).send({ message });
return;
}
res.status(500).send({ message });
+1 -2
View File
@@ -1,11 +1,10 @@
import type { Request } from 'express';
import multer, { type FileFilterCallback } from 'multer';
import { JSON_MIME } from '../../utils/parser.js';
import { storage } from '../../utils/upload.js';
const filterProjectFile = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(JSON_MIME)) {
if (file.mimetype.includes('application/json')) {
cb(null, true);
} else {
cb(null, false);
+49
View File
@@ -0,0 +1,49 @@
import { DatabaseModel, LogOrigin } from 'ontime-types';
import { logger } from '../../classes/Logger.js';
import { parseAutomationSettings } from '../automation/automation.parser.js';
import { parseProjectData } from '../project-data/projectData.parser.js';
import { parseRundowns } from '../rundown/rundown.parser.js';
import { parseSettings } from '../settings/settings.parser.js';
import { parseUrlPresets } from '../url-presets/urlPresets.parser.js';
import { parseViewSettings } from '../view-settings/viewSettings.parser.js';
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
type ParsingError = {
context: string;
message: string;
};
/**
* @description handles parsing of ontime project file
* @param {object} jsonData - project file to be parsed
* @returns {object} - parsed object
*/
export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): { data: DatabaseModel; errors: ParsingError[] } {
// we need to parse settings first to make sure the data is ours
// this may throw
const settings = parseSettings(jsonData);
const errors: ParsingError[] = [];
const makeEmitError = (context: string) => (message: string) => {
logger.error(LogOrigin.Server, `Error parsing ${context}: ${message}`);
errors.push({ context, message });
};
// we need to parse the custom fields first so they can be used in validating events
const customFields = parseCustomFields(jsonData, makeEmitError('Custom Fields'));
const rundowns = parseRundowns(jsonData, customFields, makeEmitError('Rundowns'));
const data: DatabaseModel = {
rundowns,
project: parseProjectData(jsonData, makeEmitError('Project')),
settings,
viewSettings: parseViewSettings(jsonData, makeEmitError('View Settings')),
urlPresets: parseUrlPresets(jsonData, makeEmitError('URL Presets')),
customFields,
automation: parseAutomationSettings(jsonData),
};
return { data, errors };
}
+11 -52
View File
@@ -1,12 +1,13 @@
import type { Request, Response, NextFunction } from 'express';
import { body, param, validationResult } from 'express-validator';
import { body, param } from 'express-validator';
import sanitize from 'sanitize-filename';
import { ensureJsonExtension } from '../../utils/fileManagement.js';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
/**
* @description Validates request for a new project.
*/
export const validateNewProject = [
body().notEmpty().withMessage('No object found in request'),
body('filename').optional().isString().trim(),
body('title').optional().isString().trim(),
body('description').optional().isString().trim(),
@@ -16,12 +17,9 @@ export const validateNewProject = [
body('backstageInfo').optional().isString().trim(),
body('projectLogo').optional().isString().trim(),
body('endMessage').optional().isString().trim(),
body('custom').optional().isArray(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
requestValidationFunction,
];
/**
@@ -39,25 +37,14 @@ export const validateQuickProject = [
body('viewSettings.freezeEnd').optional().isBoolean(),
body('viewSettings.endMessage').optional().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
requestValidationFunction,
];
/**
* @description Validates request for pathing data in the project.
*/
export const validatePatchProject = [
// Custom validator to ensure the body is not empty
(req: Request, res: Response, next: NextFunction) => {
if (Object.keys(req.body).length === 0) {
return res.status(422).json({ errors: [{ msg: 'Request body cannot be empty' }] });
}
next();
},
body().notEmpty().withMessage('No object found in request'),
body('rundowns').isObject().optional({ nullable: false }),
body('project').isObject().optional({ nullable: false }),
body('settings').isObject().optional({ nullable: false }),
@@ -67,11 +54,7 @@ export const validatePatchProject = [
body('osc').isObject().optional({ nullable: false }),
body('http').isObject().optional({ nullable: false }),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
requestValidationFunction,
];
/**
@@ -79,7 +62,6 @@ export const validatePatchProject = [
*/
export const validateNewFilenameBody = [
body('newFilename')
.exists()
.isString()
.trim()
.customSanitizer((input: string) => sanitize(input))
@@ -88,14 +70,7 @@ export const validateNewFilenameBody = [
.withMessage('Filename was empty or contained only invalid characters')
.customSanitizer((input: string) => ensureJsonExtension(input)),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
requestValidationFunction,
];
/**
@@ -103,7 +78,6 @@ export const validateNewFilenameBody = [
*/
export const validateFilenameBody = [
body('filename')
.exists()
.isString()
.trim()
.customSanitizer((input: string) => sanitize(input))
@@ -112,14 +86,7 @@ export const validateFilenameBody = [
.withMessage('Filename was empty or contained only invalid characters')
.customSanitizer((input: string) => ensureJsonExtension(input)),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
requestValidationFunction,
];
/**
@@ -127,7 +94,6 @@ export const validateFilenameBody = [
*/
export const validateFilenameParam = [
param('filename')
.exists()
.isString()
.trim()
.customSanitizer((input: string) => sanitize(input))
@@ -136,12 +102,5 @@ export const validateFilenameParam = [
.withMessage('Filename was empty or contained only invalid characters')
.customSanitizer((input: string) => ensureJsonExtension(input)),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
requestValidationFunction,
];
@@ -1,294 +1,9 @@
/* eslint-disable no-console -- we are mocking the console */
import { vi } from 'vitest';
import { CustomFields, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types';
import { defaultImportMap, ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
import { CustomFields, DatabaseModel, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types';
import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
import { getCustomFieldData, parseExcel } from '../excel.parser.js';
import { dbModel } from '../../models/dataModel.js';
import { demoDb } from '../../models/demoProject.js';
import { getCustomFieldData, parseExcel, parseDatabaseModel } from '../parser.js';
import { makeString } from '../parserUtils.js';
import { parseUrlPresets, parseViewSettings } from '../parserFunctions.js';
import { dataFromExcelTemplate } from './parser.mock-data.js';
// mock data provider
beforeAll(() => {
vi.mock('../../classes/data-provider/DataProvider.js', () => {
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
setRundown: vi.fn().mockImplementation((newData) => newData),
setCustomFields: vi.fn().mockImplementation((newData) => newData),
};
}),
};
});
});
describe('test parseDatabaseModel() with demo project (valid)', () => {
const filteredDemoProject = structuredClone(demoDb);
const { data } = parseDatabaseModel(filteredDemoProject);
it('has 17 events with 12 top level events', () => {
expect(data.rundowns.default.order.length).toBe(12);
expect(Object.keys(data.rundowns.default.entries).length).toBe(17);
});
it('is the same as the demo project since all data is valid', () => {
// @ts-expect-error -- its ok
delete filteredDemoProject.settings.version;
// @ts-expect-error -- its ok
delete data.settings.version;
expect(data).toMatchObject(filteredDemoProject);
});
});
describe('test parseDatabaseModel() edge cases', () => {
it('skips unknown app and version settings', () => {
console.log = vi.fn();
const testData = {
settings: {
osc_port: 8888,
},
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
expect(() => parseDatabaseModel(testData)).toThrow();
});
it('fails with invalid JSON', () => {
// @ts-expect-error -- we know this is wrong, testing imports outside domain
expect(() => parseDatabaseModel('some random dataset')).toThrow();
});
});
describe('test aliases import', () => {
it('imports a well defined urlPreset', () => {
const testData = {
rundown: [],
settings: {
version: '2.0.0',
},
urlPresets: [
{
enabled: false,
alias: 'testalias',
pathAndParams: 'testpathAndParams',
},
],
} as unknown as DatabaseModel;
const parsed = parseUrlPresets(testData);
expect(parsed.length).toBe(1);
// generates missing id
expect(parsed[0].alias).toBeDefined();
});
});
describe('test views import', () => {
it('imports data from file', () => {
const testData = {
rundown: [],
settings: {
version: '2.0.0',
},
viewSettings: {
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
dangerColor: '#ED3333',
endMessage: '',
overrideStyles: false,
// known error: properties do not exist
notAthing: true,
},
// known error: views does not exist
views: {
overrideStyles: true,
},
};
const expectedParsedViewSettings = {
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
dangerColor: '#ED3333',
freezeEnd: false,
endMessage: '',
overrideStyles: false,
};
// @ts-expect-error -- we know the above is incorrect
const parsed = parseViewSettings(testData);
expect(parsed).toStrictEqual(expectedParsedViewSettings);
});
it('imports defaults to model', () => {
const testData = {
rundown: [],
settings: {
version: '2.0.0',
},
} as unknown as DatabaseModel;
const parsed = parseViewSettings(testData);
expect(parsed).toStrictEqual(dbModel.viewSettings);
});
});
describe('makeString()', () => {
it('converts variables to string', () => {
const cases = [
{
val: 2,
expected: '2',
},
{
val: 2.22222222,
expected: '2.22222222',
},
{
val: ['testing'],
expected: 'testing',
},
{
val: ' testing ',
expected: 'testing',
},
{
val: { doing: 'testing' },
expected: 'fallback',
},
{
val: undefined,
expected: 'fallback',
},
];
cases.forEach(({ val, expected }) => {
const converted = makeString(val, 'fallback');
expect(converted).toBe(expected);
});
});
});
describe('getCustomFieldData()', () => {
it('generates a list of keys from the given import map', () => {
const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
linkStart: 'link start',
timeEnd: 'time end',
duration: 'duration',
cue: 'cue',
title: 'title',
countToEnd: 'count to end',
isPublic: 'public',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {
lighting: 'lx',
sound: 'sound',
video: 'av',
},
entryId: 'id',
} as ImportMap;
const result = getCustomFieldData(importMap, {});
expect(result.customFields).toStrictEqual({
lighting: {
type: 'string',
colour: '',
label: 'lighting',
},
sound: {
type: 'string',
colour: '',
label: 'sound',
},
video: {
type: 'string',
colour: '',
label: 'video',
},
});
// it is an inverted record of <importKey, ontimeKey>
expect(result.customFieldImportKeys).toStrictEqual({
lx: 'lighting',
sound: 'sound',
av: 'video',
});
});
it('keeps colour information from existing fields', () => {
const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
linkStart: 'link start',
timeEnd: 'time end',
duration: 'duration',
cue: 'cue',
title: 'title',
countToEnd: 'count to end',
isPublic: 'public',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {
lighting: 'lx',
sound: 'sound',
video: 'av',
ontime_label: 'excel label',
},
entryId: 'id',
} as ImportMap;
const customFields: CustomFields = {
lighting: { label: 'lx', type: 'string', colour: 'red' },
sound: { label: 'sound', type: 'string', colour: 'green' },
ontime_key: { label: 'ontime_label', type: 'string', colour: 'blue' },
};
const result = getCustomFieldData(importMap, customFields);
expect(result.customFields).toStrictEqual({
lighting: {
type: 'string',
colour: 'red',
label: 'lighting',
},
sound: {
type: 'string',
colour: 'green',
label: 'sound',
},
video: {
type: 'string',
colour: '',
label: 'video',
},
ontime_key: {
type: 'string',
colour: 'blue',
label: 'ontime_label',
},
});
// it is an inverted record of <importKey, ontimeKey>
expect(result.customFieldImportKeys).toStrictEqual({
lx: 'lighting',
sound: 'sound',
av: 'video',
'excel label': 'ontime_key',
});
});
});
import { dataFromExcelTemplate } from './mockData.js';
describe('parseExcel()', () => {
it('parses the example file', () => {
@@ -707,3 +422,161 @@ describe('parseExcel()', () => {
});
});
});
describe('getCustomFieldData()', () => {
it('generates a list of keys from the given import map', () => {
const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
linkStart: 'link start',
timeEnd: 'time end',
duration: 'duration',
cue: 'cue',
title: 'title',
countToEnd: 'count to end',
isPublic: 'public',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {
lighting: 'lx',
sound: 'sound',
video: 'av',
},
entryId: 'id',
} as ImportMap;
const result = getCustomFieldData(importMap, {});
expect(result.mergedCustomFields).toStrictEqual({
lighting: {
type: 'string',
colour: '',
label: 'lighting',
},
sound: {
type: 'string',
colour: '',
label: 'sound',
},
video: {
type: 'string',
colour: '',
label: 'video',
},
});
// it is an inverted record of <importKey, ontimeKey>
expect(result.customFieldImportKeys).toStrictEqual({
lx: 'lighting',
sound: 'sound',
av: 'video',
});
});
it('keeps colour information from existing fields', () => {
const importMap = {
worksheet: 'event schedule',
timeStart: 'time start',
linkStart: 'link start',
timeEnd: 'time end',
duration: 'duration',
cue: 'cue',
title: 'title',
countToEnd: 'count to end',
isPublic: 'public',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {
lighting: 'lx',
sound: 'sound',
video: 'av',
'ontime key': 'excel label',
},
entryId: 'id',
} as ImportMap;
const existingCustomFields: CustomFields = {
lighting: { label: 'lighting', type: 'string', colour: 'red' },
sound: { label: 'sound', type: 'string', colour: 'green' },
ontime_key: { label: 'ontime key', type: 'string', colour: 'blue' },
};
const result = getCustomFieldData(importMap, existingCustomFields);
expect(result.mergedCustomFields).toStrictEqual({
lighting: {
type: 'string',
colour: 'red',
label: 'lighting',
},
sound: {
type: 'string',
colour: 'green',
label: 'sound',
},
video: {
type: 'string',
colour: '',
label: 'video',
},
ontime_key: {
type: 'string',
colour: 'blue',
label: 'ontime key',
},
});
// it is an inverted record of <importKey, ontimeKey>
expect(result.customFieldImportKeys).toStrictEqual({
lx: 'lighting',
sound: 'sound',
av: 'video',
'excel label': 'ontime_key',
});
});
it('lowercases the keys in the import map', () => {
const importMap: ImportMap = {
...defaultImportMap,
custom: {
Lighting: 'Lx',
Sound: 'sound',
video: 'av',
},
};
const result = getCustomFieldData(importMap, {});
expect(result.mergedCustomFields).toStrictEqual({
Lighting: {
type: 'string',
colour: '',
label: 'Lighting',
},
Sound: {
type: 'string',
colour: '',
label: 'Sound',
},
video: {
type: 'string',
colour: '',
label: 'video',
},
});
// notice that the keys excel keys are lowercased
expect(result.customFieldImportKeys).toStrictEqual({
lx: 'Lighting',
sound: 'Sound',
av: 'video',
});
});
});
@@ -1,45 +0,0 @@
/**
* This module encapsulates logic related to
* Google Sheets
*/
import type { Request, Response } from 'express';
import { generateRundownPreview, listWorksheets, saveExcelFile } from './excel.service.js';
import { CustomFields, Rundown } from 'ontime-types';
export async function postExcel(req: Request, res: Response) {
try {
// file has been validated by middleware
const filePath = (req.file as Express.Multer.File).path;
await saveExcelFile(filePath);
res.status(200).send();
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
export async function getWorksheets(req: Request, res: Response) {
try {
const names = listWorksheets();
res.status(200).send(names);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
/**
* parses an Excel spreadsheet
* @returns parsed result
*/
export async function previewExcel(
req: Request,
res: Response<{ rundown: Rundown; customFields: CustomFields } | { message: string }>,
) {
try {
const { options } = req.body;
const data = generateRundownPreview(options);
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
@@ -1,9 +1,10 @@
import type { Request } from 'express';
import multer, { type FileFilterCallback } from 'multer';
import { EXCEL_MIME } from '../../utils/parser.js';
import { storage } from '../../utils/upload.js';
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
const filterExcel = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(EXCEL_MIME)) {
cb(null, true);
@@ -1,80 +1,30 @@
import {
customFieldLabelToKey,
customKeyFromLabel,
defaultImportMap,
generateId,
type ImportMap,
isKnownTimerType,
validateEndAction,
validateTimerType,
} from 'ontime-utils';
import {
CustomFields,
DatabaseModel,
EntryCustomFields,
isOntimeBlock,
LogOrigin,
OntimeBlock,
OntimeEvent,
Rundown,
OntimeEvent,
OntimeBlock,
EntryCustomFields,
SupportedEntry,
isOntimeBlock,
TimerType,
CustomFieldKey,
} from 'ontime-types';
import {
ImportMap,
defaultImportMap,
generateId,
isKnownTimerType,
validateTimerType,
validateEndAction,
customFieldLabelToKey,
isAlphanumericWithSpace,
} from 'ontime-utils';
import { Merge } from 'ts-essentials';
import { parseAutomationSettings } from '../api-data/automation/automation.parser.js';
import { parseRundowns } from '../api-data/rundown/rundown.parser.js';
import { logger } from '../classes/Logger.js';
import { makeString } from './parserUtils.js';
import { parseProject, parseSettings, parseUrlPresets, parseViewSettings } from './parserFunctions.js';
import { parseExcelDate } from './time.js';
import { is } from './is.js';
export type ErrorEmitter = (message: string) => void;
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
export const JSON_MIME = 'application/json';
function parseBooleanString(value: unknown): boolean {
if (typeof value === 'boolean') {
return value;
}
// falsy values would be nullish or empty string
if (!value || typeof value !== 'string') {
return false;
}
return value.toLowerCase() !== 'false';
}
export function getCustomFieldData(
importMap: ImportMap,
existingCustomFields: CustomFields,
): {
customFields: CustomFields;
customFieldImportKeys: Record<keyof CustomFields, string>;
} {
const customFields = {};
const customFieldImportKeys: Record<string, string> = {};
for (const ontimeLabel in importMap.custom) {
const ontimeKey = customKeyFromLabel(ontimeLabel, existingCustomFields) ?? customFieldLabelToKey(ontimeLabel);
if (!ontimeKey) {
continue;
}
const importLabel = importMap.custom[ontimeLabel].toLowerCase();
// @ts-expect-error -- we are sure that the key exists
customFields[ontimeKey] = {
type: 'string',
colour: ontimeKey in existingCustomFields ? existingCustomFields[ontimeKey].colour : '',
label: ontimeLabel,
};
customFieldImportKeys[importLabel] = ontimeKey;
}
return { customFields, customFieldImportKeys };
}
import { is } from '../../utils/is.js';
import { makeString } from '../../utils/parserUtils.js';
import { parseExcelDate } from '../../utils/time.js';
/**
* @description Excel array parser
@@ -102,7 +52,7 @@ export const parseExcel = (
}
}
const { customFields, customFieldImportKeys } = getCustomFieldData(importMap, existingCustomFields);
const { mergedCustomFields, customFieldImportKeys } = getCustomFieldData(importMap, existingCustomFields);
const rundown: Rundown = {
id: generateId(),
title: sheetName,
@@ -231,7 +181,7 @@ export const parseExcel = (
if (maybeTimeType === 'block') {
// we leave this as a clue for the object filtering later on
entry.type = SupportedEntry.Block;
} else if (maybeTimeType === '' || isKnownTimerType(maybeTimeType)) {
} else if (maybeTimeType === '' || maybeTimeType === 'event' || isKnownTimerType(maybeTimeType)) {
// @ts-expect-error -- we leave this as a clue for the object filtering later on
entry.type = SupportedEntry.Event;
entry.timerType = validateTimerType(maybeTimeType);
@@ -331,44 +281,68 @@ export const parseExcel = (
return {
rundown,
customFields,
customFields: mergedCustomFields,
rundownMetadata,
};
};
type ParsingError = {
context: string;
message: string;
};
/**
* Utility function infers a boolean from a string value
*/
function parseBooleanString(value: unknown): boolean {
if (typeof value === 'boolean') {
return value;
}
// falsy values would be nullish or empty string
if (!value || typeof value !== 'string') {
return false;
}
return value.toLowerCase() !== 'false';
}
/**
* @description handles parsing of ontime project file
* @param {object} jsonData - project file to be parsed
* @returns {object} - parsed object
* Receives an import map which contains custom field labels and a custom fields object
* the result importkeys is an inverted record of <importKey, ontimeKey>
* We need this function since, when importing from sheets, the user gives us custom field labels, not keys
* @returns the new custom fields, and a map of excel column names to ontime keys
* @private exported for testing
*/
export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): { data: DatabaseModel; errors: ParsingError[] } {
// we need to parse settings first to make sure the data is ours
// this may throw
const settings = parseSettings(jsonData);
export function getCustomFieldData(
importMap: ImportMap,
existingCustomFields: CustomFields,
): {
mergedCustomFields: CustomFields;
customFieldImportKeys: Record<keyof CustomFields, string>;
} {
const mergedCustomFields: CustomFields = {};
/**
* A map of import keys to ontime keys
* Map<excel column name, ontime key>
*/
const customFieldImportKeys: Record<string, CustomFieldKey> = {};
const errors: ParsingError[] = [];
const makeEmitError = (context: string) => (message: string) => {
logger.error(LogOrigin.Server, `Error parsing ${context}: ${message}`);
errors.push({ context, message });
};
for (const ontimeLabel in importMap.custom) {
// if the label is not valid, we skip the import
if (!isAlphanumericWithSpace(ontimeLabel)) {
continue;
}
// we need to parse the custom fields first so they can be used in validating events
const { rundowns, customFields } = parseRundowns(jsonData, makeEmitError('Rundown'));
// generate a key for the custom field
const keyInCustomFields = customFieldLabelToKey(ontimeLabel);
// we lower case the excel key to make it easier to match
const columnNameInExcel = importMap.custom[ontimeLabel].toLowerCase();
const maybeExistingColour = existingCustomFields[keyInCustomFields]?.colour ?? '';
const data: DatabaseModel = {
rundowns,
project: parseProject(jsonData, makeEmitError('Project')),
settings,
viewSettings: parseViewSettings(jsonData, makeEmitError('View Settings')),
urlPresets: parseUrlPresets(jsonData, makeEmitError('URL Presets')),
customFields,
automation: parseAutomationSettings(jsonData),
};
// 1. add the custom field to the merged custom fields
mergedCustomFields[keyInCustomFields] = {
type: 'string', // we currently only support string custom fields
colour: maybeExistingColour,
label: ontimeLabel,
};
return { data, errors };
// 2. add the column to the import keys
customFieldImportKeys[columnNameInExcel] = keyInCustomFields;
}
return { mergedCustomFields, customFieldImportKeys };
}
+36 -8
View File
@@ -1,14 +1,42 @@
/**
* This is a feature specific router for integration with Excel
*/
import express from 'express';
import type { Request, Response } from 'express';
import { uploadExcel } from './excel.middleware.js';
import { getWorksheets, postExcel, previewExcel } from './excel.controller.js';
import { validateFileExists, validateImportMapOptions } from './excel.validation.js';
import { CustomFields, ErrorResponse, Rundown } from 'ontime-types';
import { generateRundownPreview, listWorksheets, saveExcelFile } from './excel.service.js';
export const router = express.Router();
router.post('/upload', uploadExcel, validateFileExists, postExcel);
router.get('/worksheets', getWorksheets);
router.post('/preview', validateImportMapOptions, previewExcel);
router.post('/upload', uploadExcel, validateFileExists, async (req: Request, res: Response<never | ErrorResponse>) => {
try {
// file has been validated by middleware
const filePath = (req.file as Express.Multer.File).path;
await saveExcelFile(filePath);
res.status(201).send();
} catch (error) {
res.status(500).send({ message: String(error) });
}
});
router.get('/worksheets', (_req: Request, res: Response<string[] | ErrorResponse>) => {
try {
const names = listWorksheets();
res.status(200).send(names);
} catch (error) {
res.status(500).send({ message: String(error) });
}
});
router.post(
'/preview',
validateImportMapOptions,
(req: Request, res: Response<{ rundown: Rundown; customFields: CustomFields } | ErrorResponse>) => {
try {
const { options } = req.body;
const data = generateRundownPreview(options);
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: String(error) });
}
},
);
@@ -11,12 +11,13 @@ import { existsSync } from 'fs';
import xlsx from 'xlsx';
import type { WorkBook } from 'xlsx';
import { parseExcel } from '../../utils/parser.js';
import { parseCustomFields } from '../../utils/parserFunctions.js';
import { deleteFile } from '../../utils/parserUtils.js';
import { getCustomFields } from '../../services/rundown-service/rundownCache.js';
import { deleteFile } from '../../utils/fileManagement.js';
import { parseRundown } from '../rundown/rundown.parser.js';
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
import { parseExcel } from './excel.parser.js';
let excelData: WorkBook = xlsx.utils.book_new();
@@ -45,7 +46,7 @@ export function generateRundownPreview(options: ImportMap): { rundown: Rundown;
const arrayOfData: unknown[][] = xlsx.utils.sheet_to_json(data, { header: 1, blankrows: false, raw: false });
const dataFromExcel = parseExcel(arrayOfData, getCustomFields(), options.worksheet, options);
const dataFromExcel = parseExcel(arrayOfData, getProjectCustomFields(), options.worksheet, options);
const parsedCustomFields = parseCustomFields(dataFromExcel);
// we run the parsed data through an extra step to ensure the objects shape
@@ -1,28 +1,19 @@
import { isImportMap } from 'ontime-utils';
import { body, validationResult } from 'express-validator';
import type { NextFunction, Request, Response } from 'express';
import { body } from 'express-validator';
import {
requestValidationFunction,
requestValidationFunctionWithFile,
} from '../validation-utils/validationFunction.js';
export const validateFileExists = [
(req: Request, res: Response, next: NextFunction) => {
if (!req.file) {
return res.status(422).json({ errors: 'File not found' });
}
next();
},
];
export const validateFileExists = [requestValidationFunctionWithFile];
export const validateImportMapOptions = [
body('options')
.exists()
.isObject()
.custom((content) => {
return isImportMap(content);
}),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
requestValidationFunction,
];
+3 -3
View File
@@ -4,7 +4,7 @@ import { router as automationsRouter } from './automation/automation.router.js';
import { router as urlPresetsRouter } from './url-presets/urlPresets.router.js';
import { router as customFieldsRouter } from './custom-fields/customFields.router.js';
import { router as dbRouter } from './db/db.router.js';
import { router as projectRouter } from './project/project.router.js';
import { router as projectRouter } from './project-data/projectData.router.js';
import { router as rundownRouter } from './rundown/rundown.router.js';
import { router as settingsRouter } from './settings/settings.router.js';
import { router as sheetsRouter } from './sheets/sheets.router.js';
@@ -31,6 +31,6 @@ appRouter.use('/report', reportRouter);
appRouter.use('/assets', assetsRouter);
//we don't want to redirect to react index when using api routes
appRouter.all('/*', (_req, res) => {
res.status(404).send();
appRouter.all('/*splat', (_req, res) => {
res.status(404).send('data path not found');
});
@@ -0,0 +1,10 @@
import { parseProjectData } from '../projectData.parser.js';
describe('parseProjectData()', () => {
it('returns an a base model if nothing is given', () => {
const errorEmitter = vi.fn();
const result = parseProjectData({}, errorEmitter);
expect(result).toBeTypeOf('object');
expect(errorEmitter).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,10 @@
import { ProjectData } from 'ontime-types';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
/**
* Gets a copy of the stored project data
*/
export function getProjectData(): ProjectData {
return structuredClone(getDataProvider().getProjectData());
}
@@ -0,0 +1,27 @@
import { DatabaseModel, ProjectData } from 'ontime-types';
import { dbModel } from '../../models/dataModel.js';
import { ErrorEmitter } from '../../utils/parserUtils.js';
/**
* Parse event portion of an entry
*/
export function parseProjectData(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): ProjectData {
if (!data.project) {
emitError?.('No data found to import');
return { ...dbModel.project };
}
console.log('Found project data, importing...');
return {
title: data.project.title ?? dbModel.project.title,
description: data.project.description ?? dbModel.project.description,
publicUrl: data.project.publicUrl ?? dbModel.project.publicUrl,
publicInfo: data.project.publicInfo ?? dbModel.project.publicInfo,
backstageUrl: data.project.backstageUrl ?? dbModel.project.backstageUrl,
backstageInfo: data.project.backstageInfo ?? dbModel.project.backstageInfo,
projectLogo: data.project.projectLogo ?? dbModel.project.projectLogo,
custom: data.project.custom ?? dbModel.project.custom,
};
}
@@ -1,22 +1,22 @@
import { ErrorResponse, ProjectData } from 'ontime-types';
import express from 'express';
import type { Request, Response } from 'express';
import type { ErrorResponse, ProjectData } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { projectSanitiser } from './projectData.validation.js';
import { uploadImageFile } from '../db/db.middleware.js';
import { postProjectLogo } from '../db/db.controller.js';
import * as projectDao from './projectData.dao.js';
import { removeUndefined } from '../../utils/parserUtils.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { editCurrentProjectData } from '../../services/project-service/ProjectService.js';
export function getProjectData(_req: Request, res: Response<ProjectData>) {
res.json(getDataProvider().getProjectData());
}
export const router = express.Router();
export async function postProjectData(req: Request, res: Response<ProjectData | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
router.get('/', (_req: Request, res: Response<ProjectData>) => {
res.status(200).json(projectDao.getProjectData());
});
router.post('/', projectSanitiser, async (req: Request, res: Response<ProjectData | ErrorResponse>) => {
try {
const newData: Partial<ProjectData> = removeUndefined({
title: req.body?.title,
@@ -27,6 +27,7 @@ export async function postProjectData(req: Request, res: Response<ProjectData |
backstageInfo: req.body?.backstageInfo,
endMessage: req.body?.endMessage,
projectLogo: req.body?.projectLogo,
custom: req.body?.custom,
});
const updatedData = await editCurrentProjectData(newData);
@@ -36,4 +37,6 @@ export async function postProjectData(req: Request, res: Response<ProjectData |
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
});
router.post('/upload', uploadImageFile, postProjectLogo);
@@ -1,7 +1,8 @@
import { Request, Response, NextFunction } from 'express';
import { body, validationResult } from 'express-validator';
import { body } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
export const projectSanitiser = [
body().notEmpty().withMessage('No object found in request'),
body('title').optional().isString().trim(),
body('description').optional().isString().trim(),
body('publicUrl').optional().isString().trim(),
@@ -9,11 +10,10 @@ export const projectSanitiser = [
body('backstageUrl').optional().isString().trim(),
body('backstageInfo').optional().isString().trim(),
body('endMessage').optional().isString().trim(),
body('projectLogo').optional({ nullable: true }).isString().trim(),
body('projectLogo').optional({ nullable: true }).isString().trim().isBase64(),
body('custom').optional().isArray(),
body('custom.*.title').optional().isString().trim().notEmpty(),
body('custom.*.value').optional().isString().trim().notEmpty(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
requestValidationFunction,
];
@@ -1,12 +0,0 @@
import express from 'express';
import { getProjectData, postProjectData } from './project.controller.js';
import { projectSanitiser } from './project.validation.js';
import { uploadImageFile } from '../db/db.middleware.js';
import { postProjectLogo } from '../db/db.controller.js';
export const router = express.Router();
router.get('/', getProjectData);
router.post('/', projectSanitiser, postProjectData);
router.post('/upload', uploadImageFile, postProjectLogo);
@@ -1,18 +0,0 @@
import type { Request, Response } from 'express';
import type { OntimeReport } from 'ontime-types';
import * as report from './report.service.js';
export function getAll(_req: Request, res: Response<OntimeReport>) {
res.json(report.generate());
}
export function deleteAll(_req: Request, res: Response<OntimeReport>) {
report.clear();
res.status(200).send();
}
export function deleteWithId(req: Request, res: Response<OntimeReport>) {
const { eventId } = req.params;
report.clear(eventId);
res.status(200).send();
}
@@ -1,10 +1,21 @@
import express from 'express';
import { getAll, deleteWithId, deleteAll } from './report.controller.js';
import { paramsMustHaveEventId } from '../rundown/rundown.validation.js';
import type { Request, Response } from 'express';
import { paramsWithId } from '../validation-utils/validationFunction.js';
import * as report from './report.service.js';
export const router = express.Router();
router.get('/', getAll);
router.get('/', (_req: Request, res: Response) => {
res.status(200).json(report.generate());
});
router.delete('/all', deleteAll);
router.delete('/:eventId', paramsMustHaveEventId, deleteWithId);
router.delete('/all', (_req: Request, res: Response) => {
report.clear();
res.status(204).send();
});
router.delete('/:id', paramsWithId, (req: Request, res: Response) => {
const { id } = req.params;
report.clear(id);
res.status(204).send();
});

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