Compare commits

..

43 Commits

Author SHA1 Message Date
google-labs-jules[bot] 54534012bd Refactor Google Sheets integration for better modularity and testability
- Decoupled Google Sheets API interaction into `GoogleSheetsClient`.
- Decoupled OAuth2 device code flow logic and state into `DeviceAuthProvider`.
- Refactored `sheets.service.ts` to use these new components while maintaining the existing public API.
- Added comprehensive unit tests for `GoogleSheetsClient` and `DeviceAuthProvider`.
- Improved type safety by using `OAuth2Client` instead of `any`.
- Ensured consistency with original behavior by including `responseRanges` in `batchUpdate`.

Co-authored-by: cpvalente <34649812+cpvalente@users.noreply.github.com>
2026-02-22 18:15:38 +00:00
Carlos Valente 1a23287863 refactor: osc disabled by default to prevent collisions 2026-02-17 21:23:34 +01:00
Alex Christoffer Rasmussen 0b3ba65df3 feat: allow existing custom fields to keep its type on excel import (#1976) 2026-02-17 07:59:27 +08:00
Carlos Valente c3a7cd6a39 bump version to 4.4.2 2026-02-16 06:58:08 +01:00
Carlos Valente 39e6c15adf fix: hide now and next cards if there is no content 2026-02-16 06:58:08 +01:00
Carlos Valente aec6673b07 refactor: improve connection handling
- only invalidate caches on reconnect
- add jitter to avoid overwhelming server and allow cache to stabilise
- prevent multiple reconnect timers
2026-02-14 12:08:04 +01:00
Carlos Valente 9e8cb1c755 refactor: improve request cancellation and timeout handling 2026-02-14 12:08:04 +01:00
Carlos Valente cdf02f7c3f bump version to 4.4.1 2026-02-14 12:07:45 +01:00
Carlos Valente 77779a4648 refactor: debounce writing to disk 2026-02-14 09:30:24 +01:00
Carlos Valente e16ec3f388 refactor: prevent suspending page on first add 2026-02-14 08:56:16 +01:00
Carlos Valente 63cfc9ec19 refactor: optimistic add elements in UI 2026-02-14 08:56:16 +01:00
Carlos Valente 4827117163 refactor: extract entry creation logic 2026-02-14 08:56:16 +01:00
Carlos Valente 5777ef3532 refactor: extract parent resolution 2026-02-14 08:56:16 +01:00
Carlos Valente 80a3b04493 refactor: export rundown placement logic 2026-02-14 08:56:16 +01:00
Carlos Valente 7c5fdd3b19 refactor: remove unused exports 2026-02-14 08:56:16 +01:00
Carlos Valente 31fd8e5200 fix: prevent infinite render in settings forms 2026-02-13 21:12:29 +01:00
Carlos Valente b6a01a52e2 fix: table re-renders no longer lose cell focus 2026-02-08 17:41:05 +01:00
Carlos Valente f9563fca5f bump to version 4.4.0 2026-02-08 17:40:05 +01:00
Carlos Valente af1da58718 fix: update data in background rundowns 2026-02-08 17:39:53 +01:00
Carlos Valente 0f83ceaf01 fix: maintain lock on param changes 2026-02-08 17:17:25 +01:00
Carlos Valente aecf9575b1 refactor: link to settings section 2026-02-08 11:20:09 +01:00
Carlos Valente 773229ce4c refactor: align inputs with group, refs #1857 2026-02-08 11:20:09 +01:00
Carlos Valente da02ecd113 feat: editor layout 2026-02-08 11:20:09 +01:00
Carlos Valente 967e92e2c8 feat: extend rundown shortcuts 2026-02-08 11:20:09 +01:00
Carlos Valente d7af73d9ed feat: unify rundown features 2026-02-08 11:20:09 +01:00
Carlos Valente c5045ad82e refactor: table mode and context 2026-02-08 11:20:09 +01:00
Carlos Valente 8c22968bc9 fix: tsconfig references for utility tests 2026-02-08 11:20:09 +01:00
Carlos Valente 8aad271ffe refactor: create utility for enums 2026-02-08 11:20:09 +01:00
Carlos Valente b28573455a feat: create reusable scroll area 2026-02-08 11:20:09 +01:00
Carlos Valente e25725ea8b refactor: expose actions through context 2026-02-08 11:20:09 +01:00
Carlos Valente 1f8065143a refactor: improve context menu performance
- extract menu from rundown tree
- lazy create actions
2026-02-08 11:20:09 +01:00
Carlos Valente 8e32314667 refactor: improve performance in rundown
- leverage compiler
- correct subscriptions from zustand
2026-02-08 11:20:09 +01:00
Carlos Valente 0de55e74a8 feat: table mode in editor 2026-02-08 11:20:09 +01:00
Carlos Valente e5c5ec18d3 fix: automation form validation 2026-02-08 11:20:09 +01:00
Carlos Valente f9f6cb74ad chore: update npm link 2026-02-01 18:03:56 +01:00
Alex Christoffer Rasmussen 026e24bf84 Feat: unify view search params (#1947)
* feat: main src in backstage view

* feat: main src in timeline view

* feat: main src in countdown view

* feat: main src in studio view

* feat: hide past events in countdown view

* notes can not be set as main src

* feat: show group title as secondary src

* chore: spelling

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

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2026-01-27 20:42:07 +01:00
Joel Wetzell ec1890c275 update docker actions and pin to commit hash 2026-01-26 14:10:09 +01:00
Joel Wetzell 046f1176eb add docker metadata action to setup tags 2026-01-26 14:10:09 +01:00
Alex Christoffer Rasmussen 04c5b67475 fix: missing demo logo (#1954) 2026-01-26 13:52:29 +01:00
google-labs-jules[bot] 7b5f797c95 refactor: move welcome modal logic to AppStateService
The logic for deciding whether to show the welcome modal has been moved from `app.ts` to `AppStateService.ts`. The `getShowWelcomeDialog` function now accepts a boolean indicating whether a restore point exists and returns `false` if it does. This keeps the business logic out of the main application file and in the relevant service.
2026-01-24 07:45:56 +01:00
google-labs-jules[bot] 14f9472250 fix: hide welcome modal on restore
The welcome modal is now hidden if the application is started from a restore point.
2026-01-24 07:45:56 +01:00
Alex Christoffer Rasmussen efede83271 Fix: automation (#1943)
* Fix(automation): Handles undefined values in conditions (#1933)

* Fix(automation): Handles undefined values in conditions

Addresses an issue where conditions with the 'not_equals' operator incorrectly evaluated undefined values. This change ensures that empty string comparisons correctly identify missing values in automation rules.

* Fixes automation "not equals" logic

Simplifies the 'not_equals' condition evaluation in automations by reusing the 'equals' condition, improving code readability and consistency.

Fixes #1932

* Test(automation): Expanding filter condition testing

Expanding test cases to cover various data types and edge case for each operators.
Unexpected behavior have been mark with a TO_DO.

* Fix(automation): Handles default filter case

- Ensures that their is a default scenario.
- Fixing Deepsource issue "No default cases in switch statements JS-0047"

* Test(automation): Remove abstraction in test

* move to test.each

* extract a isEquivalent function

* lowercase contains test

---------

Co-authored-by: Philippe Allard-Rousse <philrousse@gmail.com>
2026-01-21 16:07:21 +01:00
Carlos Valente 904db95f59 docs: update linux links 2026-01-16 07:52:56 +01:00
126 changed files with 2447 additions and 1239 deletions
+15 -16
View File
@@ -37,32 +37,31 @@ jobs:
run: pnpm build:docker
- name: Docker Login
uses: docker/login-action@v2.1.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Setup Docker metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/ontime
tags: |
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}
type=raw,value=nightly,enable=${{ github.event.release.prerelease == true }}
- name: Build and push stable release
if: github.event.release.prerelease == false
uses: docker/build-push-action@v4.0.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+6 -4
View File
@@ -10,13 +10,15 @@
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe">Windows</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">macOS (Apple Silicon)</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">macOS (Intel)</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-x86_64.AppImage">Linux (Intel / AMD 64-bit)</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-arm64.AppImage">Linux (ARM 64-bit, Raspberry Pi 4+)</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-armv7l.AppImage">Linux (ARM 32-bit, older Raspberry Pi)</a>
... or
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
- Install from <a href="https://www.npmjs.com/package/ontime">NPM</a>
- Install from <a href="https://www.npmjs.com/package/@getontime/cli">NPM</a>
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
## Need help?
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.4.0-beta.0",
"version": "4.4.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.4.0-beta.0",
"version": "4.4.2",
"private": true,
"type": "module",
"dependencies": {
+3
View File
@@ -3,6 +3,9 @@ import { Tooltip } from '@base-ui/react/tooltip';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// apply global axios config defaults
import './common/api/axios.config';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
import { AppContextProvider } from './common/context/AppContext';
+5 -4
View File
@@ -4,14 +4,15 @@ import { TranslationObject } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
import type { RequestOptions } from './requestOptions';
const assetsPath = `${apiEntryUrl}/assets`;
/**
* HTTP request to get css contents
*/
export async function getCSSContents(): Promise<string> {
const res = await axios.get(`${assetsPath}/css`);
export async function getCSSContents(options?: RequestOptions): Promise<string> {
const res = await axios.get(`${assetsPath}/css`, { signal: options?.signal });
return res.data;
}
@@ -35,8 +36,8 @@ export async function restoreCSSContents(): Promise<string> {
/**
* HTTP request to get user translation
*/
export async function getUserTranslation(): Promise<TranslationObject> {
const res = await axios.get(customTranslationsURL);
export async function getUserTranslation(options?: RequestOptions): Promise<TranslationObject> {
const res = await axios.get(customTranslationsURL, { signal: options?.signal });
return res.data;
}
+3 -2
View File
@@ -9,14 +9,15 @@ import type {
} from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const automationsPath = `${apiEntryUrl}/automations`;
/**
* HTTP request to get the automations settings
*/
export async function getAutomationSettings(): Promise<AutomationSettings> {
const res = await axios.get(automationsPath);
export async function getAutomationSettings(options?: RequestOptions): Promise<AutomationSettings> {
const res = await axios.get(automationsPath, { signal: options?.signal });
return res.data;
}
+4 -1
View File
@@ -1,5 +1,8 @@
import axios from 'axios';
import { axiosConfig } from './requestTimeouts';
axios.defaults.validateStatus = (status) => {
return (status >= 200 && status < 300) || status === 304;
return status >= 200 && status < 300;
};
axios.defaults.timeout = axiosConfig.shortTimeout;
+3 -2
View File
@@ -2,14 +2,15 @@ import axios from 'axios';
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const customFieldsPath = `${apiEntryUrl}/custom-fields`;
/**
* Requests list of known custom fields
*/
export async function getCustomFields(): Promise<CustomFields> {
const res = await axios.get(customFieldsPath);
export async function getCustomFields(options?: RequestOptions): Promise<CustomFields> {
const res = await axios.get(customFieldsPath, { signal: options?.signal });
return res.data;
}
+9 -3
View File
@@ -2,6 +2,8 @@ import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { createBlob, downloadBlob } from './utils';
const dbPath = `${apiEntryUrl}/db`;
@@ -10,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
* HTTP request to the current DB
*/
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download`, { filename });
return axios.post(`${dbPath}/download`, { filename }, { timeout: axiosConfig.longTimeout });
}
/**
@@ -37,6 +39,7 @@ export async function uploadProjectFile(file: File): Promise<MessageResponse> {
const formData = new FormData();
formData.append('project', file);
const response = await axios.post(`${dbPath}/upload`, formData, {
timeout: axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
@@ -76,8 +79,11 @@ export async function quickProject(data: QuickStartData): Promise<MessageRespons
/**
* HTTP request to get the list of available project files
*/
export async function getProjects(): Promise<ProjectFileListResponse> {
const res = await axios.get(`${dbPath}/all`);
export async function getProjects(options?: RequestOptions): Promise<ProjectFileListResponse> {
const res = await axios.get(`${dbPath}/all`, {
signal: options?.signal,
timeout: options?.timeout,
});
return res.data;
}
+22 -6
View File
@@ -3,6 +3,8 @@ import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { downloadBlob } from './utils';
const excelPath = `${apiEntryUrl}/excel`;
@@ -11,10 +13,12 @@ const excelPath = `${apiEntryUrl}/excel`;
* upload Excel file to server
* @return string - file ID op the uploaded file
*/
export async function upload(file: File): Promise<string[]> {
export async function upload(file: File, requestOptions?: RequestOptions): Promise<string[]> {
const formData = new FormData();
formData.append('excel', file);
const response = await axios.post(`${excelPath}/upload`, formData, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
@@ -27,19 +31,31 @@ type PreviewSpreadsheetResponse = {
customFields: CustomFields;
summary: RundownSummary;
};
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
options,
});
export async function importRundownPreview(
options: ImportMap,
requestOptions?: RequestOptions,
): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(
`${excelPath}/preview`,
{
options,
},
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
}
/**
* Downloads a xlsx representation of the rundown from the server
*/
export async function downloadAsExcel(rundownId: string, fileName?: string) {
export async function downloadAsExcel(rundownId: string, fileName?: string, requestOptions?: RequestOptions) {
try {
const response = await axios.get(`${excelPath}/${rundownId}/export`, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
responseType: 'blob',
});
+4 -2
View File
@@ -2,6 +2,8 @@ import axios from 'axios';
import { apiRepoLatest } from '../../externals';
import type { RequestOptions } from './requestOptions';
export type HasUpdate = {
url: string;
version: string;
@@ -10,8 +12,8 @@ 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);
export async function getLatestVersion(options?: RequestOptions): Promise<HasUpdate> {
const res = await axios.get(apiRepoLatest, { signal: options?.signal });
return {
url: res.data.html_url as string,
version: res.data.tag_name as string,
+8 -2
View File
@@ -2,14 +2,19 @@ import axios, { AxiosResponse } from 'axios';
import { ProjectData, ProjectLogoResponse } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
const projectPath = `${apiEntryUrl}/project`;
/**
* HTTP request to fetch project data
*/
export async function getProjectData(): Promise<ProjectData> {
const res = await axios.get(projectPath);
export async function getProjectData(options?: RequestOptions): Promise<ProjectData> {
const res = await axios.get(projectPath, {
signal: options?.signal,
timeout: options?.timeout,
});
return res.data;
}
@@ -27,6 +32,7 @@ export async function uploadProjectLogo(file: File): Promise<AxiosResponse<Proje
const formData = new FormData();
formData.append('image', file);
const response = await axios.post(`${projectPath}/upload`, formData, {
timeout: axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
+3 -2
View File
@@ -4,14 +4,15 @@ import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, REPORT } from './constants';
import type { RequestOptions } from './requestOptions';
export const reportUrl = `${apiEntryUrl}/report`;
/**
* HTTP request to fetch all reports
*/
export async function fetchReport(): Promise<OntimeReport> {
const res = await axios.get(reportUrl);
export async function fetchReport(options?: RequestOptions): Promise<OntimeReport> {
const res = await axios.get(reportUrl, { signal: options?.signal });
return res.data;
}
@@ -0,0 +1,4 @@
export type RequestOptions = {
signal?: AbortSignal;
timeout?: number;
};
@@ -0,0 +1,8 @@
/**
* Keep a short global timeout for regular API requests, but allow
* longer windows for file transfer and heavy import/export operations.
*/
export const axiosConfig = {
shortTimeout: 20 * 1000, // 20 seconds
longTimeout: 3 * 60 * 1000, // 3 minutes
} as const;
+5 -4
View File
@@ -2,6 +2,7 @@ import axios, { AxiosResponse } from 'axios';
import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
type RundownId = string;
const rundownPath = `${apiEntryUrl}/rundowns`;
@@ -11,16 +12,16 @@ const rundownPath = `${apiEntryUrl}/rundowns`;
/**
* HTTP request to fetch a list of existing rundowns
*/
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
const res = await axios.get(rundownPath);
export async function fetchProjectRundownList(options?: RequestOptions): Promise<ProjectRundownsList> {
const res = await axios.get(rundownPath, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to fetch all entries in the currently loaded rundown
*/
export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`);
export async function fetchCurrentRundown(options?: RequestOptions): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`, { signal: options?.signal });
if (!isValidRundown(res.data)) {
throw new Error('Invalid rundown payload');
}
+3 -2
View File
@@ -2,14 +2,15 @@ import axios from 'axios';
import { GetInfo, LinkOptions } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const sessionPath = `${apiEntryUrl}/session`;
/**
* HTTP request to retrieve application info
*/
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`);
export async function getInfo(options?: RequestOptions): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`, { signal: options?.signal });
return res.data;
}
+3 -2
View File
@@ -2,14 +2,15 @@ import axios, { AxiosResponse } from 'axios';
import { Settings } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const settingsPath = `${apiEntryUrl}/settings`;
/**
* HTTP request to retrieve application settings
*/
export async function getSettings(): Promise<Settings> {
const res = await axios.get(settingsPath);
export async function getSettings(options?: RequestOptions): Promise<Settings> {
const res = await axios.get(settingsPath, { signal: options?.signal });
return res.data;
}
+36 -5
View File
@@ -3,6 +3,8 @@ import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ont
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
const sheetsPath = `${apiEntryUrl}/sheets`;
@@ -23,6 +25,7 @@ export const verifyAuthenticationStatus = async (): Promise<{
export const requestConnection = async (
file: File,
sheetId: string,
requestOptions?: RequestOptions,
): Promise<{
verification_url: string;
user_code: string;
@@ -31,6 +34,8 @@ export const requestConnection = async (
formData.append('client_secret', file);
const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
@@ -53,24 +58,50 @@ export const revokeAuthentication = async (): Promise<{ authenticated: Authentic
export const previewRundown = async (
sheetId: string,
options: ImportMap,
requestOptions?: RequestOptions,
): Promise<{
rundown: Rundown;
customFields: CustomFields;
summary: RundownSummary;
}> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
const response = await axios.post(
`${sheetsPath}/${sheetId}/read`,
{ options },
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
};
export const getWorksheetNames = async (sheetId: string): Promise<string[]> => {
const response: AxiosResponse<string[]> = await axios.post(`${sheetsPath}/${sheetId}/worksheets`);
export const getWorksheetNames = async (sheetId: string, requestOptions?: RequestOptions): Promise<string[]> => {
const response: AxiosResponse<string[]> = await axios.post(
`${sheetsPath}/${sheetId}/worksheets`,
undefined,
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
};
/**
* HTTP request to upload the rundown to a google sheet
*/
export const uploadRundown = async (sheetId: string, options: ImportMap): Promise<void> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/write`, { options });
export const uploadRundown = async (
sheetId: string,
options: ImportMap,
requestOptions?: RequestOptions,
): Promise<void> => {
const response = await axios.post(
`${sheetsPath}/${sheetId}/write`,
{ options },
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
return response.data;
};
+3 -2
View File
@@ -2,14 +2,15 @@ import axios from 'axios';
import { URLPreset } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
/**
* HTTP request to retrieve all presets
*/
export async function getUrlPresets(): Promise<URLPreset[]> {
const res = await axios.get(urlPresetsPath);
export async function getUrlPresets(options?: RequestOptions): Promise<URLPreset[]> {
const res = await axios.get(urlPresetsPath, { signal: options?.signal });
return res.data;
}
+3 -2
View File
@@ -2,6 +2,7 @@ import axios from 'axios';
import type { ViewSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
@@ -9,8 +10,8 @@ const viewSettingsPath = `${apiEntryUrl}/view-settings`;
* HTTP request to fetch view settings
* @returns
*/
export async function getViewSettings(): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath);
export async function getViewSettings(options?: RequestOptions): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath, { signal: options?.signal });
return res.data;
}
@@ -16,6 +16,7 @@
.title-card__title {
color: var(--color-override, $viewer-color);
padding-right: 1em;
min-height: 1.2em;
}
.title-card__placeholder {
@@ -11,7 +11,7 @@ import IconButton from '../buttons/IconButton';
import Info from '../info/Info';
import { ViewOption } from './viewParams.types';
import { getURLSearchParamsFromObj } from './viewParams.utils';
import { getPreservedSearchParams, getURLSearchParamsFromObj } from './viewParams.utils';
import { useViewParamsEditorStore } from './viewParamsEditor.store';
import { ViewParamsPresets } from './ViewParamsPresets';
import ViewParamsSection from './ViewParamsSection';
@@ -25,17 +25,19 @@ interface EditFormDrawerProps {
export default memo(ViewParamsEditor);
function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [_, setSearchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore();
const isSmallScreen = useIsSmallScreen();
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
const handleClose = () => {
close();
};
const resetParams = () => {
setSearchParams();
setSearchParams(getPreservedParams());
};
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
@@ -43,6 +45,10 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
const preservedParams = getPreservedParams();
preservedParams.forEach((value, key) => {
newSearchParams.append(key, value);
});
setSearchParams(newSearchParams);
if (isSmallScreen) {
@@ -191,3 +191,26 @@ export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields:
return newSearchParams;
}
/**
* Extracts search params that are not managed by the view params editor
* @param currentParams - The current URL search params
* @param paramFields - The view options that define the managed parameters
* @returns A new URLSearchParams object with preserved params
*/
export function getPreservedSearchParams(currentParams: URLSearchParams, paramFields: ViewOption[]) {
const managedParamIds = new Set<string>();
paramFields.forEach((section) => {
section.options.forEach((option) => {
managedParamIds.add(option.id);
});
});
const preserved = new URLSearchParams();
currentParams.forEach((value, key) => {
if (!managedParamIds.has(key)) {
preserved.append(key, value);
}
});
return preserved;
}
@@ -17,7 +17,7 @@ export default function useAppVersion() {
refetch,
} = useQuery({
queryKey: APP_VERSION,
queryFn: getLatestVersion,
queryFn: ({ signal }) => getLatestVersion({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
@@ -8,12 +8,9 @@ import { automationPlaceholderSettings } from '../models/AutomationSettings';
export default function useAutomationSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: AUTOMATION,
queryFn: getAutomationSettings,
queryFn: ({ signal }) => getAutomationSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
@@ -10,12 +10,9 @@ const placeholder: CustomFields = {};
export default function useCustomFields() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS,
queryFn: getCustomFields,
queryFn: ({ signal }) => getCustomFields({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? placeholder, status, isFetching, isError, refetch };
@@ -8,7 +8,7 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
export function useCustomTranslation() {
const { data, status, refetch } = useQuery({
queryKey: TRANSLATION,
queryFn: getUserTranslation,
queryFn: ({ signal }) => getUserTranslation({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
@@ -9,12 +9,9 @@ import { ontimePlaceholderInfo } from '../models/Info';
export default function useInfo() {
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
queryKey: APP_INFO,
queryFn: getInfo,
queryFn: ({ signal }) => getInfo({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? ontimePlaceholderInfo, status, isError, refetch, isFetching };
@@ -8,7 +8,7 @@ import { projectDataPlaceholder } from '../models/ProjectData';
export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: PROJECT_DATA,
queryFn: getProjectData,
queryFn: ({ signal }) => getProjectData({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
@@ -14,12 +14,9 @@ const placeholderProjectList: ProjectFileListResponse = {
function useProjectList() {
const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST,
queryFn: getProjects,
queryFn: ({ signal }) => getProjects({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? placeholderProjectList, status, refetch };
}
@@ -11,7 +11,7 @@ import { createRundown, deleteRundown, duplicateRundown, fetchProjectRundownList
export function useProjectRundowns() {
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
queryKey: PROJECT_RUNDOWNS,
queryFn: fetchProjectRundownList,
queryFn: ({ signal }) => fetchProjectRundownList({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
@@ -8,11 +8,8 @@ import { fetchReport } from '../api/report';
export default function useReport() {
const { data, refetch } = useQuery<OntimeReport>({
queryKey: REPORT,
queryFn: fetchReport,
queryFn: ({ signal }) => fetchReport({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
networkMode: 'always',
staleTime: MILLIS_PER_HOUR,
});
@@ -24,7 +24,7 @@ const cachedRundownPlaceholder: Rundown = {
export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: RUNDOWN,
queryFn: fetchCurrentRundown,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
refetchInterval: queryRefetchIntervalSlow,
});
@@ -8,7 +8,7 @@ import { ontimePlaceholderSettings } from '../models/OntimeSettings';
export default function useSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: APP_SETTINGS,
queryFn: getSettings,
queryFn: ({ signal }) => getSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
select: (data) => {
const unobfuscated = { ...data };
@@ -13,7 +13,7 @@ interface FetchProps {
export default function useUrlPresets({ skip = false }: FetchProps = {}) {
const { data, status, isError, refetch } = useQuery({
queryKey: URL_PRESETS,
queryFn: getUrlPresets,
queryFn: ({ signal }) => getUrlPresets({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
enabled: !skip,
@@ -9,7 +9,7 @@ import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() {
const { data, status } = useQuery({
queryKey: VIEW_SETTINGS,
queryFn: getViewSettings,
queryFn: ({ signal }) => getViewSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
staleTime: MILLIS_PER_HOUR,
});
+111 -14
View File
@@ -6,15 +6,31 @@ import {
isOntimeEvent,
isOntimeGroup,
MaybeString,
OntimeDelay,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
PatchWithId,
Rundown,
SupportedEntry,
TimeField,
TimeStrategy,
TransientEventPayload,
} from 'ontime-types';
import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, swapEventData } from 'ontime-utils';
import {
addToRundown,
createDelay,
createEvent,
createGroup,
createMilestone,
dayInMs,
generateId,
getInsertAfterId,
MILLIS_PER_SECOND,
parseUserTime,
resolveInsertParent,
swapEventData,
} from 'ontime-utils';
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
import { RUNDOWN } from '../api/constants';
@@ -85,9 +101,64 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: addEntryMutation } = useMutation({
mutationFn: ([rundownId, entry]: Parameters<typeof postAddEntry>) => postAddEntry(rundownId, entry),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry),
onMutate: async ([_rundownId, entry]) => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) {
const optimisticEntry = createOptimisticEntry(entry);
const parentId = resolveInsertParent(previousData, entry);
const maybeParent = parentId ? previousData.entries[parentId] : null;
const parent = maybeParent && isOntimeGroup(maybeParent) ? maybeParent : null;
const afterId = getInsertAfterId(previousData, parent, entry.after, entry.before);
// create a mutable copy — addToRundown mutates in place using immutable array operations
const newRundown: Rundown = {
...previousData,
entries: { ...previousData.entries },
revision: -1,
};
// copy the parent group so we don't mutate the original
if (parent && parentId) {
newRundown.entries[parentId] = { ...parent, entries: [...parent.entries] };
}
addToRundown(
newRundown,
optimisticEntry,
afterId,
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
);
queryClient.setQueryData<Rundown>(RUNDOWN, newRundown);
}
return { previousData };
},
onSuccess: (response) => {
if (!response.data) return;
const serverEntry = response.data;
const currentData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (currentData) {
queryClient.setQueryData<Rundown>(RUNDOWN, {
...currentData,
entries: { ...currentData.entries, [serverEntry.id]: serverEntry },
});
}
},
onError: (_error, _variables, context) => {
if (context?.previousData) {
queryClient.setQueryData<Rundown>(RUNDOWN, context.previousData);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
});
/**
@@ -102,7 +173,15 @@ export const useEntryActions = () => {
throw new Error('Rundown not initialised');
}
const newEntry: TransientEventPayload = { ...entry, id: generateId() };
const newEntry: PatchWithId & InsertOptions = { ...entry, id: generateId() };
// handle adding options that concern all event types
if (options?.after) {
newEntry.after = options.after;
}
if (options?.before) {
newEntry.before = options.before;
}
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) {
@@ -142,14 +221,6 @@ export const useEntryActions = () => {
}
}
// handle adding options that concern all event type
if (options?.after) {
(newEntry as TransientEventPayload).after = options.after;
}
if (options?.before) {
(newEntry as TransientEventPayload).before = options.before;
}
try {
await addEntryMutation([rundownId, newEntry]);
} catch (error) {
@@ -905,3 +976,29 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
return { entries, order, flatOrder };
}
/**
* Utility to create an optimistic entry for immediate cache insertion
*/
function createOptimisticEntry(payload: PatchWithId & InsertOptions): OntimeEntry {
const { after: _after, before: _before, ...entryData } = payload;
const id = entryData.id;
let parent: EntryId | null = null;
if ('parent' in entryData && entryData.parent) {
parent = entryData.parent;
}
switch (entryData.type) {
case SupportedEntry.Event: {
return createEvent({ ...entryData, id, parent }) as OntimeEvent;
}
case SupportedEntry.Delay:
return createDelay({ id, duration: (entryData as Partial<OntimeDelay>).duration ?? 0, parent });
case SupportedEntry.Group:
return createGroup({ ...(entryData as Partial<OntimeGroup>), id });
case SupportedEntry.Milestone:
return createMilestone({ ...(entryData as Partial<OntimeMilestone>), id, parent });
default:
throw new Error('Unknown entry type');
}
}
+10 -3
View File
@@ -1,4 +1,5 @@
import { QueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { isOntimeCloud } from '../externals';
@@ -8,10 +9,15 @@ export const ontimeQueryClient = new QueryClient({
queries: {
gcTime: 10 * MILLIS_PER_MINUTE,
// staleTime: MILLIS_PER_HOUR, //TODO: when all routes have implemented refetch signal from server, we can the assume that the data is not stale until we get the signal
networkMode: 'always',
networkMode: isOntimeCloud ? 'online' : 'always',
refetchOnWindowFocus: false,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
retry: (failureCount, error) => {
if (axios.isCancel(error)) {
return false;
}
return failureCount < 5;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 5000),
},
mutations: {
/**
@@ -21,6 +27,7 @@ export const ontimeQueryClient = new QueryClient({
* - use 'online' for clients that are connected to the cloud
*/
networkMode: isOntimeCloud ? 'online' : 'always',
retry: 0,
},
},
});
+31 -7
View File
@@ -38,7 +38,13 @@ import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null;
const reconnectInterval = 1000;
const socketConfig = {
reconnectBaseInterval: 1000, // 1 second
reconnectMaxInterval: 30000, // 30 seconds
reconnectMinInterval: 500, // 0.5 seconds
reconnectJitter: 0.25,
offlineAttemptsThreshold: 2, // when we consider the client disconnected
} as const;
export let hasConnected = false;
export let reconnectAttempts = 0;
@@ -49,7 +55,11 @@ export const connectSocket = () => {
const preferredClientName = getClientName();
websocket.onopen = () => {
clearTimeout(reconnectTimeout as NodeJS.Timeout);
const isReconnect = hasConnected;
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
hasConnected = true;
reconnectAttempts = 0;
@@ -59,24 +69,38 @@ export const connectSocket = () => {
path: window.location.pathname + window.location.search,
name: preferredClientName,
});
invalidateAllCaches(); // assume all data to be stale after a reconnect
if (isReconnect) {
invalidateAllCaches();
}
setOnlineStatus(true);
};
websocket.onclose = () => {
console.warn('WebSocket disconnected');
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
const exponentialDelay = Math.min(
socketConfig.reconnectBaseInterval * 2 ** reconnectAttempts,
socketConfig.reconnectMaxInterval,
);
const jitterOffset = exponentialDelay * socketConfig.reconnectJitter * (Math.random() * 2 - 1);
const delay = Math.max(socketConfig.reconnectMinInterval, Math.round(exponentialDelay + jitterOffset));
// we decide to allows reconnect
reconnectTimeout = setTimeout(() => {
if (reconnectAttempts > 2) {
reconnectTimeout = null;
if (reconnectAttempts > socketConfig.offlineAttemptsThreshold) {
setOnlineStatus(false);
}
console.warn('WebSocket: attempting reconnect');
console.warn(`WebSocket: reconnecting now (#${reconnectAttempts + 1}, waited ${delay}ms)`);
if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1;
connectSocket();
}
}, reconnectInterval);
}, delay);
};
websocket.onerror = (error) => {
@@ -32,7 +32,6 @@ export default function GeneralSettings() {
} = useForm<Settings>({
mode: 'onChange',
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
@@ -31,7 +31,6 @@ export default function ViewSettings() {
formState: { isSubmitting, isDirty, errors },
} = useForm<ViewSettingsType>({
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
@@ -1,4 +1,4 @@
import { lazy, useEffect, useRef, useState } from 'react';
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../../common/api/assets';
import Button from '../../../../../common/components/buttons/Button';
@@ -81,7 +81,9 @@ export default function CodeEditorModal({ isOpen, onClose }: CodeEditorModalProp
showCloseButton
showBackdrop
bodyElements={
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
<Suspense fallback={null}>
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
</Suspense>
}
footerElements={
<div className={style.column}>
@@ -90,7 +90,10 @@ const staticOptions = [
},
] as const;
export type SettingsOptionId = (typeof staticOptions)[number]['id'];
// a child of navigation or a child of secondary navigation
export type SettingsOptionId =
| (typeof staticOptions)[number]['id']
| Extract<(typeof staticOptions)[number], { secondary: object }>['secondary'][number]['id'];
export function useAppSettingsMenu() {
const { data } = useAppVersion();
@@ -21,7 +21,7 @@
}
&[data-phase='overtime'] {
color: $playback-negative;;
color: $playback-negative;
}
&[data-phase='pending'] {
+29 -15
View File
@@ -1,9 +1,16 @@
import { type HTMLProps, forwardRef, Fragment, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { type HTMLProps, forwardRef, Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { TbFlagFilled } from 'react-icons/tb';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { closestCenter, DndContext } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { type EntryId, type Rundown, isOntimeEvent, isOntimeGroup, Playback, SupportedEntry } from 'ontime-types';
import {
type EntryId,
type Rundown as RundownType,
isOntimeEvent,
isOntimeGroup,
Playback,
SupportedEntry,
} from 'ontime-types';
import { useEntryActionsContext } from '../../common/context/EntryActionsContext';
import { useEntryCopy } from '../../common/stores/entryCopyStore';
@@ -19,19 +26,18 @@ import RundownGroup from './rundown-group/RundownGroup';
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
import { filterVisibleEntries, makeSortableList } from './rundown.utils';
import RundownEmpty from './RundownEmpty';
import RundownEntry from './RundownEntry';
import { useCollapsedGroups } from './useCollapsedGroups';
import { useEditorFollowMode } from './useEditorFollowMode';
import { useEventSelection } from './useEventSelection';
import style from './Rundown.module.scss';
const RundownEntry = lazy(() => import('./RundownEntry'));
interface RundownProps {
entries: Rundown['entries'];
id: Rundown['id'];
order: Rundown['order'];
flatOrder: Rundown['flatOrder'];
entries: RundownType['entries'];
id: RundownType['id'];
order: RundownType['order'];
flatOrder: RundownType['flatOrder'];
rundownMetadata: RundownMetadataObject;
featureData: {
playback: Playback;
@@ -145,19 +151,27 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
};
}, [visibleData, dnd.isDraggingRef, setScrollHandler]);
// Keep selected cursor visible by expanding its parent group in any mode.
// Finder can select entries while layout is in Run mode, where we do not follow cursor scroll.
useEffect(() => {
if (!cursor) {
return;
}
const entry = entries[cursor];
if (entry && 'parent' in entry) {
expandGroup(entry.parent);
}
}, [cursor, entries, expandGroup]);
// Auto-follow behavior in Edit mode: follow the user's cursor
useEffect(() => {
if (dnd.isDraggingRef.current || editorMode !== AppMode.Edit || !cursor) {
return;
}
// Open parent group if the target is inside a collapsed group
const entry = entries[cursor];
if (entry && 'parent' in entry) {
expandGroup(entry.parent);
}
scrollToEntry(cursor);
}, [editorMode, cursor, entries, expandGroup, scrollToEntry, dnd.isDraggingRef]);
}, [editorMode, cursor, scrollToEntry, dnd.isDraggingRef]);
// Auto-follow behavior in Run mode: follow the currently playing event
useEffect(() => {
@@ -317,7 +331,7 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
// Virtuoso components - extracted to prevent recreation on every render
const VirtuosoListComponent = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(({ children, ...props }, ref) => (
<div ref={ref} className={style.list} {...props}>
<div ref={ref} {...props} className={style.list}>
{children}
</div>
));
@@ -22,7 +22,7 @@
}
.rundownRoot {
height: calc(100% - 1.5rem);
height: calc(100% - 1rem);
width: 100%;
display: flex;
flex-direction: column;
@@ -33,6 +33,7 @@
display: flex;
height: inherit;
padding-inline: 0;
padding-bottom: 0;
box-shadow: $box-shadow-right;
flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
@@ -136,7 +136,6 @@ function EventEditorEmpty() {
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Backspace</Kbd>
<AuxKey>/</AuxKey>
</td>
</tr>
<tr className={style.spacer} />
@@ -1,6 +1,7 @@
import { useHotkeys } from '@mantine/hooks';
import { type OntimeEntry, EntryId, SupportedEntry } from 'ontime-types';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { useEventSelection } from '../useEventSelection';
interface UseRundownKeyboardOptions {
@@ -20,6 +21,20 @@ interface UseRundownKeyboardOptions {
setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void;
}
/**
* Returns true when a keyboard event target is a text input element.
* Use this to avoid intercepting browser-native copy/cut/paste shortcuts
* while users are typing in form fields.
*/
function isEditableElement(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
const tagName = target.tagName.toLowerCase();
return tagName === 'input' || tagName === 'textarea';
}
export function useRundownKeyboard({
cursor,
commands,
@@ -174,10 +189,50 @@ export function useRundownKeyboard({
{ preventDefault: true, usePhysicalKeys: true },
],
['mod + C', () => setEntryCopyId(cursor), { preventDefault: true, usePhysicalKeys: true }],
['mod + X', () => setEntryCopyId(cursor, 'cut'), { preventDefault: true, usePhysicalKeys: true }],
['mod + V', () => commands.insertCopyAtId(cursor), { preventDefault: true, usePhysicalKeys: true }],
[
'mod + C',
(event) => {
if (cursor === null || isEditableElement(event.target)) {
return;
}
event.preventDefault();
setEntryCopyId(cursor);
},
{ usePhysicalKeys: true },
],
[
'mod + X',
(event) => {
if (cursor === null || isEditableElement(event.target)) {
return;
}
event.preventDefault();
setEntryCopyId(cursor, 'cut');
},
{ usePhysicalKeys: true },
],
[
'mod + V',
(event) => {
if (isEditableElement(event.target) || useEntryCopy.getState().entryCopyId === null) {
return;
}
event.preventDefault();
commands.insertCopyAtId(cursor);
},
{ usePhysicalKeys: true },
],
['mod + D', () => commands.cloneEntry(cursor), { preventDefault: true, usePhysicalKeys: true }],
['mod + shift + V', () => commands.insertCopyAtId(cursor, true), { preventDefault: true, usePhysicalKeys: true }],
[
'mod + shift + V',
(event) => {
if (isEditableElement(event.target) || useEntryCopy.getState().entryCopyId === null) {
return;
}
event.preventDefault();
commands.insertCopyAtId(cursor, true);
},
{ usePhysicalKeys: true },
],
]);
}
@@ -2,7 +2,7 @@
padding-inline: 1rem 2rem;
display: flex;
align-items: center;
gap: 0.5rem;
gap: 1rem;
:is([data-target='small-device']) & {
padding-inline: 0;
@@ -72,10 +72,3 @@
gap: 0.5rem;
align-self: start;
}
.column {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-self: start;
}
@@ -76,43 +76,49 @@ function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProp
<Toolbar.Root className={style.header}>
{showRunEditToggle && (
<ToggleGroup value={[editorMode]} onValueChange={toggleAppMode} className={style.group}>
<Tooltip text='Live playback view with auto-follow' render={<span />}>
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
Run
</Toolbar.Button>
<Tooltip
text='Live playback view with auto-follow'
render={<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton} />}
>
Run
</Tooltip>
<Tooltip text='Manual editing without playback automation' render={<span />}>
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
Edit
</Toolbar.Button>
<Tooltip
text='Manual editing without playback automation'
render={<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton} />}
>
Edit
</Tooltip>
</ToggleGroup>
)}
<ToggleGroup value={[viewMode]} onValueChange={toggleViewMode} className={style.group}>
<Tooltip text='View rundown in list mode' render={<span />}>
<Toolbar.Button render={<Toggle />} value={RundownViewMode.List} className={style.radioButton}>
List
</Toolbar.Button>
<Tooltip
text='View rundown in list mode'
render={<Toolbar.Button render={<Toggle />} value={RundownViewMode.List} className={style.radioButton} />}
>
List
</Tooltip>
<Tooltip text='View rundown in table mode' render={<span />}>
<Toolbar.Button render={<Toggle />} value={RundownViewMode.Table} className={style.radioButton}>
Table
</Toolbar.Button>
<Tooltip
text='View rundown in table mode'
render={<Toolbar.Button render={<Toggle />} value={RundownViewMode.Table} className={style.radioButton} />}
>
Table
</Tooltip>
</ToggleGroup>
{showOffsetToggle && (
<ToggleGroup value={[offsetMode]} onValueChange={toggleOffsetMode} className={style.group}>
<Tooltip text='Offsets use fixed clock time' render={<span />}>
<Toolbar.Button render={<Toggle />} value={OffsetMode.Absolute} className={style.radioButton}>
Absolute
</Toolbar.Button>
<Tooltip
text='Offsets use fixed clock time'
render={<Toolbar.Button render={<Toggle />} value={OffsetMode.Absolute} className={style.radioButton} />}
>
Absolute
</Tooltip>
<Tooltip text='Offsets follow the rundown relative start' render={<span />}>
<Toolbar.Button render={<Toggle />} value={OffsetMode.Relative} className={style.radioButton}>
Relative
</Toolbar.Button>
<Tooltip
text='Offsets follow the rundown relative start'
render={<Toolbar.Button render={<Toggle />} value={OffsetMode.Relative} className={style.radioButton} />}
>
Relative
</Tooltip>
</ToggleGroup>
)}
@@ -41,7 +41,7 @@ function RundownMenu({ allowNavigation }: RundownMenuProps) {
type: 'item',
label: 'Manage Rundowns...',
icon: IoList,
onClick: () => setLocation('manage'),
onClick: () => setLocation('manage__rundowns'),
disabled: !allowNavigation,
},
{ type: 'divider' },
@@ -19,7 +19,10 @@ export function useSelectAndRevealEntry(rundownId: string) {
({ id, index, parent }: SelectAndRevealOptions) => {
expandGroup(parent);
selectEntry({ id, index, selectMode: 'click' });
scrollToEntry(id);
// Wait one frame so collapsed-group expansion is reflected in rendered/visible entries before scrolling.
requestAnimationFrame(() => {
scrollToEntry(id);
});
},
[expandGroup, scrollToEntry, selectEntry],
);
@@ -21,6 +21,7 @@ export const langDe: TranslationObject = {
'countdown.to_start': 'Zeit bis zum Start',
'countdown.waiting': 'Warten auf den Veranstaltungsbeginn',
'countdown.overtime': 'überfällig',
'countdown.all_have_finished': 'Alle ausgewählten Veranstaltungen sind beendet',
'timeline.live': 'live',
'timeline.done': 'Beendet',
'timeline.due': 'fällig',
@@ -21,6 +21,7 @@ export const langEs: TranslationObject = {
'countdown.to_start': 'Tiempo para comenzar',
'countdown.waiting': 'Esperando el inicio del evento',
'countdown.overtime': 'en tiempo extra',
'countdown.all_have_finished': 'Todos los eventos seleccionados han terminado',
'timeline.live': 'live',
'timeline.done': 'Terminado',
'timeline.due': 'pendiente',
@@ -21,6 +21,7 @@ export const langFr: TranslationObject = {
'countdown.to_start': 'Évènement commence dans',
'countdown.waiting': 'En attente du début de l’évènement',
'countdown.overtime': 'en dépassement',
'countdown.all_have_finished': 'Tous les évènements sélectionnés ont terminé',
'timeline.live': 'live',
'timeline.done': 'Terminé',
'timeline.due': 'dû',
@@ -21,6 +21,7 @@ export const langIt: TranslationObject = {
'countdown.to_start': 'Tempo alla partenza',
'countdown.waiting': "In attesa dell'inizio dell'evento",
'countdown.overtime': 'in ritardo',
'countdown.all_have_finished': 'Tutti gli eventi selezionati sono finiti',
'timeline.live': 'live',
'timeline.done': 'Terminato',
'timeline.due': 'previsto',
@@ -21,6 +21,7 @@ export const langPt: TranslationObject = {
'countdown.to_start': 'Tempo para iniciar',
'countdown.waiting': 'Aguardando o início do evento',
'countdown.overtime': 'em tempo extra',
'countdown.all_have_finished': 'Todos os eventos selecionados terminaram',
'timeline.live': 'live',
'timeline.done': 'Concluído',
'timeline.due': 'Pendente',
@@ -43,7 +43,7 @@ export default function BackstageLoader() {
function Backstage({ events, customFields, projectData, isMirrored, settings }: BackstageData) {
const { getLocalizedString } = useTranslation();
const { secondarySource, extraInfo } = useBackstageOptions();
const { mainSource, secondarySource, extraInfo } = useBackstageOptions();
const { eventNext, eventNow, rundown, selectedEventId, time } = useBackstageSocket();
const [blinkClass, setBlinkClass] = useState(false);
const { height: screenHeight } = useViewportSize();
@@ -64,7 +64,7 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
eventNow,
eventNext,
'title',
mainSource,
secondarySource,
time.playback,
);
@@ -23,6 +23,7 @@ export const getBackstageOptions = (
{ value: 'note', label: 'Note' },
]);
const projectDataOptions = makeProjectDataOptions(projectData, [{ value: 'none', label: 'None' }]);
const mainOptions = makeOptionsFromCustomFields(customFields, [{ value: 'title', label: 'Title' }]);
return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
@@ -30,6 +31,14 @@ export const getBackstageOptions = (
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'main',
title: 'Main text',
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'title',
},
{
id: 'secondary-src',
title: 'Event secondary text',
@@ -59,6 +68,7 @@ export const getBackstageOptions = (
};
type BackstageOptions = {
mainSource: keyof OntimeEvent | null;
secondarySource: keyof OntimeEvent | null;
extraInfo: string | null;
};
@@ -72,6 +82,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
return {
mainSource: getValue('main') as keyof OntimeEvent | null,
secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
extraInfo: getValue('extra-info'),
};
+20 -2
View File
@@ -1,4 +1,12 @@
import { MaybeNumber, MaybeString, OntimeEvent, TimerState, TimerType } from 'ontime-types';
import {
MaybeNumber,
MaybeString,
OntimeEvent,
OntimeGroup,
RundownEntries,
TimerState,
TimerType,
} from 'ontime-types';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils';
import { timerPlaceholder, timerPlaceholderMin } from '../../common/utils/styleUtils';
@@ -66,12 +74,22 @@ export function makeColourString(hex: string | null): string | undefined {
/**
* Retrieves a dynamic property from an event
* Considers custom fields
* if rundown entries are provided it can also find the parent title
*/
export function getPropertyValue(event: OntimeEvent | null, property: MaybeString): string | undefined {
export function getPropertyValue(
event: OntimeEvent | null,
property: MaybeString,
entries?: RundownEntries,
): string | undefined {
if (!event || typeof property !== 'string' || property === 'none') {
return undefined;
}
if (property === 'parent') {
if (!entries || !event.parent) return undefined;
return (entries[event.parent] as OntimeGroup)?.title;
}
if (property.startsWith('custom-')) {
const field = property.split('custom-')[1];
return event.custom?.[field];
+16 -4
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { EntryId, isOntimeEvent, isPlayableEvent, OntimeEvent, OntimeView } from 'ontime-types';
import { EntryId, isOntimeEvent, isPlayableEvent, OntimeEvent, OntimeView, PlayableEvent } from 'ontime-types';
import Button from '../../common/components/buttons/Button';
import Empty from '../../common/components/state/Empty';
@@ -47,7 +47,9 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
const [editMode, setEditMode] = useState(false);
// gather rundown data
const playableEvents = rundownData.filter((entry) => isOntimeEvent(entry) && isPlayableEvent(entry));
const playableEvents = rundownData.filter((entry): entry is ExtendedEntry<PlayableEvent> => {
return isOntimeEvent(entry) && isPlayableEvent(entry);
});
// gather presentation data
const hasEvents = playableEvents.length > 0;
@@ -93,6 +95,7 @@ interface CountdownContentsProps {
function CountdownContents({ playableEvents, subscriptions, goToEditMode }: CountdownContentsProps) {
const { getLocalizedString } = useTranslation();
const { hidePast } = useCountdownOptions();
if (subscriptions.length === 0) {
return (
@@ -106,6 +109,7 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun
}
const subscribedEvents = getOrderedSubscriptions(subscriptions, playableEvents);
const eventsToShow = !hidePast ? subscribedEvents : subscribedEvents.filter((event) => !event.isPast);
if (subscribedEvents.length === 0) {
return (
@@ -118,13 +122,21 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun
);
}
if (subscribedEvents.length === 1) {
if (subscribedEvents.length === 1 && eventsToShow.length === 1) {
const event = subscribedEvents.at(0);
if (!event) return null;
return <SingleEventCountdown subscribedEvent={event} goToEditMode={goToEditMode} />;
}
return <CountdownSubscriptions subscribedEvents={subscribedEvents} goToEditMode={goToEditMode} />;
if (eventsToShow.length === 0) {
return (
<div className='empty-container'>
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-container' />
</div>
);
}
return <CountdownSubscriptions subscribedEvents={eventsToShow} goToEditMode={goToEditMode} />;
}
function CountdownClock() {
@@ -36,7 +36,7 @@ interface CountdownSubscriptionsProps {
}
export default function CountdownSubscriptions({ subscribedEvents, goToEditMode }: CountdownSubscriptionsProps) {
const { secondarySource, showExpected } = useCountdownOptions();
const { mainSource, secondarySource, showExpected } = useCountdownOptions();
const playback = usePlayback();
const selectedEventId = useSelectedEventId();
const showFab = useFadeOutOnInactivity(true);
@@ -103,7 +103,7 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const isLive = getIsLive(event.id, selectedEventId, playback);
const isArmed = !isLive && event.id === selectedEventId;
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
const title = event.title.length ? event.title : ' '; // insert utf-8 empty space to avoid the line collapsing
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return (
<div
key={event.id}
@@ -114,7 +114,7 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
<div className='sub__binder' style={{ '--user-color': event.colour }} />
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
<SubscriptionStatus event={countdownEvent} />
<div className={cx(['sub__title', !event.title && 'subdued'])}>{title}</div>
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>{displayTitle}</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div>
);
@@ -23,7 +23,7 @@ interface SingleEventCountdownProps {
}
export default function SingleEventCountdown({ subscribedEvent, goToEditMode }: SingleEventCountdownProps) {
const { secondarySource, showExpected } = useCountdownOptions();
const { mainSource, secondarySource, showExpected } = useCountdownOptions();
const showFab = useFadeOutOnInactivity(true);
const { data: reportData } = useReport();
@@ -41,7 +41,8 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
const { endedAt } = reportData[subscribedEvent.id] ?? { endedAt: null };
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
const title = subscribedEvent.title.length ? subscribedEvent.title : ' '; // insert utf-8 empty space to avoid the line collapsing
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
const secondaryData = getPropertyValue(subscribedEvent, secondarySource);
return (
@@ -14,6 +14,7 @@ export const getCountdownOptions = (
customFields: CustomFields,
persistedSubscriptions: EntryId[],
): ViewOption[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, [{ value: 'title', label: 'Title' }]);
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'note', label: 'Note' },
@@ -25,6 +26,14 @@ export const getCountdownOptions = (
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'main',
title: 'Main text',
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'title',
},
{
id: 'secondary-src',
title: 'Event secondary text',
@@ -48,6 +57,19 @@ export const getCountdownOptions = (
},
],
},
{
title: OptionTitle.ElementVisibility,
collapsible: true,
options: [
{
id: 'hidePast',
title: 'Hide Past Events',
description: 'Whether to hide events that have passed',
type: 'boolean',
defaultValue: false,
},
],
},
{
title: OptionTitle.Hidden,
options: [
@@ -65,8 +87,10 @@ export const getCountdownOptions = (
type CountdownOptions = {
subscriptions: EntryId[];
mainSource: keyof OntimeEvent | null;
secondarySource: keyof OntimeEvent | null;
showExpected: boolean;
hidePast: boolean;
};
/**
@@ -87,8 +111,10 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
return {
subscriptions: getArrayValues('sub'),
mainSource: getValue('main') as keyof OntimeEvent | null,
secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
showExpected: isStringBoolean(getValue('showExpected')),
hidePast: isStringBoolean(getValue('hidePast')),
};
}
@@ -1,7 +1,14 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
import { ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import {
ContextProp,
ItemProps,
TableComponents,
TableProps,
TableVirtuoso,
TableVirtuosoHandle,
} from 'react-virtuoso';
import { useTableNav } from '@table-nav/react';
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { ColumnDef, getCoreRowModel, Table, useReactTable } from '@tanstack/react-table';
import { isOntimeDelay, isOntimeGroup, isOntimeMilestone, OntimeEntry, TimeField } from 'ontime-types';
import EmptyPage from '../../../common/components/state/EmptyPage';
@@ -170,6 +177,28 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
const allLeafColumns = table.getAllLeafColumns();
const { rows } = table.getRowModel();
const virtuosoContext = useMemo(
() => ({
columnSizeVars,
cursor,
listeners,
rows,
table,
}),
[columnSizeVars, cursor, listeners, rows, table],
);
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
const fixedHeaderContent = useCallback(() => {
return table.getHeaderGroups().map((headerGroup) => {
const HeaderComponent = table.getState().columnSizingInfo.isResizingColumn
? CuesheetHeader
: SortableCuesheetHeader;
// if the table is being resized, we render non-sortable headers to avoid performance issues
return <HeaderComponent key={headerGroup.id} cuesheetMode={cuesheetMode} headerGroup={headerGroup} />;
});
}, [cuesheetMode, table]);
const isLoading = !data || status === 'pending';
@@ -191,112 +220,146 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
<TableVirtuoso
ref={virtuosoRef}
data={data}
context={virtuosoContext}
style={tableRoot === 'editor' ? { paddingLeft: '1rem' } : undefined}
computeItemKey={computeItemKey}
increaseViewportBy={{ top: 100, bottom: 200 }}
components={{
EmptyPlaceholder: () => <EmptyTableBody text='No data in rundown' />,
Table: ({ style: injectedStyles, ...virtuosoProps }) => {
return (
<table
className={style.cuesheet}
id='cuesheet'
style={{ ...injectedStyles, ...columnSizeVars }}
{...listeners}
{...virtuosoProps}
/>
);
},
TableRow: ({ item: _item, style: injectedStyles, ...virtuosoProps }) => {
// eslint-disable-next-line react/destructuring-assignment
const rowIndex = virtuosoProps['data-index'];
const row = rows[rowIndex];
const key = row.original.id;
const entry = row.original;
const hasCursor = entry.id === cursor;
if (isOntimeGroup(entry)) {
return (
<GroupRow
key={key}
groupId={entry.id}
colour={entry.colour}
rowId={row.id}
rowIndex={row.index}
table={table}
injectedStyles={injectedStyles}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
}
if (isOntimeDelay(entry)) {
return (
<DelayRow
key={key}
duration={entry.duration}
injectedStyles={injectedStyles}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
}
if (isOntimeMilestone(entry)) {
return (
<MilestoneRow
key={key}
entryId={entry.id}
isPast={entry.isPast}
parentBgColour={entry.groupColour}
parentId={entry.parent}
colour={entry.colour}
rowId={row.id}
rowIndex={rowIndex}
table={table}
injectedStyles={injectedStyles}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
}
return (
<EventRow
key={row.id}
id={entry.id}
eventIndex={entry.eventIndex}
colour={entry.colour}
isFirstAfterGroup={entry.isFirstAfterGroup}
isLoaded={entry.isLoaded}
isPast={entry.isPast}
groupColour={entry.groupColour}
flag={entry.flag}
skip={entry.skip}
parent={entry.parent}
rowId={row.id}
rowIndex={rowIndex}
table={table}
injectedStyles={injectedStyles}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
},
TableHead: (virtuosoProps) => <thead className={style.tableHeader} {...virtuosoProps} />,
}}
fixedHeaderContent={() => {
return table.getHeaderGroups().map((headerGroup) => {
const HeaderComponent = table.getState().columnSizingInfo.isResizingColumn
? CuesheetHeader
: SortableCuesheetHeader;
// if the table is being resized, we render non-sortable headers to avoid performance issues
return <HeaderComponent key={headerGroup.id} cuesheetMode={cuesheetMode} headerGroup={headerGroup} />;
});
}}
components={virtuosoComponents}
fixedHeaderContent={fixedHeaderContent}
/>
<TableMenu />
</>
);
}
interface CuesheetVirtuosoContext {
columnSizeVars: { [key: string]: number };
cursor: string | null;
listeners: ReturnType<typeof useTableNav>['listeners'];
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
table: Table<ExtendedEntry>;
}
const EmptyPlaceholder = memo(function EmptyPlaceholder() {
return <EmptyTableBody text='No data in rundown' />;
});
const CuesheetTableElement = memo(function CuesheetTableElement({
style: injectedStyles,
context,
...virtuosoProps
}: TableProps & ContextProp<CuesheetVirtuosoContext>) {
return (
<table
className={style.cuesheet}
id='cuesheet'
style={{ ...injectedStyles, ...context.columnSizeVars }}
{...context.listeners}
{...virtuosoProps}
/>
);
});
const CuesheetTableHead = memo(function CuesheetTableHead({
context: _context,
className: _className,
...virtuosoProps
}: ComponentProps<'thead'> & ContextProp<CuesheetVirtuosoContext>) {
return <thead className={style.tableHeader} {...virtuosoProps} />;
});
const CuesheetTableRow = memo(function CuesheetTableRow({
item: _item,
style: injectedStyles,
context,
...virtuosoProps
}: ItemProps<ExtendedEntry> & ContextProp<CuesheetVirtuosoContext>) {
// eslint-disable-next-line react/destructuring-assignment
const rowIndex = virtuosoProps['data-index'];
const row = context.rows[rowIndex];
if (!row) {
return null;
}
const key = row.original.id;
const entry = row.original;
const hasCursor = entry.id === context.cursor;
if (isOntimeGroup(entry)) {
return (
<GroupRow
key={key}
groupId={entry.id}
colour={entry.colour}
rowId={row.id}
rowIndex={row.index}
table={context.table}
injectedStyles={injectedStyles}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
}
if (isOntimeDelay(entry)) {
return (
<DelayRow
key={key}
duration={entry.duration}
injectedStyles={injectedStyles}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
}
if (isOntimeMilestone(entry)) {
return (
<MilestoneRow
key={key}
entryId={entry.id}
isPast={entry.isPast}
parentBgColour={entry.groupColour}
parentId={entry.parent}
colour={entry.colour}
rowId={row.id}
rowIndex={rowIndex}
table={context.table}
injectedStyles={injectedStyles}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
}
return (
<EventRow
key={row.id}
id={entry.id}
eventIndex={entry.eventIndex}
colour={entry.colour}
isFirstAfterGroup={entry.isFirstAfterGroup}
isLoaded={entry.isLoaded}
isPast={entry.isPast}
groupColour={entry.groupColour}
flag={entry.flag}
skip={entry.skip}
parent={entry.parent}
rowId={row.id}
rowIndex={rowIndex}
table={context.table}
injectedStyles={injectedStyles}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
});
const virtuosoComponents: TableComponents<ExtendedEntry, CuesheetVirtuosoContext> = {
EmptyPlaceholder,
Table: CuesheetTableElement,
TableHead: CuesheetTableHead,
TableRow: CuesheetTableRow,
};
@@ -91,6 +91,7 @@ export default function EventRow({
}}
data-cursor={hasCursor}
data-testid='cuesheet-event'
data-entry-id={id}
{...virtuosoProps}
>
{cuesheetMode === AppMode.Edit && (
@@ -127,6 +128,8 @@ export default function EventRow({
}}
tabIndex={-1}
role='cell'
data-testid={`cuesheet-cell-${cell.column.id}`}
data-column-id={cell.column.id}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
@@ -5,12 +5,14 @@ import useReactiveTextInput from '../../../../common/components/input/text-input
interface MultiLineCellProps {
initialValue: string;
fieldId?: string;
fieldLabel?: string;
handleUpdate: (newValue: string) => void;
}
export default memo(MultiLineCell);
function MultiLineCell({ initialValue, handleUpdate }: MultiLineCellProps) {
function MultiLineCell({ initialValue, fieldId, fieldLabel, handleUpdate }: MultiLineCellProps) {
const ref = useRef<HTMLTextAreaElement | null>(null);
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
@@ -30,6 +32,8 @@ function MultiLineCell({ initialValue, handleUpdate }: MultiLineCellProps) {
onBlur={onBlur}
onKeyDown={onKeyDown}
spellCheck={false}
data-testid={fieldId ? `cuesheet-editor-${fieldId}` : undefined}
aria-label={fieldLabel ? `${fieldLabel} editor` : undefined}
/>
);
}
@@ -5,13 +5,18 @@ import useReactiveTextInput from '../../../../common/components/input/text-input
interface SingleLineCellProps {
initialValue: string;
fieldId?: string;
fieldLabel?: string;
allowSubmitSameValue?: boolean;
handleUpdate: (newValue: string) => void;
handleCancelUpdate?: () => void;
}
const SingleLineCell = forwardRef(
({ initialValue, allowSubmitSameValue, handleUpdate, handleCancelUpdate }: SingleLineCellProps, inputRef) => {
(
{ initialValue, fieldId, fieldLabel, allowSubmitSameValue, handleUpdate, handleCancelUpdate }: SingleLineCellProps,
inputRef,
) => {
const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
@@ -47,6 +52,8 @@ const SingleLineCell = forwardRef(
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
data-testid={fieldId ? `cuesheet-editor-${fieldId}` : undefined}
aria-label={fieldLabel ? `${fieldLabel} editor` : undefined}
/>
);
},
@@ -17,6 +17,10 @@ import MutedText from './MutedText';
import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput';
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string {
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
}
function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
if (!table.options.meta) {
return null;
@@ -146,7 +150,14 @@ function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, u
return <GhostedText multiline>{initialValue}</GhostedText>;
}
return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />;
return (
<MultiLineCell
initialValue={initialValue as string}
fieldId={column.id}
fieldLabel={getColumnLabel(column)}
handleUpdate={update}
/>
);
}
function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
@@ -186,7 +197,14 @@ function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry,
return <GhostedText>{initialValue}</GhostedText>;
}
return <SingleLineCell initialValue={initialValue as string} handleUpdate={update} />;
return (
<SingleLineCell
initialValue={initialValue as string}
fieldId={column.id}
fieldLabel={getColumnLabel(column)}
handleUpdate={update}
/>
);
}
function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
@@ -219,7 +237,14 @@ function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unkn
return <GhostedText multiline>{initialValue}</GhostedText>;
}
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
return (
<MultiLineCell
initialValue={initialValue}
fieldId={column.id}
fieldLabel={getColumnLabel(column)}
handleUpdate={update}
/>
);
}
/**
@@ -48,4 +48,5 @@ $max-playback-width: 30rem;
flex: 0 0 $overview-width;
min-width: $overview-width;
max-width: $overview-width;
overflow: hidden;
}
@@ -2,8 +2,9 @@
.container {
height: 100%;
width: 100%;
background: $bg-container-l1;
margin-top: 1rem;
padding-top: 1rem;
}
.list {
+2 -2
View File
@@ -32,12 +32,12 @@ export default function StudioLoader() {
return <Studio {...data} />;
}
function Studio({ projectData, isMirrored, settings, viewSettings }: StudioData) {
function Studio({ customFields, projectData, isMirrored, settings, viewSettings }: StudioData) {
const { hideCards } = useStudioOptions();
// gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const studioOptions = useMemo(() => getStudioOptions(defaultFormat), [defaultFormat]);
const studioOptions = useMemo(() => getStudioOptions(defaultFormat, customFields), [defaultFormat, customFields]);
return (
<div className={cx(['studio', isMirrored && 'mirror'])} data-testid='studio-view'>
@@ -5,8 +5,10 @@ import { useAuxTimersTime, useStudioTimersSocket } from '../../common/hooks/useS
import { getOffsetState } from '../../common/utils/offset';
import { cx } from '../../common/utils/styleUtils';
import { useTranslation } from '../../translation/TranslationProvider';
import { getPropertyValue } from '../common/viewUtils';
import { getTimerColour } from '../utils/presentation.utils';
import { useStudioOptions } from './studio.options';
import { getFormattedEventData, getFormattedScheduleTimes } from './studioTimers.utils';
import './StudioTimers.scss';
@@ -17,6 +19,7 @@ interface StudioTimersProps {
export default function StudioTimers({ viewSettings }: StudioTimersProps) {
const { getLocalizedString } = useTranslation();
const { mainSource } = useStudioOptions();
const { eventNow, eventNext, message, time, offset, rundown, expectedRundownEnd } = useStudioTimersSocket();
const schedule = getFormattedScheduleTimes({
@@ -24,8 +27,8 @@ export default function StudioTimers({ viewSettings }: StudioTimersProps) {
actualStart: rundown.actualStart,
expectedEnd: expectedRundownEnd,
});
const event = getFormattedEventData(eventNow, time);
const eventNextTitle = eventNext?.title || '-';
const event = getFormattedEventData(eventNow, time, mainSource);
const eventNextTitle = getPropertyValue(eventNext, mainSource ?? 'title') || '-';
const formattedTimerMessage = (message.timer.visible && message.timer.text) || '-';
const formattedSecondaryMessage = message.timer.secondarySource === 'secondary' ? message.secondary || '-' : '-';
+38 -16
View File
@@ -1,30 +1,51 @@
import { use, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { CustomFields, OntimeEvent } from 'ontime-types';
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean } from '../common/viewUtils';
export const getStudioOptions = (timeFormat: string): ViewOption[] => [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{
title: OptionTitle.ElementVisibility,
collapsible: true,
options: [
{
id: 'hideCards',
title: 'Hide cards section',
description: 'Hides the card section with the timers',
type: 'boolean',
defaultValue: false,
},
],
},
];
export const getStudioOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, [{ value: 'title', label: 'Title' }]);
return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'main',
title: 'Main text',
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'title',
},
],
},
{
title: OptionTitle.ElementVisibility,
collapsible: true,
options: [
{
id: 'hideCards',
title: 'Hide cards section',
description: 'Hides the card section with the timers',
type: 'boolean',
defaultValue: false,
},
],
},
];
};
type StudioOptions = {
mainSource: keyof OntimeEvent | null;
hideCards: boolean;
};
@@ -37,6 +58,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
return {
mainSource: getValue('main') as keyof OntimeEvent | null,
hideCards: isStringBoolean(getValue('hideCards')),
};
}
@@ -17,9 +17,13 @@ export function getFormattedScheduleTimes(data: {
};
}
export function getFormattedEventData(eventNow: OntimeEvent | null, timer: TimerState) {
export function getFormattedEventData(
eventNow: OntimeEvent | null,
timer: TimerState,
mainSource: keyof OntimeEvent | null,
) {
return {
title: eventNow?.title || '-',
title: (eventNow?.[mainSource ?? 'title'] as string) || '-',
startedAt: formatTime(timer.startedAt, timeFormat),
expectedEnd: formatTime(timer.expectedFinish, timeFormat),
timer: millisToString(timer.current),
@@ -1,5 +1,6 @@
import { ProjectData, Settings, ViewSettings } from 'ontime-types';
import { CustomFields, ProjectData, Settings, ViewSettings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData';
import useSettings from '../../common/hooks-query/useSettings';
import useViewSettings from '../../common/hooks-query/useViewSettings';
@@ -7,6 +8,7 @@ import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { aggregateQueryStatus, ViewData } from '../utils/viewLoader.utils';
export interface StudioData {
customFields: CustomFields;
projectData: ProjectData;
isMirrored: boolean;
settings: Settings;
@@ -21,14 +23,16 @@ export function useStudioData(): ViewData<StudioData> {
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
return {
data: {
customFields,
projectData,
isMirrored,
settings,
viewSettings,
},
status: aggregateQueryStatus([projectDataStatus, viewSettingsStatus, settingsStatus]),
status: aggregateQueryStatus([projectDataStatus, viewSettingsStatus, settingsStatus, customFieldsStatus]),
};
}
+5 -2
View File
@@ -6,6 +6,7 @@ import { dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
import useHorizontalFollowComponent from '../../common/hooks/useHorizontalFollowComponent';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils';
import { getPropertyValue } from '../common/viewUtils';
import TimelineMarkers from './timeline-markers/TimelineMarkers';
import { useTimelineOptions } from './timeline.options';
@@ -24,7 +25,7 @@ interface TimelineProps {
export default memo(Timeline);
function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: TimelineProps) {
const { width: screenWidth } = useViewportSize();
const { hidePast, fixedSize } = useTimelineOptions();
const { mainSource, hidePast, fixedSize } = useTimelineOptions();
const selectedRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
@@ -86,6 +87,8 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
const position = positions[index];
if (!position) return null;
const displayTitle = getPropertyValue(event, mainSource ?? 'title') || event.title;
return (
<TimelineEntry
key={event.id}
@@ -100,7 +103,7 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
totalGap={event.totalGap}
isLinkedToLoaded={event.isLinkedToLoaded}
dayOffset={event.dayOffset}
title={event.title}
title={displayTitle}
cue={event.cue}
width={position.width}
/>
@@ -12,7 +12,7 @@ import Loader from '../common/loader/Loader';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import Timeline from './Timeline';
import { getTimelineOptions } from './timeline.options';
import { getTimelineOptions, useTimelineOptions } from './timeline.options';
import { getUpcomingEvents, useScopedRundown } from './timeline.utils';
import TimelineSections from './TimelineSections';
import { TimelineData, useTimelineData } from './useTimelineData';
@@ -35,8 +35,9 @@ export default function TimelinePageLoader() {
return <TimelinePage {...data} />;
}
function TimelinePage({ events, projectData, settings }: TimelineData) {
function TimelinePage({ events, customFields, projectData, settings }: TimelineData) {
const selectedEventId = useSelectedEventId();
const { mainSource } = useTimelineOptions();
// holds copy of the rundown with only relevant events
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(events, selectedEventId);
@@ -47,7 +48,7 @@ function TimelinePage({ events, projectData, settings }: TimelineData) {
// populate options
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const progressOptions = useMemo(() => getTimelineOptions(defaultFormat), [defaultFormat]);
const progressOptions = useMemo(() => getTimelineOptions(defaultFormat, customFields), [defaultFormat, customFields]);
return (
<div className='timeline' data-testid='timeline-view'>
@@ -58,7 +59,7 @@ function TimelinePage({ events, projectData, settings }: TimelineData) {
<TimelineClock />
</div>
<TimelineSections now={now} next={next} followedBy={followedBy} />
<TimelineSections now={now} next={next} followedBy={followedBy} mainSource={mainSource} />
<Timeline
firstStart={firstStart}
@@ -5,6 +5,7 @@ import { useExpectedStartData } from '../../common/hooks/useSocket';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { formatDuration, getExpectedTimesFromExtendedEvent } from '../../common/utils/time';
import { useTranslation } from '../../translation/TranslationProvider';
import { getPropertyValue } from '../common/viewUtils';
import TimelineSection from './timeline-section/TimelineSection';
@@ -12,17 +13,18 @@ interface TimelineSectionsProps {
now: ExtendedEntry<OntimeEvent> | null;
next: ExtendedEntry<OntimeEvent> | null;
followedBy: ExtendedEntry<OntimeEvent> | null;
mainSource: keyof OntimeEvent | null;
}
export default function TimelineSections({ now, next, followedBy }: TimelineSectionsProps) {
export default function TimelineSections({ now, next, followedBy, mainSource }: TimelineSectionsProps) {
const { getLocalizedString } = useTranslation();
const state = useExpectedStartData();
// gather card data
const titleNow = now?.title ?? '-';
const titleNow = getPropertyValue(now, mainSource ?? 'title') ?? '-';
const dueText = getLocalizedString('timeline.due').toUpperCase();
const nextText = next !== null ? next.title : '-';
const followedByText = followedBy !== null ? followedBy.title : '-';
const nextText = getPropertyValue(next, mainSource ?? 'title') ?? '-';
const followedByText = getPropertyValue(followedBy, mainSource ?? 'title') ?? '-';
let nextStatus: string | undefined;
let followedByStatus: string | undefined;
@@ -1,15 +1,33 @@
import { use, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { CustomFields, OntimeEvent } from 'ontime-types';
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean } from '../common/viewUtils';
export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
export const getTimelineOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, [{ value: 'title', label: 'Title' }]);
return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'main',
title: 'Main text',
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'title',
},
],
},
{
title: OptionTitle.ElementVisibility,
collapsible: true,
@@ -34,6 +52,7 @@ export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
};
type TimelineOptions = {
mainSource: keyof OntimeEvent | null;
hidePast: boolean;
fixedSize: boolean;
};
@@ -47,6 +66,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
return {
mainSource: getValue('main') as keyof OntimeEvent | null,
hidePast: isStringBoolean(getValue('hidePast')),
fixedSize: isStringBoolean(getValue('fixedSize')),
};
@@ -1,5 +1,6 @@
import { OntimeEntry, ProjectData, Settings } from 'ontime-types';
import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
@@ -8,6 +9,7 @@ import { aggregateQueryStatus, ViewData } from '../utils/viewLoader.utils';
export interface TimelineData {
events: ExtendedEntry<OntimeEntry>[];
customFields: CustomFields;
projectData: ProjectData;
settings: Settings;
}
@@ -17,13 +19,15 @@ export function useTimelineData(): ViewData<TimelineData> {
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
return {
data: {
events: rundownData,
customFields,
projectData,
settings,
},
status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus]),
status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]),
};
}
+2 -1
View File
@@ -49,7 +49,7 @@ export default function TimerLoader() {
return <Timer {...data} />;
}
function Timer({ customFields, projectData, isMirrored, settings, viewSettings }: TimerData) {
function Timer({ customFields, projectData, isMirrored, settings, viewSettings, entries }: TimerData) {
const { eventNext, eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
const {
hideClock,
@@ -97,6 +97,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings }
secondarySource,
time.playback,
time.phase,
entries,
);
// gather timer data
+3 -3
View File
@@ -27,12 +27,12 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
const mainOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'title', label: 'Title' },
{ value: 'note', label: 'Note' },
]);
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'title', label: 'Title' },
{ value: 'note', label: 'Note' },
{ value: 'parent', label: 'Group Title' },
]);
return [
@@ -185,8 +185,8 @@ type TimerOptions = {
hideLogo: boolean;
hideTimerSeconds: boolean;
removeLeadingZeros: boolean;
mainSource: keyof OntimeEvent | null;
secondarySource: keyof OntimeEvent | null;
mainSource: keyof OntimeEvent | null | 'none';
secondarySource: keyof OntimeEvent | null | 'none';
timerType?: TimerType;
freezeOvertime: boolean;
freezeMessage: string;
+18 -8
View File
@@ -1,4 +1,13 @@
import { MaybeNumber, MessageState, OntimeEvent, Playback, TimerMessage, TimerPhase, TimerType } from 'ontime-types';
import {
MaybeNumber,
MessageState,
OntimeEvent,
Playback,
RundownEntries,
TimerMessage,
TimerPhase,
TimerType,
} from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils';
import { getFormattedTimer, getPropertyValue } from '../common/viewUtils';
@@ -141,10 +150,11 @@ export function getSecondaryDisplay(
export function getCardData(
eventNow: OntimeEvent | null,
eventNext: OntimeEvent | null,
mainSource: keyof OntimeEvent | null,
secondarySource: keyof OntimeEvent | null,
mainSource: keyof OntimeEvent | null | 'none',
secondarySource: keyof OntimeEvent | null | 'none',
playback: Playback,
phase: TimerPhase,
entries: RundownEntries,
) {
if (playback === Playback.Stop) {
return {
@@ -162,19 +172,19 @@ export function getCardData(
// if we are loaded, we show the upcoming event as next
const nowMain = hasActiveTimer ? getPropertyValue(eventNow, mainSource ?? 'title') : undefined;
const nowSecondary = hasActiveTimer ? getPropertyValue(eventNow, secondarySource) : undefined;
const nowSecondary = hasActiveTimer ? getPropertyValue(eventNow, secondarySource, entries) : undefined;
const nextMain = hasActiveTimer
? getPropertyValue(eventNext, mainSource ?? 'title')
: getPropertyValue(eventNow, mainSource ?? 'title');
const nextSecondary = hasActiveTimer
? getPropertyValue(eventNext, secondarySource)
: getPropertyValue(eventNow, secondarySource);
? getPropertyValue(eventNext, secondarySource, entries)
: getPropertyValue(eventNow, secondarySource, entries);
return {
showNow: Boolean(nowMain) || Boolean(nowSecondary),
showNow: mainSource !== 'none' && (Boolean(nowMain) || Boolean(nowSecondary)),
nowMain,
nowSecondary,
showNext: Boolean(nextMain) || Boolean(nextSecondary),
showNext: mainSource !== 'none' && (Boolean(nextMain) || Boolean(nextSecondary)),
nextMain,
nextSecondary,
};
+13 -2
View File
@@ -1,7 +1,8 @@
import { CustomFields, ProjectData, Settings, ViewSettings } from 'ontime-types';
import { CustomFields, ProjectData, RundownEntries, Settings, ViewSettings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import useViewSettings from '../../common/hooks-query/useViewSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
@@ -13,6 +14,7 @@ export interface TimerData {
isMirrored: boolean;
settings: Settings;
viewSettings: ViewSettings;
entries: RundownEntries;
}
export function useTimerData(): ViewData<TimerData> {
@@ -24,6 +26,8 @@ export function useTimerData(): ViewData<TimerData> {
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
const { data: rundown, status: rundownStatus } = useRundown();
const { entries } = rundown;
return {
data: {
@@ -32,7 +36,14 @@ export function useTimerData(): ViewData<TimerData> {
isMirrored,
settings,
viewSettings,
entries,
},
status: aggregateQueryStatus([projectDataStatus, viewSettingsStatus, settingsStatus, customFieldsStatus]),
status: aggregateQueryStatus([
projectDataStatus,
viewSettingsStatus,
settingsStatus,
customFieldsStatus,
rundownStatus,
]),
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.4.0-beta.0",
"version": "4.4.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/resolver",
"version": "4.4.0-beta.0",
"version": "4.4.2",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.4.0-beta.0",
"version": "4.4.2",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -9,6 +9,7 @@ import * as oscClient from '../clients/osc.client.js';
import * as httpClient from '../clients/http.client.js';
import { makeOSCAction, makeHTTPAction } from './testUtils.js';
import { RuntimeState } from '../../../stores/runtimeState.js';
beforeAll(() => {
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
@@ -100,117 +101,440 @@ describe('testConditions()', () => {
expect(result).toBe(true);
});
describe('equals operator', () => {
it('should compare two equal values', () => {
const mockStore = makeRuntimeStateData({ clock: 10 });
const result = testConditions([{ field: 'clock', operator: 'equals', value: '10' }], 'all', mockStore);
expect(result).toBe(true);
});
it('string comparisons should be case insensitive', () => {
const mockStore = makeRuntimeStateData({ eventNow: { title: 'Title' } as PlayableEvent });
const result = testConditions(
[{ field: 'eventNow.title', operator: 'equals', value: 'title' }],
'all',
mockStore,
);
expect(result).toBe(true);
});
it('should check if a value does not exist', () => {
const mockStore = makeRuntimeStateData({ eventNow: null });
const result = testConditions([{ field: 'eventNow.title', operator: 'equals', value: '' }], 'all', mockStore);
expect(result).toBe(true);
});
it('should handle trueness boolean comparisons', () => {
const mockStore = makeRuntimeStateData({ eventNow: { countToEnd: true } as PlayableEvent });
describe('equals', () => {
test.each([
{
description: 'number and string number',
value: '10',
state: 10,
},
{
description: 'string and string',
value: 'Title',
state: 'Title',
},
{
description: 'null and empty string',
value: '',
state: null,
},
{
description: 'undefined and empty string',
value: '',
state: undefined,
},
{
description: 'boolean true and string true',
value: 'true',
state: true,
},
{
description: 'boolean false equals string false',
value: 'false',
state: false,
},
{
description: 'not case sensitive for strings',
value: 'title',
state: 'Title',
},
{
description: 'not case sensitive for booleans',
value: 'TRUE',
state: true,
},
])('$description', ({ value, state }) => {
expect(
testConditions([{ field: 'eventNow.countToEnd', operator: 'equals', value: 'true' }], 'all', mockStore),
testConditions([{ field: 'test.path', operator: 'equals', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(true);
expect(
testConditions([{ field: 'eventNow.countToEnd', operator: 'equals', value: 'false' }], 'all', mockStore),
).toBe(false);
});
it('should handle falseness boolean comparisons', () => {
const mockStore = makeRuntimeStateData({ eventNow: { countToEnd: false } as PlayableEvent });
expect(
testConditions([{ field: 'eventNow.countToEnd', operator: 'equals', value: 'false' }], 'all', mockStore),
).toBe(true);
expect(
testConditions([{ field: 'eventNow.countToEnd', operator: 'equals', value: 'true' }], 'all', mockStore),
testConditions([{ field: 'test.path', operator: 'not_equals', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
});
});
describe('not_equals operator', () => {
it('should check if two values are different', () => {
const mockStore = makeRuntimeStateData({ clock: 10 });
const result = testConditions([{ field: 'clock', operator: 'not_equals', value: '11' }], 'all', mockStore);
expect(result).toBe(true);
describe('not_equals', () => {
test.each([
{
description: 'boolean true and string false',
value: 'false',
state: true,
},
{
description: 'boolean false and string true',
value: 'true',
state: false,
},
{
description: 'number and non-numeric string',
value: 'lighting',
state: 10,
},
{
description: 'number and different number',
value: '100',
state: 10,
},
{
description: '10 and 11',
value: '11',
state: 10,
},
{
description: '10 and 5',
value: '5',
state: 10,
},
{
description: '10 and 1',
value: '1',
state: 10,
},
{
description: 'string and substring',
value: 'lighting',
state: 'testing-lighting-10',
},
{
description: 'string and different string',
value: 'sound',
state: 'testing-lighting-10',
},
{
description: 'null and non-empty string',
value: 'not-empty',
state: null,
},
{
description: 'undefined and non-empty string',
value: 'not-empty',
state: undefined,
},
{
description: 'undefined and " " string',
value: ' ',
state: undefined,
},
{
description: 'boolean false and empty string',
value: '',
state: false,
},
{
description: '0 and empty string',
value: '',
state: 0,
},
])('$description', ({ value, state }) => {
expect(
testConditions([{ field: 'test.path', operator: 'not_equals', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(true);
expect(
testConditions([{ field: 'test.path', operator: 'equals', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
});
});
describe('greater_than operator', () => {
it('should check if the given value is smaller', () => {
const mockStore = makeRuntimeStateData({ clock: 10 });
const result = testConditions([{ field: 'clock', operator: 'greater_than', value: '9' }], 'all', mockStore);
expect(result).toBe(true);
});
it('should handle values which are not numbers', () => {
const mockStore = makeRuntimeStateData({ clock: 10 });
const result = testConditions([{ field: 'clock', operator: 'greater_than', value: 'testing' }], 'all', mockStore);
expect(result).toBe(false);
describe('greater_than', () => {
test.each([
{
description: '10 > 5',
value: '5',
state: 10,
},
{
description: '10 > 1',
value: '1',
state: 10,
},
{
description: '100 > 5',
value: '5',
state: 100,
},
{
description: '11 > 10',
value: '10',
state: 11,
},
{
description: '0 > -1',
value: '-1',
state: 0,
},
{
description: '-100 > -150',
value: '-150',
state: -100,
},
])('$description', ({ value, state }) => {
expect(
testConditions([{ field: 'test.path', operator: 'greater_than', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(true);
expect(
testConditions([{ field: 'test.path', operator: 'less_than', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
});
});
describe('less_than operator', () => {
it('should check if the given value is larger', () => {
const mockStore = makeRuntimeStateData({ clock: 10 });
const result = testConditions([{ field: 'clock', operator: 'less_than', value: '11' }], 'all', mockStore);
expect(result).toBe(true);
});
it('should handle values which are not numbers', () => {
const mockStore = makeRuntimeStateData({ clock: 10 });
const result = testConditions([{ field: 'clock', operator: 'less_than', value: 'testing' }], 'all', mockStore);
expect(result).toBe(false);
describe('less_than', () => {
test.each([
{
description: '10 > 5',
value: '10',
state: 5,
},
{
description: '10 > 1',
value: '10',
state: 1,
},
{
description: '100 > 5',
value: '100',
state: 5,
},
{
description: '11 > 10',
value: '11',
state: 10,
},
{
description: '0 > -1',
value: '0',
state: -1,
},
{
description: '-100 > -150',
value: '-100',
state: -150,
},
])('$description', ({ value, state }) => {
expect(
testConditions([{ field: 'test.path', operator: 'less_than', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(true);
expect(
testConditions([{ field: 'test.path', operator: 'greater_than', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
});
});
describe('contains operator', () => {
it('should check if value contains given string', () => {
const result = testConditions(
[{ field: 'eventNow.title', operator: 'contains', value: 'lighting' }],
'all',
makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }),
);
expect(result).toBe(true);
const result2 = testConditions(
[{ field: 'eventNow.title', operator: 'contains', value: 'sound' }],
'all',
makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }),
);
expect(result2).toBe(false);
describe('invalid and edge case for greater/less_than', () => {
test.each([
{
description: 'number and non-numeric string',
value: 'lighting',
state: 10,
},
{
description: '0 and empty string',
value: '',
state: 0,
},
{
description: 'equal values',
value: '10',
state: 10,
},
{
description: 'boolean false and empty string',
value: '',
state: false,
},
{
description: 'undefined and non-empty string',
value: 'not-empty',
state: undefined,
},
{
description: 'null and non-empty string',
value: 'not-empty',
state: null,
},
{
description: 'string and different string',
value: 'sound',
state: 'testing-lighting-10',
},
{
description: 'string and substring',
value: 'lighting',
state: 'testing-lighting-10',
},
{
description: 'boolean true and uppercase TRUE',
value: 'TRUE',
state: true,
},
{
description: 'string and lowercase string',
value: 'title',
state: 'Title',
},
{
description: 'boolean false and string true',
value: 'true',
state: false,
},
{
description: 'boolean false and string false',
value: 'false',
state: false,
},
{
description: 'boolean true and string false',
value: 'false',
state: true,
},
{
description: 'string and string',
value: 'Title',
state: 'Title',
},
{
description: 'null and empty string',
value: '',
state: null,
},
{
description: 'undefined and empty string',
value: '',
state: undefined,
},
{
description: 'boolean true and string true',
value: 'true',
state: true,
},
])('$description', ({ value, state }) => {
expect(
testConditions([{ field: 'test.path', operator: 'less_than', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
expect(
testConditions([{ field: 'test.path', operator: 'greater_than', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
});
});
describe('not_contains operator', () => {
it('should check if value does not contain given string', () => {
const result = testConditions(
[{ field: 'eventNow.title', operator: 'not_contains', value: 'lighting' }],
'all',
makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }),
);
expect(result).toBe(false);
describe('contains', () => {
test.each([
{
description: 'substring in string',
value: 'lighting',
state: 'testing-lighting-10',
},
{
description: 'number in string',
value: '10',
state: 'testing-lighting-10',
},
{
description: 'digit in string',
value: '1',
state: 'testing-lighting-10',
},
{
description: 'empty in empty',
value: '',
state: '',
},
{
description: 'empty in not-empty',
value: '',
state: '12345', // TODO: is this intended
},
{
description: 'string equals string',
value: 'Title',
state: 'Title',
},
{
description: 'case sensitive',
value: 'title',
state: 'Title',
},
{
description: 'number does contain string',
value: '10',
state: 2105,
},
])('$description', ({ value, state }) => {
expect(
testConditions([{ field: 'test.path', operator: 'contains', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(true);
expect(
testConditions([{ field: 'test.path', operator: 'not_contains', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
});
});
const result2 = testConditions(
[{ field: 'eventNow.title', operator: 'not_contains', value: 'sound' }],
'all',
makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }),
);
expect(result2).toBe(true);
describe('not_contains', () => {
test.each([
{
description: 'number does not contain substring',
value: '456',
state: 12345,
},
{
description: 'substring not in string',
value: 'sound',
state: 'testing-lighting-10',
},
{
description: 'string and undefined',
value: 'sound',
state: undefined,
},
{
description: 'string and null',
value: 'sound',
state: null,
},
{
description: 'boolean true and string true',
value: 'true',
state: true,
},
{
description: 'boolean false and string false',
value: 'false',
state: false,
},
])('$description', ({ value, state }) => {
expect(
testConditions([{ field: 'test.path', operator: 'not_contains', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(true);
expect(
testConditions([{ field: 'test.path', operator: 'contains', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
});
});
@@ -17,7 +17,7 @@ import { isOntimeCloud } from '../../setup/environment.js';
import { emitOSC } from './clients/osc.client.js';
import { emitHTTP } from './clients/http.client.js';
import { getAutomationsEnabled, getAutomations, getAutomationTriggers } from './automation.dao.js';
import { isBooleanEquals, isGreaterThan, isLessThan } from './automation.utils.js';
import { isContained, isEquivalent, isGreaterThan, isLessThan } from './automation.utils.js';
import { toOntimeAction } from './clients/ontime.client.js';
/**
@@ -92,29 +92,17 @@ export function testConditions(
// we use loose equality to be able to check for converted values (eg '10' == 10)
switch (operator) {
case 'equals':
// handle the case where we are comparing boolean strings
if (typeof fieldValue === 'boolean') {
return isBooleanEquals(fieldValue, lowerCasedValue);
}
// make string comparisons case insensitive
if (typeof fieldValue === 'string') {
return fieldValue.toLowerCase() === lowerCasedValue;
}
// overload the edge case where we use empty string to check if a value does not exist
if (value === '' && fieldValue === undefined) {
return true;
}
return fieldValue == value;
return isEquivalent(fieldValue, lowerCasedValue);
case 'not_equals':
return fieldValue != value;
return !isEquivalent(fieldValue, lowerCasedValue);
case 'greater_than':
return isGreaterThan(fieldValue, value);
case 'less_than':
return isLessThan(fieldValue, value);
case 'contains':
return typeof fieldValue === 'string' && fieldValue.includes(value);
return isContained(fieldValue, lowerCasedValue);
case 'not_contains':
return typeof fieldValue === 'string' && !fieldValue.includes(value);
return !isContained(fieldValue, lowerCasedValue);
default: {
operator satisfies never;
return false;
@@ -148,6 +148,38 @@ const quickAliases: AliasesDefinition = {
startedAt: { key: 'timer.startedAt', cb: (value: string) => formatDisplayFromString(value) },
};
export function isEquivalent(a: unknown, b: string): boolean {
// handle the case where we are comparing boolean strings
if (typeof a === 'boolean') {
return isBooleanEquals(a, b);
}
// make string comparisons case insensitive
if (typeof a === 'string') {
return a.toLowerCase() === b;
}
// overload the edge case where we use empty string to check if a value does not exist
// this also avoids the case where 0 == ''
if (b === '') {
return a === null || a === undefined;
}
return a == b;
}
export function isContained(a: unknown, b: string): boolean {
// handle the case where we are comparing boolean strings
if (typeof a === 'boolean') {
return false;
}
// make string comparisons case insensitive
if (typeof a === 'string') {
return a.toLowerCase().includes(b);
}
if (typeof a === 'number') {
return a.toString().toLowerCase().includes(b);
}
return false;
}
/**
* Utility encapsulates logic for comparing two strings which may encode numbers
* @example isGreaterThan('10', '5') // true
@@ -17,10 +17,15 @@ import {
URLPreset,
ViewSettings,
} from 'ontime-types';
import { customFieldLabelToKey, checkRegex, isKnownTimerType, validateEndAction } from 'ontime-utils';
import {
customFieldLabelToKey,
checkRegex,
isKnownTimerType,
validateEndAction,
eventDef as eventModel,
} from 'ontime-utils';
import { is } from '../../../utils/is.js';
import { event as eventModel } from '../../../models/eventsDefinition.js';
import { ONTIME_VERSION } from '../../../ONTIME_VERSION.js';
import { getPartialProject } from '../../../models/dataModel.js';
@@ -468,7 +468,7 @@ describe('v3 to v4', () => {
const expectedAutomation: AutomationSettings = {
enabledAutomations: true,
enabledOscIn: true,
enabledOscIn: false,
oscPortIn: 8888,
triggers: [
{
@@ -18,12 +18,12 @@ import {
isKnownTimerType,
validateTimerType,
validateEndAction,
makeString,
} from 'ontime-utils';
import { Prettify } from 'ts-essentials';
import { is } from '../../utils/is.js';
import { makeString } from '../../utils/parserUtils.js';
import { parseExcelDate } from '../../utils/time.js';
import { generateImportHandlers, getCustomFieldData, parseBooleanString, SheetMetadata } from './excel.utils.js';
@@ -33,10 +33,11 @@ export function getCustomFieldData(
// we lower case the excel key to make it easier to match
const columnNameInExcel = importMap.custom[ontimeLabel].toLowerCase();
const maybeExistingColour = existingCustomFields[keyInCustomFields]?.colour ?? '';
const maybeExistingType = existingCustomFields[keyInCustomFields]?.type ?? 'text';
// 1. add the custom field to the merged custom fields
mergedCustomFields[keyInCustomFields] = {
type: 'text', // we currently only support text custom fields
type: maybeExistingType,
colour: maybeExistingColour,
label: ontimeLabel,
};
@@ -1,5 +1,5 @@
import { TimeStrategy, EndAction, TimerType, OntimeEvent, OntimeGroup } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { TimeStrategy, EndAction, TimerType, OntimeEvent } from 'ontime-types';
import { createEvent, MILLIS_PER_HOUR } from 'ontime-utils';
import { assertType } from 'vitest';
@@ -7,11 +7,9 @@ import { demoDb } from '../../../models/demoProject.js';
import {
calculateDayOffset,
createEvent,
deleteById,
doesInvalidateMetadata,
duplicateRundown,
getInsertAfterId,
hasChanges,
makeDeepClone,
} from '../rundown.utils.js';
@@ -226,45 +224,6 @@ describe('calculateDayOffset()', () => {
});
});
describe('getInsertAfterId()', () => {
const rundown = makeRundown({
entries: {
'1': makeOntimeEvent({ id: '1', parent: null }),
'2': makeOntimeEvent({ id: '2', parent: null }),
group: makeOntimeGroup({ id: 'group', entries: ['31', '32'] }),
'31': makeOntimeEvent({ id: '31', parent: 'group' }),
'32': makeOntimeEvent({ id: '32', parent: 'group' }),
'4': makeOntimeEvent({ id: '4', parent: null }),
},
order: ['1', '2', 'group', '4'],
flatOrder: ['1', '2', 'group', '31', '32', '4'],
});
it('returns afterId if provided', () => {
expect(getInsertAfterId(rundown, null, 'b')).toBe('b');
});
it('returns null if neither afterId nor beforeId is provided', () => {
expect(getInsertAfterId(rundown, null)).toBeNull();
});
it('returns null if beforeId is not found', () => {
expect(getInsertAfterId(rundown, null, undefined, 'z')).toBeNull();
expect(getInsertAfterId(rundown, null, undefined, '1')).toBeNull();
});
it('returns the previous id of an entry in the rundown', () => {
expect(getInsertAfterId(rundown, null, undefined, '2')).toBe('1');
expect(getInsertAfterId(rundown, null, undefined, '4')).toBe('group');
expect(getInsertAfterId(rundown, null, undefined, 'group')).toBe('2');
});
it('returns the previous id of an event in a group', () => {
expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '31')).toBeNull();
expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '32')).toBe('31');
});
});
describe('duplicateRundown', () => {
it('duplicates a given rundown', () => {
const demoRundown = demoDb.rundowns['default'];
+12 -66
View File
@@ -26,7 +26,7 @@ import {
Rundown,
InsertOptions,
} from 'ontime-types';
import { customFieldLabelToKey, insertAtIndex } from 'ontime-utils';
import { addToRundown, customFieldLabelToKey, getInsertAfterId, insertAtIndex, createGroup } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { consoleError } from '../../utils/console.js';
@@ -35,10 +35,8 @@ import type { RundownMetadata } from './rundown.types.js';
import {
applyPatchToEntry,
cloneSimpleRundownEntry,
createGroup,
deleteById,
doesInvalidateMetadata,
getInsertAfterId,
getUniqueId,
makeDeepClone,
} from './rundown.utils.js';
@@ -110,11 +108,6 @@ export function createTransaction(options: TransactionOptions): Transaction {
function commit(shouldProcess: boolean = true) {
// if the rundown is mutable we persist the changes
if (options.mutableRundown) {
// schedule a database update
setImmediate(async () => {
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
});
// update fields which are agnostic of whether the rundown is processed
cachedRundown.revision = cachedRundown.revision + 1;
cachedRundown.title = rundown.title;
@@ -135,16 +128,17 @@ export function createTransaction(options: TransactionOptions): Transaction {
cachedRundown.flatOrder = metadata.flatEntryOrder;
rundownMetadata = metadata;
}
// persist after all mutations are applied
getDataProvider().setRundown(cachedRundown.id, cachedRundown);
}
// if the customFields are mutable we persist the changes
if (options.mutableCustomFields) {
// schedule a database update
setImmediate(async () => {
await getDataProvider().setCustomFields(projectCustomFields);
});
projectCustomFields = customFields;
// persist after reassignment
getDataProvider().setCustomFields(projectCustomFields);
}
return {
@@ -163,50 +157,6 @@ export function createTransaction(options: TransactionOptions): Transaction {
};
}
/**
* Add entry to rundown, handles the following cases:
* - 1a. add entry in group, after a given entry
* - 1b. add entry in group, at the beginning
* - 2a. add entry to the rundown, after a given entry
* - 2b. add entry to the rundown, at the beginning
*/
function add(rundown: Rundown, entry: OntimeEntry, afterId: EntryId | null, parent: OntimeGroup | null): OntimeEntry {
if (parent) {
// 1. inserting an entry inside a group
if ('parent' in entry) {
entry.parent = parent.id;
}
if (afterId) {
const atEventsIndex = parent.entries.indexOf(afterId) + 1;
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
parent.entries = insertAtIndex(atEventsIndex, entry.id, parent.entries);
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
} else {
parent.entries = insertAtIndex(0, entry.id, parent.entries);
const atFlatIndex = rundown.flatOrder.indexOf(parent.id) + 1;
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
}
} else {
// 2. inserting an entry at top level
if (afterId) {
const atOrderIndex = rundown.order.indexOf(afterId) + 1;
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
rundown.order = insertAtIndex(atOrderIndex, entry.id, rundown.order);
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
} else {
rundown.order = insertAtIndex(0, entry.id, rundown.order);
rundown.flatOrder = insertAtIndex(0, entry.id, rundown.flatOrder);
}
}
// either way, we insert the entry into the rundown
rundown.entries[entry.id] = entry;
return entry;
}
/**
* Applies a patch of changes to an existing entry
* @returns { entry: OntimeEntry, didInvalidate: boolean } - didInvalidate indicates whether the change warrants a recalculation of the cache
@@ -516,7 +466,7 @@ function clone(rundown: Rundown, entry: OntimeEntry, options?: InsertOptions): O
after = entry.id;
}
return add(rundown, clonedEntry, after, parent);
return addToRundown(rundown, clonedEntry, after, parent);
}
}
@@ -583,7 +533,7 @@ function ungroup(rundown: Rundown, group: OntimeGroup) {
}
export const rundownMutation = {
add,
add: addToRundown,
edit,
remove,
removeAll,
@@ -598,10 +548,8 @@ export const rundownMutation = {
/**
* Exposes a way to update a rundown which is not active
*/
export function updateBackgroundRundown(rundownId: string, rundown: Rundown) {
setImmediate(async () => {
await getDataProvider().setRundown(rundownId, rundown);
});
export async function updateBackgroundRundown(rundownId: string, rundown: Rundown) {
await getDataProvider().setRundown(rundownId, rundown);
}
/**
@@ -698,9 +646,7 @@ export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Rea
rundownMetadata = metadata;
// defer writing to the database
setImmediate(async () => {
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
});
getDataProvider().setRundown(cachedRundown.id, cachedRundown);
return { rundown, rundownMetadata, customFields, revision: rundown.revision };
}
@@ -17,13 +17,22 @@ import {
OntimeMilestone,
OntimeGroup,
} from 'ontime-types';
import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
import {
isObjectEmpty,
generateId,
getLinkedTimes,
getTimeFrom,
isNewLatest,
createDelay,
createEvent,
createGroup,
createMilestone,
} from 'ontime-utils';
import { delay as delayDef } from '../../models/eventsDefinition.js';
import { makeNewRundown } from '../../models/dataModel.js';
import type { ErrorEmitter } from '../../utils/parserUtils.js';
import { calculateDayOffset, cleanupCustomFields, createGroup, createEvent, createMilestone } from './rundown.utils.js';
import { calculateDayOffset, cleanupCustomFields } from './rundown.utils.js';
import { RundownMetadata } from './rundown.types.js';
/**
@@ -107,7 +116,7 @@ export function parseRundown(
cleanupCustomFields(newEvent.custom, parsedCustomFields);
eventIndex += 1;
} else if (isOntimeDelay(event)) {
newEvent = { ...delayDef, duration: event.duration, id };
newEvent = createDelay({ duration: event.duration, id });
} else if (isOntimeMilestone(event)) {
newEvent = createMilestone({ ...event, id });
cleanupCustomFields(newEvent.custom, parsedCustomFields);
@@ -134,7 +143,7 @@ export function parseRundown(
cleanupCustomFields(newNestedEvent.custom, parsedCustomFields);
eventIndex += 1;
} else if (isOntimeDelay(nestedEvent)) {
newNestedEvent = { ...delayDef, duration: nestedEvent.duration, id: nestedEventId };
newNestedEvent = createDelay({ duration: nestedEvent.duration, id: nestedEventId });
newNestedEvent.parent = event.id;
} else if (isOntimeMilestone(nestedEvent)) {
newNestedEvent = createMilestone({ ...nestedEvent, id: nestedEventId });

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