Compare commits

..

3 Commits

Author SHA1 Message Date
Carlos Valente b13d5792f3 fixup! feat: implement speed controls 2025-03-02 14:45:40 +01:00
Carlos Valente d0027d4016 fixup! feat: implement speed controls 2025-03-02 14:45:32 +01:00
Carlos Valente ae1639802c feat: implement speed controls 2025-03-02 14:13:53 +01:00
351 changed files with 8988 additions and 11539 deletions
+5
View File
@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
+51
View File
@@ -0,0 +1,51 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="CssUnknownProperty" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myCustomPropertiesEnabled" value="true" />
<option name="myIgnoreVendorSpecificProperties" value="false" />
<option name="myCustomPropertiesList">
<value>
<list size="3">
<item index="0" class="java.lang.String" itemvalue="scrollbar-color" />
<item index="1" class="java.lang.String" itemvalue="scrollbar-width" />
<item index="2" class="java.lang.String" itemvalue="app-region" />
</list>
</value>
</option>
</inspection_tool>
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredUrls">
<list>
<option value="http://localhost" />
<option value="http://127.0.0.1" />
<option value="http://0.0.0.0" />
<option value="http://www.w3.org/" />
<option value="http://json-schema.org/draft" />
<option value="http://java.sun.com/" />
<option value="http://xmlns.jcp.org/" />
<option value="http://javafx.com/javafx/" />
<option value="http://javafx.com/fxml" />
<option value="http://maven.apache.org/xsd/" />
<option value="http://maven.apache.org/POM/" />
<option value="http://www.springframework.org/schema/" />
<option value="http://www.springframework.org/tags" />
<option value="http://www.springframework.org/security/tags" />
<option value="http://www.thymeleaf.org" />
<option value="http://www.jboss.org/j2ee/schema/" />
<option value="http://www.jboss.com/xml/ns/" />
<option value="http://www.ibm.com/webservices/xsd" />
<option value="http://activemq.apache.org/schema/" />
<option value="http://schema.cloudfoundry.org/spring/" />
<option value="http://schemas.xmlsoap.org/" />
<option value="http://cxf.apache.org/schemas/" />
<option value="http://primefaces.org/ui" />
<option value="http://tiles.apache.org/" />
<option value="http://__IP__" />
</list>
</option>
</inspection_tool>
<inspection_tool class="Stylelint" enabled="true" level="ERROR" enabled_by_default="true" />
</profile>
</component>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.15.2", "version": "3.13.1",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+7 -11
View File
@@ -1,38 +1,35 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.15.2", "version": "3.13.1",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@chakra-ui/react": "^2.7.0", "@chakra-ui/react": "^2.7.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emotion/is-prop-valid": "^1.3.1", "@emotion/is-prop-valid": "^1.3.1",
"@emotion/react": "^11.10.6", "@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6", "@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28", "@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.17.2", "@mantine/hooks": "^7.13.3",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^8.43.0", "@sentry/react": "^8.43.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-devtools": "^5.62.7", "@tanstack/react-query-devtools": "^5.62.7",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.20.5",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.9.0", "axios": "^1.2.0",
"color": "^4.2.3", "color": "^4.2.3",
"csv-stringify": "^6.4.5", "csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0", "framer-motion": "^10.10.0",
"prismjs": "^1.29.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.53.1", "react-hook-form": "^7.53.1",
"react-icons": "5.4.0",
"react-qr-code": "^2.0.12", "react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"react-simple-code-editor": "^0.14.1",
"web-vitals": "^3.1.1", "web-vitals": "^3.1.1",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
@@ -67,7 +64,6 @@
"@sentry/vite-plugin": "^2.16.1", "@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4", "@tanstack/eslint-plugin-query": "^5.8.4",
"@types/color": "^3.0.3", "@types/color": "^3.0.3",
"@types/prismjs": "^1.26.5",
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:",
+1 -8
View File
@@ -158,14 +158,7 @@ export default function AppRouter() {
{/*/!* Protected Routes *!/*/} {/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} /> <Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<PCuesheet />} /> <Route path='/cuesheet' element={<PCuesheet />} />
<Route <Route path='/op' element={<POperator />} />
path='/op'
element={
<ViewLoader>
<POperator />
</ViewLoader>
}
/>
{/*/!* Protected Routes - Elements *!/*/} {/*/!* Protected Routes - Elements *!/*/}
<Route <Route
-30
View File
@@ -1,30 +0,0 @@
import axios from 'axios';
import { apiEntryUrl } from './constants';
const assetsPath = `${apiEntryUrl}/assets`;
/**
* HTTP request to get css contents
*/
export async function getCSSContents(): Promise<string> {
const res = await axios.get(`${assetsPath}/css`);
return res.data;
}
/**
* HTTP request to post css contents
*/
export async function postCSSContents(css: string): Promise<void> {
await axios.post(`${assetsPath}/css`, {
css,
});
}
/**
* HTTP request to restore default css
*/
export async function restoreCSSContents(): Promise<string> {
const res = await axios.post(`${assetsPath}/css/restore`);
return res.data;
}
+6 -2
View File
@@ -10,6 +10,7 @@ export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList']; export const PROJECT_LIST = ['projectList'];
export const RUNDOWN = ['rundown']; export const RUNDOWN = ['rundown'];
export const RUNTIME = ['runtimeStore']; export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState'];
export const URL_PRESETS = ['urlpresets']; export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings']; export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList']; export const CLIENT_LIST = ['clientList'];
@@ -18,8 +19,11 @@ export const REPORT = ['report'];
// API URLs // API URLs
export const apiEntryUrl = `${serverURL}/data`; export const apiEntryUrl = `${serverURL}/data`;
const userAssetsPath = 'user'; export const projectDataURL = `${serverURL}/project`;
const cssOverridePath = 'styles/override.css'; export const rundownURL = `${serverURL}/events`;
export const ontimeURL = `${serverURL}/ontime`;
export const userAssetsPath = 'user';
export const cssOverridePath = 'styles/override.css';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`; export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`; export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
+3 -5
View File
@@ -2,7 +2,7 @@ import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeTable } from '../../views/cuesheet/cuesheet.utils'; import { makeTable } from '../../views/cuesheet/cuesheet.utils';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv'; import { makeCSVFromArrayOfArrays } from '../utils/csv';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils'; import { createBlob, downloadBlob } from './utils';
@@ -40,11 +40,9 @@ export async function downloadProject(fileName: string) {
export async function downloadCSV(fileName: string = 'rundown') { export async function downloadCSV(fileName: string = 'rundown') {
try { try {
const { data, name } = await fileDownload(fileName); const { data, name } = await fileDownload(fileName);
const { project, rundowns, customFields } = data; const { project, rundown, customFields } = data;
const flatRundowns = aggregateRundowns(rundowns);
const sheetData = makeTable(project, flatRundowns, customFields);
const sheetData = makeTable(project, rundown, customFields);
const fileContent = makeCSVFromArrayOfArrays(sheetData); const fileContent = makeCSVFromArrayOfArrays(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;'); const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
+6 -5
View File
@@ -1,11 +1,16 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { CustomFields, Rundown } from 'ontime-types'; import { CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
const excelPath = `${apiEntryUrl}/excel`; const excelPath = `${apiEntryUrl}/excel`;
type PreviewSpreadsheetResponse = {
rundown: OntimeRundown;
customFields: CustomFields;
};
/** /**
* upload Excel file to server * upload Excel file to server
* @return string - file ID op the uploaded file * @return string - file ID op the uploaded file
@@ -29,10 +34,6 @@ export async function getWorksheetNames(): Promise<string[]> {
return response.data; return response.data;
} }
type PreviewSpreadsheetResponse = {
rundown: Rundown;
customFields: CustomFields;
};
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> { export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, { const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
options, options,
+1 -1
View File
@@ -11,7 +11,7 @@ export type HasUpdate = {
* HTTP request to get the latest version and url from github * HTTP request to get the latest version and url from github
*/ */
export async function getLatestVersion(): Promise<HasUpdate> { export async function getLatestVersion(): Promise<HasUpdate> {
const res = await axios.get(apiRepoLatest); const res = await axios.get(`${apiRepoLatest}`);
return { return {
url: res.data.html_url as string, url: res.data.html_url as string,
version: res.data.tag_name as string, version: res.data.tag_name as string,
+1 -1
View File
@@ -11,7 +11,7 @@ export const reportUrl = `${apiEntryUrl}/report`;
* HTTP request to fetch all reports * HTTP request to fetch all reports
*/ */
export async function fetchReport(): Promise<OntimeReport> { export async function fetchReport(): Promise<OntimeReport> {
const res = await axios.get(reportUrl); const res = await axios.get(`${reportUrl}/`);
return res.data; return res.data;
} }
+19 -56
View File
@@ -1,45 +1,29 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types';
EntryId,
MessageResponse,
OntimeEntry,
OntimeEvent,
ProjectRundownsList,
Rundown,
TransientEventPayload,
} from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
const rundownPath = `${apiEntryUrl}/rundown`; const rundownPath = `${apiEntryUrl}/rundown`;
/**
* HTTP request to fetch a list of existing rundowns
*/
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
const res = await axios.get(rundownPath);
return res.data;
}
/** /**
* HTTP request to fetch all events * HTTP request to fetch all events
*/ */
export async function fetchCurrentRundown(): Promise<Rundown> { export async function fetchNormalisedRundown(): Promise<RundownCached> {
const res = await axios.get(`${rundownPath}/current`); const res = await axios.get(`${rundownPath}/normalised`);
return res.data; return res.data;
} }
/** /**
* HTTP request to post new entry * HTTP request to post new event
*/ */
export async function postAddEntry(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> { export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.post(rundownPath, data); return axios.post(rundownPath, data);
} }
/** /**
* HTTP request to edit an entry * HTTP request to put new event
*/ */
export async function putEditEntry(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> { export async function requestPutEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.put(rundownPath, data); return axios.put(rundownPath, data);
} }
@@ -49,22 +33,22 @@ type BatchEditEntry = {
}; };
/** /**
* HTTP request to edit multiple events * HTTP request to put multiple events
*/ */
export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<MessageResponse>> { export async function requestBatchPutEvents(data: BatchEditEntry): Promise<AxiosResponse<MessageResponse>> {
return axios.put(`${rundownPath}/batch`, data); return axios.put(`${rundownPath}/batch`, data);
} }
export type ReorderEntry = { export type ReorderEntry = {
entryId: EntryId; eventId: string;
destinationId: EntryId; from: number;
order: 'before' | 'after' | 'insert'; to: number;
}; };
/** /**
* HTTP request to reorder an entry * HTTP request to reorder events
*/ */
export async function patchReorderEntry(data: ReorderEntry): Promise<AxiosResponse<Rundown>> { export async function requestReorderEvent(data: ReorderEntry): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.patch(`${rundownPath}/reorder`, data); return axios.patch(`${rundownPath}/reorder`, data);
} }
@@ -83,36 +67,15 @@ export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<M
/** /**
* HTTP request to request application of delay * HTTP request to request application of delay
*/ */
export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<MessageResponse>> { export async function requestApplyDelay(eventId: string): Promise<AxiosResponse<MessageResponse>> {
return axios.patch(`${rundownPath}/applydelay/${delayId}`); return axios.patch(`${rundownPath}/applydelay/${eventId}`);
} }
/** /**
* HTTP request for cloning an entry * HTTP request to delete given event
*/ */
export async function postCloneEntry(entryId: EntryId): Promise<AxiosResponse<Rundown>> { export async function requestDelete(eventIds: string[]): Promise<AxiosResponse<MessageResponse>> {
return axios.post(`${rundownPath}/clone/${entryId}`); return axios.delete(rundownPath, { data: { ids: eventIds } });
}
/**
* HTTP request for dissolving of a block
*/
export async function requestUngroup(blockId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/ungroup/${blockId}`);
}
/**
* HTTP request for grouping a list of entries into a block
*/
export async function requestGroupEntries(entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/group`, { ids: entryIds });
}
/**
* HTTP request to delete entries
*/
export async function deleteEntries(entryIds: EntryId[]): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(rundownPath, { data: { ids: entryIds } });
} }
/** /**
+2 -2
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types'; import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -54,7 +54,7 @@ export const previewRundown = async (
sheetId: string, sheetId: string,
options: ImportMap, options: ImportMap,
): Promise<{ ): Promise<{
rundown: Rundown; rundown: OntimeRundown;
customFields: CustomFields; customFields: CustomFields;
}> => { }> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options }); const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
+2 -2
View File
@@ -7,7 +7,7 @@ import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time'; import { nowInMillis } from '../utils/time';
/** /**
* Utility unwrap a potential axios error * Utility unrwap a potential axios error
* @param error * @param error
* @returns * @returns
*/ */
@@ -34,7 +34,7 @@ export function maybeAxiosError(error: unknown) {
} }
/** /**
* Utility unwraps a potential axios error and sends to logger * Utility unrwaps a potential axios error and sends to logger
* @param prepend * @param prepend
* @param error * @param error
*/ */
@@ -1,5 +1,4 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5';
import { import {
IconButton, IconButton,
Input, Input,
@@ -11,6 +10,7 @@ import {
ModalOverlay, ModalOverlay,
Select, Select,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { IoArrowForward } from '@react-icons/all-files/io5/IoArrowForward';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
@@ -2,8 +2,8 @@
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx // https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { ReactElement } from 'react'; import { ReactElement } from 'react';
import { IconType } from 'react-icons';
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react'; import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files';
import { create } from 'zustand'; import { create } from 'zustand';
import { ContextMenuOption } from './ContextMenuOption'; import { ContextMenuOption } from './ContextMenuOption';
@@ -23,7 +23,7 @@ export type OptionWithoutGroup = {
withDivider?: boolean; withDivider?: boolean;
}; };
type OptionWithGroup = { export type OptionWithGroup = {
label: string; label: string;
group: Omit<OptionWithoutGroup, 'isGroup'>[]; group: Omit<OptionWithoutGroup, 'isGroup'>[];
}; };
@@ -1,7 +1,7 @@
import { PropsWithChildren, useState } from 'react'; import { PropsWithChildren, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IoCopy } from 'react-icons/io5';
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react'; import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoCopy } from '@react-icons/all-files/io5/IoCopy';
import { tooltipDelayFast } from '../../../ontimeConfig'; import { tooltipDelayFast } from '../../../ontimeConfig';
import { Size } from '../../models/Util.type'; import { Size } from '../../models/Util.type';
@@ -1,5 +1,6 @@
import { IoChevronDown, IoChevronUp } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
import { tooltipDelayFast } from '../../../ontimeConfig'; import { tooltipDelayFast } from '../../../ontimeConfig';
import { millisToDelayString } from '../../utils/dateConfig'; import { millisToDelayString } from '../../utils/dateConfig';
@@ -3,8 +3,8 @@ import React from 'react';
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module. // skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import { hasConnected, reconnectAttempts } from '../../../common/utils/socket'; import { runtimeStore } from '@/common/stores/runtime';
import { runtimeStore } from '../../stores/runtime'; import { hasConnected, reconnectAttempts, shouldReconnect } from '@/common/utils/socket';
import style from './ErrorBoundary.module.scss'; import style from './ErrorBoundary.module.scss';
@@ -26,22 +26,17 @@ class ErrorBoundary extends React.Component {
error, error,
errorInfo: info, errorInfo: info,
}); });
this.reportContent = `${error} ${info.componentStack}`;
const appState = runtimeStore.getState();
if (appState.ping < 0) {
return;
}
Sentry.withScope((scope) => { Sentry.withScope((scope) => {
scope.setExtras({ scope.setExtras({
error, error,
store: appState, store: runtimeStore.getState(),
hasSocket: { hasConnected, reconnectAttempts }, hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
}); });
const eventId = Sentry.captureException(error); const eventId = Sentry.captureException(error);
this.setState({ eventId, info }); this.setState({ eventId, info });
}); });
this.reportContent = `${error} ${info.componentStack}`;
} }
render() { render() {
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { IoAlertCircle } from 'react-icons/io5'; import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -1,4 +1,4 @@
import { IoBan } from 'react-icons/io5'; import { IoBan } from '@react-icons/all-files/io5/IoBan';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
@@ -1,6 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useController, UseControllerProps } from 'react-hook-form'; import { useController, UseControllerProps } from 'react-hook-form';
import { IoEyedrop } from 'react-icons/io5'; import { IoEyedrop } from '@react-icons/all-files/io5/IoEyedrop';
import { ViewSettings } from 'ontime-types'; import { ViewSettings } from 'ontime-types';
import { debounce } from '../../../utils/debounce'; import { debounce } from '../../../utils/debounce';
@@ -2,7 +2,7 @@ import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { Input, Radio, RadioGroup } from '@chakra-ui/react'; import { Input, Radio, RadioGroup } from '@chakra-ui/react';
import { millisToString, parseUserTime } from 'ontime-utils'; import { millisToString, parseUserTime } from 'ontime-utils';
import { useEntryActions } from '../../../hooks/useEntryAction'; import { useEventAction } from '../../../hooks/useEventAction';
import style from './DelayInput.module.scss'; import style from './DelayInput.module.scss';
@@ -13,7 +13,7 @@ interface DelayInputProps {
export default function DelayInput(props: DelayInputProps) { export default function DelayInput(props: DelayInputProps) {
const { eventId, duration } = props; const { eventId, duration } = props;
const { updateEntry } = useEntryActions(); const { updateEvent } = useEventAction();
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
@@ -54,7 +54,7 @@ export default function DelayInput(props: DelayInputProps) {
}; };
const submitChange = (value: number) => { const submitChange = (value: number) => {
updateEntry({ updateEvent({
id: eventId, id: eventId,
duration: value, duration: value,
}); });
@@ -17,7 +17,6 @@ export default function useReactiveTextInput(
submitOnCtrlEnter?: boolean; submitOnCtrlEnter?: boolean;
onCancelUpdate?: () => void; onCancelUpdate?: () => void;
allowSubmitSameValue?: boolean; allowSubmitSameValue?: boolean;
allowKeyboardNavigation?: boolean;
}, },
): UseReactiveTextInputReturn { ): UseReactiveTextInputReturn {
const [text, setText] = useState<string>(initialText); const [text, setText] = useState<string>(initialText);
@@ -59,14 +58,7 @@ export default function useReactiveTextInput(
setText(cleanVal); setText(cleanVal);
} }
} }
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
setTimeout(() => {
if (options?.allowKeyboardNavigation) {
ref.current?.parentElement?.focus(); // Focus on parent element to continue keyboard navigation
} else {
ref.current?.blur();
}
}); // Immediate timeout to ensure text is set before blurring
}, },
[initialText, options, ref, submitCallback], [initialText, options, ref, submitCallback],
); );
@@ -78,25 +70,12 @@ export default function useReactiveTextInput(
const handleEscape = useCallback(() => { const handleEscape = useCallback(() => {
// No need to update if it hasn't changed // No need to update if it hasn't changed
setText(initialText); setText(initialText);
// force the text to be the initial value
if (ref.current) {
ref.current.value = initialText;
}
options?.onCancelUpdate?.(); options?.onCancelUpdate?.();
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
}, [initialText, options, ref]); }, [initialText, options, ref]);
const keyHandler = useMemo(() => { const keyHandler = useMemo(() => {
const hotKeys: HotkeyItem[] = [ const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
[
'Escape',
(event) => {
event.preventDefault();
handleEscape();
},
{ preventDefault: true },
],
];
if (options?.submitOnEnter) { if (options?.submitOnEnter) {
hotKeys.push(['Enter', () => handleSubmit(text)]); hotKeys.push(['Enter', () => handleSubmit(text)]);
@@ -105,22 +84,7 @@ export default function useReactiveTextInput(
if (options?.submitOnCtrlEnter) { if (options?.submitOnCtrlEnter) {
hotKeys.push(['mod + Enter', () => handleSubmit(text)]); hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
} }
return getHotkeyHandler(hotKeys);
const hotKeyHandler = getHotkeyHandler(hotKeys);
return (event: KeyboardEvent<HTMLElement>) => {
// allow moving in input field with arrow keys
if (
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
event.key === 'ArrowUp' ||
event.key === 'ArrowDown'
) {
event.stopPropagation();
}
hotKeyHandler(event);
};
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]); }, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
return { return {
@@ -3,9 +3,8 @@
font-size: $text-body-size; font-size: $text-body-size;
text-decoration: underline; text-decoration: underline;
text-underline-offset: 2px; text-underline-offset: 2px;
color: $blue-400;
&:hover { &:hover {
color: $blue-500; color: $blue-400;
} }
} }
@@ -1,9 +1,12 @@
import { type PropsWithChildren } from 'react'; import { type PropsWithChildren } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { cx } from '../../../utils/styleUtils';
import style from './AppLink.module.scss'; import style from './AppLink.module.scss';
interface AppLinkProps { interface AppLinkProps {
className?: string;
search: string; search: string;
} }
@@ -12,13 +15,13 @@ interface AppLinkProps {
* Handles the path to respect Ontime Clouds base URL * Handles the path to respect Ontime Clouds base URL
*/ */
export default function AppLink(props: PropsWithChildren<AppLinkProps>) { export default function AppLink(props: PropsWithChildren<AppLinkProps>) {
const { search, children } = props; const { className, search, children } = props;
const navigate = useNavigate(); const navigate = useNavigate();
const handleClick = () => navigate({ search }); const handleClick = () => navigate({ search });
return ( return (
<button onClick={handleClick} className={style.link}> <button onClick={handleClick} className={cx([style.link, className])}>
{children} {children}
</button> </button>
); );
@@ -1,5 +1,5 @@
import { MouseEvent, ReactNode } from 'react'; import { MouseEvent, ReactNode } from 'react';
import { IoOpenOutline } from 'react-icons/io5'; import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { openLink } from '../../../utils/linkUtils'; import { openLink } from '../../../utils/linkUtils';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
@@ -0,0 +1,45 @@
$loader-size: 4rem;
.overlay {
position: absolute;
z-index: 10;
width: 100%;
height: 100%;
display: grid;
place-content: center;
background-color: $black-10;
backdrop-filter: blur(5px);
}
.loader {
width: $loader-size;
height: $loader-size;
background: $blue-500;
display: inline-block;
border-radius: 50%;
box-sizing: border-box;
animation: animloader 1s ease-in infinite;
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@@ -0,0 +1,9 @@
import style from './LoaderOverlay.module.scss';
export default function LoaderOverlay() {
return (
<div className={style.overlay}>
<span className={style.loader} />
</div>
);
}
@@ -1,5 +1,5 @@
import { IoApps } from 'react-icons/io5'; import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from 'react-icons/io5'; import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity'; import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -1,6 +1,5 @@
import { memo, PropsWithChildren } from 'react'; import { memo, PropsWithChildren } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { IoArrowUp, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { import {
Drawer, Drawer,
@@ -12,6 +11,11 @@ import {
useDisclosure, useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useFullscreen } from '@mantine/hooks'; import { useFullscreen } from '@mantine/hooks';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { isLocalhost } from '../../../externals'; import { isLocalhost } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
@@ -0,0 +1,16 @@
import { memo } from 'react';
import NavigationMenu from './NavigationMenu';
interface ProductionNavigationMenuProps {
isMenuOpen: boolean;
onMenuClose: () => void;
}
function ProductionNavigationMenu(props: ProductionNavigationMenuProps) {
const { isMenuOpen, onMenuClose } = props;
return <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />;
}
export default memo(ProductionNavigationMenu);
@@ -1,4 +1,4 @@
import { IoLockClosedOutline } from 'react-icons/io5'; import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity'; import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -1,5 +1,7 @@
import { IoPause, IoPlay, IoStop } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { Playback } from 'ontime-types'; import { Playback } from 'ontime-types';
import { tooltipDelayFast } from '../../../ontimeConfig'; import { tooltipDelayFast } from '../../../ontimeConfig';
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react'; import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import style from './ProtectRoute.module.scss'; import style from './ProtectRoute.module.scss';
@@ -1,5 +1,4 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoChevronDown } from 'react-icons/io5';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { import {
Button, Button,
@@ -14,6 +13,7 @@ import {
Select, Select,
Switch, Switch,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils'; import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
@@ -126,9 +126,9 @@ function MultiOption(props: EditFormMultiOptionProps) {
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])} onChange={(value) => setParamState(Array.isArray(value) ? value : [value])}
> >
{Object.values(paramField.values).map((option) => { {Object.values(paramField.values).map((option) => {
const { value, label, colour } = option; const { value, label } = option;
return ( return (
<MenuItemOption value={value} key={value} style={{ borderRight: `8px solid ${colour}` }}> <MenuItemOption value={value} key={value}>
{label} {label}
</MenuItemOption> </MenuItemOption>
); );
@@ -1,5 +1,5 @@
import { IoChevronDown } from 'react-icons/io5';
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -1,13 +1,13 @@
import { CustomFields } from 'ontime-types'; import { CustomFields } from 'ontime-types';
import type { MultiselectOptions, ParamField } from './types'; import type { ParamField } from './types';
export const makeOptionsFromCustomFields = ( export const makeOptionsFromCustomFields = (
customFields: CustomFields, customFields: CustomFields,
additionalOptions: Readonly<Record<string, string>> = {}, additionalOptions: Record<string, string> = {},
filterImageType = true, filterImageType = true,
) => { ) => {
const options = { ...additionalOptions }; const options = structuredClone(additionalOptions);
for (const [key, value] of Object.entries(customFields)) { for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') { if (filterImageType && value.type === 'image') {
continue; continue;
@@ -18,17 +18,6 @@ export const makeOptionsFromCustomFields = (
return options; return options;
}; };
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOptions {
const options: MultiselectOptions = {};
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options[key] = { value: key, label: value.label, colour: value.colour };
}
return options;
}
export const getTimeOption = (timeFormat: string): ParamField => { export const getTimeOption = (timeFormat: string): ParamField => {
const placeholder = `${timeFormat} (default)`; const placeholder = `${timeFormat} (default)`;
return { return {
@@ -12,11 +12,9 @@ type OptionsField = {
defaultValue?: string; defaultValue?: string;
}; };
type MultiselectOption = { value: string; label: string; colour: string };
export type MultiselectOptions = Record<string, MultiselectOption>;
type MultiOptionsField = { type MultiOptionsField = {
type: 'multi-option'; type: 'multi-option';
values: MultiselectOptions; values: Record<string, { value: string; label: string; colour: string }>;
defaultValue?: string; defaultValue?: string;
}; };
@@ -0,0 +1,32 @@
// roughly from https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useMediaQuery/useMediaQuery.ts
import { useCallback, useEffect, useState } from 'react';
function getMatches(query: string): boolean {
return window.matchMedia(query).matches;
}
// TODO: debounce handleChange
export default function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(getMatches(query));
const handleChange = useCallback(() => {
setMatches(getMatches(query));
}, [query]);
useEffect(() => {
const matchMedia = window.matchMedia(query);
// Triggered at the first client-side load and if query changes
handleChange();
// Listen matchMedia
matchMedia.addEventListener('change', handleChange);
return () => {
matchMedia.removeEventListener('change', handleChange);
};
}, [handleChange, query]);
return matches;
}
@@ -1,9 +1,11 @@
import { useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { getAutomationSettings } from '../api/automation'; import { editAutomationSettings, getAutomationSettings } from '../api/automation';
import { AUTOMATION } from '../api/constants'; import { AUTOMATION } from '../api/constants';
import { logAxiosError } from '../api/utils';
import { automationPlaceholderSettings } from '../models/AutomationSettings'; import { automationPlaceholderSettings } from '../models/AutomationSettings';
import { ontimeQueryClient } from '../queryClient';
export default function useAutomationSettings() { export default function useAutomationSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
@@ -18,3 +20,15 @@ export default function useAutomationSettings() {
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch }; return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
} }
export function useAutomationSettingsMutation() {
const { isPending, mutateAsync } = useMutation({
mutationFn: editAutomationSettings,
onError: (error) => logAxiosError('Error saving Automation settings', error),
onSuccess: (data) => {
ontimeQueryClient.setQueryData(AUTOMATION, data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: AUTOMATION }),
});
return { isPending, mutateAsync };
}
@@ -11,7 +11,7 @@ const placeholderProjectList: ProjectFileListResponse = {
lastLoadedProject: '', lastLoadedProject: '',
}; };
function useProjectList() { export function useProjectList() {
const { data, status, refetch } = useQuery({ const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST, queryKey: PROJECT_LIST,
queryFn: getProjects, queryFn: getProjects,
@@ -1,30 +1,23 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { OntimeEntry, Rundown } from 'ontime-types'; import { NormalisedRundown, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { fetchCurrentRundown } from '../api/rundown'; import { fetchNormalisedRundown } from '../api/rundown';
import useProjectData from './useProjectData'; import useProjectData from './useProjectData';
// revision is -1 so that the remote revision is higher // revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = { const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
id: 'default',
title: '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
};
/** /**
* Normalised rundown data * Normalised rundown data
*/ */
export default function useRundown() { export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({ const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
queryKey: RUNDOWN, queryKey: RUNDOWN,
queryFn: fetchCurrentRundown, queryFn: fetchNormalisedRundown,
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
@@ -44,16 +37,16 @@ export function useFlatRundown() {
const loadedProject = useRef<string>(''); const loadedProject = useRef<string>('');
const [prevRevision, setPrevRevision] = useState<number>(-1); const [prevRevision, setPrevRevision] = useState<number>(-1);
const [flatRundown, setFlatRundown] = useState<OntimeEntry[]>([]); const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]);
// update data whenever the revision changes // update data whenever the revision changes
useEffect(() => { useEffect(() => {
if (data.revision !== -1 && data.revision !== prevRevision) { if (data.revision !== -1 && data.revision !== prevRevision) {
const flatRundown = data.order.map((id) => data.entries[id]); const flatRundown = data.order.map((id) => data.rundown[id]);
setFlatRundown(flatRundown); setFlatRunDown(flatRundown);
setPrevRevision(data.revision); setPrevRevision(data.revision);
} }
}, [data.entries, data.order, data.revision, prevRevision]); }, [data.order, data.revision, data.rundown, prevRevision]);
// TODO: should we have a project id field? // TODO: should we have a project id field?
// invalidate current version if project changes // invalidate current version if project changes
@@ -64,13 +57,13 @@ export function useFlatRundown() {
} }
}, [projectData]); }, [projectData]);
return { data: flatRundown, status }; return { data: flatRunDown, status };
} }
/** /**
* Provides access to a partial rundown based on a filter callback * Provides access to a partial rundown based on a filter callback
*/ */
export function usePartialRundown(cb: (event: OntimeEntry) => boolean) { export function usePartialRundown(cb: (event: OntimeRundownEntry) => boolean) {
const { data, status } = useFlatRundown(); const { data, status } = useFlatRundown();
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
return data.filter(cb); return data.filter(cb);
@@ -1,35 +1,29 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
EntryId,
isOntimeBlock,
isOntimeEvent, isOntimeEvent,
MaybeString,
OntimeBlock, OntimeBlock,
OntimeEntry, OntimeDelay,
OntimeEvent, OntimeEvent,
Rundown, OntimeRundownEntry,
RundownCached,
TimeField, TimeField,
TimeStrategy, TimeStrategy,
TransientEventPayload, TransientEventPayload,
} from 'ontime-types'; } from 'ontime-types';
import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, swapEventData } from 'ontime-utils'; import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
import { moveDown, moveUp } from '../../features/rundown/rundown.utils';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { import {
deleteEntries,
patchReorderEntry,
postAddEntry,
postCloneEntry,
putBatchEditEvents,
putEditEntry,
ReorderEntry, ReorderEntry,
requestApplyDelay, requestApplyDelay,
requestBatchPutEvents,
requestDelete,
requestDeleteAll, requestDeleteAll,
requestEventSwap, requestEventSwap,
requestGroupEntries, requestPostEvent,
requestUngroup, requestPutEvent,
requestReorderEvent,
SwapEntry, SwapEntry,
} from '../api/rundown'; } from '../api/rundown';
import { logAxiosError } from '../api/utils'; import { logAxiosError } from '../api/utils';
@@ -37,18 +31,18 @@ import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{ export type EventOptions = Partial<{
// options to any new block (event / delay / block) // options to any new block (event / delay / block)
after: MaybeString; after: string;
before: MaybeString; before: string;
// options to blocks of type OntimeEvent // options to blocks of type OntimeEvent
defaultPublic: boolean; defaultPublic: boolean;
linkPrevious: boolean; linkPrevious: boolean;
lastEventId: MaybeString; lastEventId: string;
}>; }>;
/** /**
* Gather utilities for actions on entries * @description Set of utilities for events //TODO: should this be called useEntryAction and so on
*/ */
export const useEntryActions = () => { export const useEventAction = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { const {
defaultPublic, defaultPublic,
@@ -61,36 +55,38 @@ export const useEntryActions = () => {
defaultEndAction, defaultEndAction,
} = useEditorSettings(); } = useEditorSettings();
const getEntryById = useCallback( const getEventById = useCallback(
(eventId: string): OntimeEntry | undefined => { (eventId: string) => {
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN); const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown?.entries) { if (!cachedRundown?.rundown) {
return; return;
} }
return cachedRundown.entries[eventId]; return cachedRundown.rundown[eventId];
}, },
[queryClient], [queryClient],
); );
/** /**
* Calls mutation to add new entry * Calls mutation to add new event
* @private * @private
*/ */
const _addEntryMutation = useMutation({ const _addEventMutation = useMutation({
// TODO(v4): optimistic create entry mutationFn: requestPostEvent,
mutationFn: postAddEntry, onSettled: () => {
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
}); });
/** /**
* Adds an entry to rundown * Adds an event to rundown
*/ */
const addEntry = useCallback( const addEvent = useCallback(
async (entry: Partial<OntimeEntry>, options?: EventOptions) => { async (event: Partial<OntimeEvent | OntimeDelay | OntimeBlock>, options?: EventOptions) => {
const newEntry: TransientEventPayload = { ...entry, id: generateId() }; const newEvent: TransientEventPayload = { ...event };
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) { if (isOntimeEvent(newEvent)) {
// merge creation time options with event settings // merge creation time options with event settings
const applicationOptions = { const applicationOptions = {
after: options?.after, after: options?.after,
@@ -100,60 +96,66 @@ export const useEntryActions = () => {
linkPrevious: options?.linkPrevious ?? linkPrevious, linkPrevious: options?.linkPrevious ?? linkPrevious,
}; };
if (applicationOptions?.lastEventId) { if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
newEvent.linkStart = applicationOptions.lastEventId;
} else if (applicationOptions?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!; const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
const previousEvent = rundownData.entries[applicationOptions.lastEventId]; const { rundown } = rundownData;
const previousEvent = rundown[applicationOptions.lastEventId];
if (isOntimeEvent(previousEvent)) { if (isOntimeEvent(previousEvent)) {
newEntry.timeStart = previousEvent.timeEnd; newEvent.timeStart = previousEvent.timeEnd;
} }
} }
// Override event with options from editor settings // Override event with options from editor settings
newEntry.linkStart = applicationOptions.linkPrevious; if (applicationOptions.defaultPublic) {
newEntry.isPublic = applicationOptions.defaultPublic; newEvent.isPublic = true;
if (newEntry.duration === undefined && newEntry.timeEnd === undefined) {
newEntry.duration = parseUserTime(defaultDuration);
} }
if (newEntry.timeDanger === undefined) { if (newEvent.duration === undefined && newEvent.timeEnd === undefined) {
newEntry.timeDanger = parseUserTime(defaultDangerTime); newEvent.duration = parseUserTime(defaultDuration);
} }
if (newEntry.timeWarning === undefined) { if (newEvent.timeDanger === undefined) {
newEntry.timeWarning = parseUserTime(defaultWarnTime); newEvent.timeDanger = parseUserTime(defaultDangerTime);
} }
if (newEntry.timerType === undefined) { if (newEvent.timeWarning === undefined) {
newEntry.timerType = defaultTimerType; newEvent.timeWarning = parseUserTime(defaultWarnTime);
} }
if (newEntry.endAction === undefined) { if (newEvent.timerType === undefined) {
newEntry.endAction = defaultEndAction; newEvent.timerType = defaultTimerType;
} }
if (newEntry.timeStrategy === undefined) { if (newEvent.endAction === undefined) {
newEntry.timeStrategy = defaultTimeStrategy; newEvent.endAction = defaultEndAction;
}
if (newEvent.timeStrategy === undefined) {
newEvent.timeStrategy = defaultTimeStrategy;
} }
} }
// handle adding options that concern all event type // handle adding options that concern all event type
if (options?.after) { if (options?.after) {
(newEntry as TransientEventPayload).after = options.after; // @ts-expect-error -- not sure how to type this, <after> is a transient property
newEvent.after = options.after;
} }
if (options?.before) { if (options?.before) {
(newEntry as TransientEventPayload).before = options.before; // @ts-expect-error -- not sure how to type this, <before> is a transient property
newEvent.before = options.before;
} }
try { try {
await _addEntryMutation.mutateAsync(newEntry); await _addEventMutation.mutateAsync(newEvent as TransientEventPayload);
} catch (error) { } catch (error) {
logAxiosError('Failed adding event', error); logAxiosError('Failed adding event', error);
} }
}, },
[ [
_addEntryMutation, _addEventMutation,
defaultDangerTime, defaultDangerTime,
defaultDuration, defaultDuration,
defaultEndAction, defaultEndAction,
@@ -167,56 +169,26 @@ export const useEntryActions = () => {
); );
/** /**
* Calls mutation to clone a selection * Calls mutation to update existing event
* @private * @private
*/ */
const _cloneMutation = useMutation({ const _updateEventMutation = useMutation({
mutationFn: postCloneEntry, mutationFn: requestPutEvent,
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
/**
* Clone a selection
*/
const clone = useCallback(
async (entryId: EntryId) => {
try {
await _cloneMutation.mutateAsync(entryId);
} catch (error) {
logAxiosError('Error cloning entry', error);
}
},
[_cloneMutation],
);
/**
* Calls mutation to update existing entry
* @private
*/
const _updateEntryMutation = useMutation({
mutationFn: putEditEntry,
// we optimistically update here // we optimistically update here
onMutate: async (newEvent) => { onMutate: async (newEvent) => {
// cancel ongoing queries // cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN); const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const eventId = newEvent.id; const eventId = newEvent.id;
if (previousData && eventId) { if (previousData && eventId) {
// optimistically update object // optimistically update object
const newRundown = { ...previousData.entries }; const newRundown = { ...previousData.rundown };
// @ts-expect-error -- we expect the events to be of same type // @ts-expect-error -- we expect the events to be of same type
newRundown[eventId] = { ...newRundown[eventId], ...newEvent }; newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
queryClient.setQueryData<Rundown>(RUNDOWN, { queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
id: previousData.id,
title: previousData.title,
order: previousData.order,
flatOrder: previousData.flatOrder,
entries: newRundown,
revision: -1,
});
} }
// Return a context with the previous and new events // Return a context with the previous and new events
@@ -224,45 +196,46 @@ export const useEntryActions = () => {
}, },
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => { onError: (_error, _newEvent, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData); queryClient.setQueryData(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
onSettled: async () => { onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN }); await queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
networkMode: 'always',
}); });
/** /**
* Updates existing entry * Updates existing event
*/ */
const updateEntry = useCallback( const updateEvent = useCallback(
async (event: Partial<OntimeEntry>) => { async (event: Partial<OntimeRundownEntry>) => {
try { try {
await _updateEntryMutation.mutateAsync(event); await _updateEventMutation.mutateAsync(event);
} catch (error) { } catch (error) {
logAxiosError('Error updating event', error); logAxiosError('Error updating event', error);
} }
}, },
[_updateEntryMutation], [_updateEventMutation],
); );
const updateCustomField = useCallback( const updateCustomField = useCallback(
async (entryId: EntryId, field: string, value: string) => { async (eventId: string, field: string, value: string) => {
updateEntry({ id: entryId, custom: { [field]: value } }); updateEvent({ id: eventId, custom: { [field]: value } });
}, },
[updateEntry], [updateEvent],
); );
/** /**
* Updates time of existing event * Updates time of existing event
* @param eventId {EntryId} - id of the event * @param eventId {string} - id of the event
* @param field {TimeField} - field to update * @param field {TimeField} - field to update
* @param value {string} - new value string to be parsed * @param value {string} - new value string to be parsed
* @param lockOnUpdate {boolean} - whether we will apply the lock / release on update * @param lockOnUpdate {boolean} - whether we will apply the lock / release on update
*/ */
const updateTimer = useCallback( const updateTimer = useCallback(
async (eventId: EntryId, field: TimeField, value: string, lockOnUpdate?: boolean) => { async (eventId: string, field: TimeField, value: string, lockOnUpdate?: boolean) => {
// an empty value with no lock has no domain validity // an empty value with no lock has no domain validity
if (!lockOnUpdate && value === '') { if (!lockOnUpdate && value === '') {
return; return;
@@ -284,7 +257,7 @@ export const useEntryActions = () => {
newEvent.duration = value === '' ? undefined : calculateNewValue(); newEvent.duration = value === '' ? undefined : calculateNewValue();
} else if (field === 'timeStart') { } else if (field === 'timeStart') {
// an empty values means we should link to the previous // an empty values means we should link to the previous
newEvent.linkStart = value === ''; newEvent.linkStart = value === '' ? 'true' : null;
newEvent.timeStart = value === '' ? undefined : calculateNewValue(); newEvent.timeStart = value === '' ? undefined : calculateNewValue();
} }
} else { } else {
@@ -292,7 +265,7 @@ export const useEntryActions = () => {
} }
try { try {
await _updateEntryMutation.mutateAsync(newEvent); await _updateEventMutation.mutateAsync(newEvent);
} catch (error) { } catch (error) {
logAxiosError('Error updating event', error); logAxiosError('Error updating event', error);
} }
@@ -323,9 +296,9 @@ export const useEntryActions = () => {
* Utility function to get the previous event end time * Utility function to get the previous event end time
*/ */
function getPreviousEnd(): number { function getPreviousEnd(): number {
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN); const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown?.order || !cachedRundown?.entries) { if (!cachedRundown?.order || !cachedRundown?.rundown) {
return 0; return 0;
} }
@@ -335,7 +308,7 @@ export const useEntryActions = () => {
} }
let previousEnd = 0; let previousEnd = 0;
for (let i = index - 1; i >= 0; i--) { for (let i = index - 1; i >= 0; i--) {
const event = cachedRundown.entries[cachedRundown.order[i]]; const event = cachedRundown.rundown[cachedRundown.order[i]];
if (isOntimeEvent(event)) { if (isOntimeEvent(event)) {
previousEnd = event.timeEnd; previousEnd = event.timeEnd;
break; break;
@@ -344,7 +317,7 @@ export const useEntryActions = () => {
return previousEnd; return previousEnd;
} }
}, },
[_updateEntryMutation, queryClient], [_updateEventMutation, queryClient],
); );
/** /**
@@ -352,17 +325,17 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const _batchUpdateEventsMutation = useMutation({ const _batchUpdateEventsMutation = useMutation({
mutationFn: putBatchEditEvents, mutationFn: requestBatchPutEvents,
onMutate: async ({ ids, data }) => { onMutate: async ({ ids, data }) => {
// cancel ongoing queries // cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousRundown = queryClient.getQueryData<Rundown>(RUNDOWN); const previousEvents = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousRundown) { if (previousEvents) {
const eventIds = new Set(ids); const eventIds = new Set(ids);
const newRundown = { ...previousRundown.entries }; const newRundown = { ...previousEvents.rundown };
eventIds.forEach((eventId) => { eventIds.forEach((eventId) => {
if (Object.hasOwn(newRundown, eventId)) { if (Object.hasOwn(newRundown, eventId)) {
@@ -376,25 +349,18 @@ export const useEntryActions = () => {
} }
}); });
queryClient.setQueryData<Rundown>(RUNDOWN, { queryClient.setQueryData(RUNDOWN, { order: previousEvents.order, rundown: newRundown, revision: -1 });
id: previousRundown.id,
title: previousRundown.title,
order: previousRundown.order,
flatOrder: previousRundown.flatOrder,
entries: newRundown,
revision: -1,
});
} }
// Return a context with the previous and new events
// Return a context with the previous rundown return { previousEvents };
return { previousRundown };
}, },
onSettled: async () => { onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN }); await queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
onError: (_error, _newEvent, context) => { onError: (_error, _newEvent, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousRundown); queryClient.setQueryData(RUNDOWN, context?.previousEvents);
}, },
networkMode: 'always',
}); });
const batchUpdateEvents = useCallback( const batchUpdateEvents = useCallback(
@@ -409,29 +375,30 @@ export const useEntryActions = () => {
); );
/** /**
* Calls mutation to delete an entry * Calls mutation to delete an event
* @private * @private
*/ */
const _deleteEntryMutation = useMutation({ const _deleteEventMutation = useMutation({
mutationFn: deleteEntries, mutationFn: requestDelete,
// we optimistically update here // we optimistically update here
onMutate: async (entryIds: EntryId[]) => { onMutate: async (eventIds: string[]) => {
// cancel ongoing queries // cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN); const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) { if (previousData) {
// optimistically update object // optimistically update object
const { entries, order, flatOrder } = optimisticDeleteEntries(entryIds, previousData); const newOrder = previousData.order.filter((id) => !eventIds.includes(id));
const newRundown = { ...previousData.rundown };
for (const eventId of eventIds) {
delete newRundown[eventId];
}
queryClient.setQueryData<Rundown>(RUNDOWN, { queryClient.setQueryData(RUNDOWN, {
id: previousData.id, order: newOrder,
title: previousData.title, rundown: newRundown,
order,
flatOrder,
entries,
revision: -1, revision: -1,
}); });
} }
@@ -441,35 +408,36 @@ export const useEntryActions = () => {
}, },
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _entryIds, context) => { onError: (_error, _eventId, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData); queryClient.setQueryData(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
networkMode: 'always',
}); });
/** /**
* Deletes an event entry from the rundown * Deletes an event form the list
*/ */
const deleteEntry = useCallback( const deleteEvent = useCallback(
async (entryIds: EntryId[]) => { async (eventIds: string[]) => {
try { try {
await _deleteEntryMutation.mutateAsync(entryIds); await _deleteEventMutation.mutateAsync(eventIds);
} catch (error) { } catch (error) {
logAxiosError('Error deleting event', error); logAxiosError('Error deleting event', error);
} }
}, },
[_deleteEntryMutation], [_deleteEventMutation],
); );
/** /**
* Calls mutation to delete all events * Calls mutation to delete all events
* @private * @private
*/ */
const _deleteAllEntriesMutation = useMutation({ const _deleteAllEventsMutation = useMutation({
mutationFn: requestDeleteAll, mutationFn: requestDeleteAll,
// we optimistically update here // we optimistically update here
onMutate: async () => { onMutate: async () => {
@@ -477,43 +445,37 @@ export const useEntryActions = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN); const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
// optimistically update object // optimistically update object
queryClient.setQueryData<Rundown>(RUNDOWN, { queryClient.setQueryData(RUNDOWN, { rundown: {}, order: [], revision: -1 });
id: previousData?.id ?? 'default',
title: previousData?.title ?? '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
});
// Return a context with the previous and new events // Return a context with the previous and new events
return { previousData }; return { previousData };
}, },
// Mutation fails, rollback optimist update // Mutation fails, rollback undos optimist update
onError: (_error, _, context) => { onError: (_error, _eventId, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData); queryClient.setQueryData(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
networkMode: 'always',
}); });
/** /**
* Deletes all entries in the rundown * Deletes all events from list
*/ */
const deleteAllEntries = useCallback(async () => { const deleteAllEvents = useCallback(async () => {
try { try {
await _deleteAllEntriesMutation.mutateAsync(); await _deleteAllEventsMutation.mutateAsync();
} catch (error) { } catch (error) {
logAxiosError('Error deleting events', error); logAxiosError('Error deleting events', error);
} }
}, [_deleteAllEntriesMutation]); }, [_deleteAllEventsMutation]);
/** /**
* Calls mutation to apply a delay * Calls mutation to apply a delay
@@ -525,13 +487,14 @@ export const useEntryActions = () => {
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
networkMode: 'always',
}); });
/** /**
* Applies a given delay * Applies a given delay block
*/ */
const applyDelay = useCallback( const applyDelay = useCallback(
async (delayEventId: EntryId) => { async (delayEventId: string) => {
try { try {
await _applyDelayMutation.mutateAsync(delayEventId); await _applyDelayMutation.mutateAsync(delayEventId);
} catch (error) { } catch (error) {
@@ -542,133 +505,61 @@ export const useEntryActions = () => {
); );
/** /**
* Calls mutation to dissolve a block * Calls mutation to reorder an event
* @private * @private
*/ */
const _ungroupMutation = useMutation({ const _reorderEventMutation = useMutation({
mutationFn: requestUngroup, mutationFn: requestReorderEvent,
onSuccess: (response) => { // we optimistically update here
if (!response.data) return; onMutate: async (data) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
const { id, title, order, flatOrder, entries, revision } = response.data; // Snapshot the previous value
queryClient.setQueryData<Rundown>(RUNDOWN, { const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
id,
title,
order,
flatOrder,
entries,
revision,
});
},
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
/** if (previousData) {
* Deletes a block and moves its events to the top level // optimistically update object
*/ const newOrder = reorderArray(previousData.order, data.from, data.to);
const ungroup = useCallback(
async (blockId: EntryId) => { queryClient.setQueryData(RUNDOWN, { order: newOrder, rundown: previousData.rundown, revision: -1 });
try {
await _ungroupMutation.mutateAsync(blockId);
} catch (error) {
logAxiosError('Error dissolving block', error);
} }
// Return a context with the previous and new events
return { previousData };
}, },
[_ungroupMutation],
);
/** // Mutation fails, rollback undoes optimist update
* Calls mutation to create a block with a selection onError: (_error, _eventId, context) => {
* @private queryClient.setQueryData(RUNDOWN, context?.previousData);
*/
const _groupEntriesMutation = useMutation({
mutationFn: requestGroupEntries,
onSuccess: (response) => {
if (!response.data) return;
const { id, title, order, flatOrder, entries, revision } = response.data;
queryClient.setQueryData<Rundown>(RUNDOWN, {
id,
title,
order,
flatOrder,
entries,
revision,
});
}, },
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
/**
* Create a block with a selection
*/
const groupEntries = useCallback(
async (entryIds: EntryId[]) => {
try {
await _groupEntriesMutation.mutateAsync(entryIds);
} catch (error) {
logAxiosError('Error grouping entries', error);
}
},
[_groupEntriesMutation],
);
/**
* Calls mutation to reorder an entry
* @private
*/
const _reorderEntryMutation = useMutation({
mutationFn: patchReorderEntry,
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
networkMode: 'always',
}); });
/** /**
* Reorders a given entry * Reorders a given event
*/ */
const reorderEntry = useCallback( const reorderEvent = useCallback(
async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => { async (eventId: string, from: number, to: number) => {
try { try {
const reorderObject: ReorderEntry = { const reorderObject: ReorderEntry = {
entryId, eventId,
destinationId, from,
order, to,
}; };
await _reorderEntryMutation.mutateAsync(reorderObject); await _reorderEventMutation.mutateAsync(reorderObject);
} catch (error) { } catch (error) {
logAxiosError('Error re-ordering event', error); logAxiosError('Error re-ordering event', error);
} }
}, },
[_reorderEntryMutation], [_reorderEventMutation],
); );
const move = useCallback(async (entryId: EntryId, direction: 'up' | 'down') => {
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.order) {
return;
}
const { destinationId, order } =
direction === 'up'
? moveUp(entryId, cachedRundown.order, cachedRundown.entries)
: moveDown(entryId, cachedRundown.order, cachedRundown.entries);
if (destinationId) {
try {
const reorderObject: ReorderEntry = {
entryId,
destinationId,
order: order as 'before' | 'after' | 'insert',
};
await _reorderEntryMutation.mutateAsync(reorderObject);
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
}
}, []);
/** /**
* Calls mutation to swap events * Calls mutation to swap events
* @private * @private
@@ -681,29 +572,22 @@ export const useEntryActions = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN); const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) { if (previousData) {
// optimistically update object // optimistically update object
const newRundown = { ...previousData.entries }; const newRundown = { ...previousData.rundown };
const eventA = previousData.entries[from]; const eventA = previousData.rundown[from];
const eventB = previousData.entries[to]; const eventB = previousData.rundown[to];
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) { if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
return; return;
} }
const [newA, newB] = swapEventData(eventA, eventB); const { newA, newB } = swapEventData(eventA, eventB);
newRundown[from] = newA; newRundown[from] = newA;
newRundown[to] = newB; newRundown[to] = newB;
queryClient.setQueryData<Rundown>(RUNDOWN, { queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
id: previousData.id,
title: previousData.title,
order: previousData.order,
flatOrder: previousData.flatOrder,
entries: newRundown,
revision: -1,
});
} }
// Return a context with the previous events // Return a context with the previous events
@@ -712,13 +596,14 @@ export const useEntryActions = () => {
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => { onError: (_error, _eventId, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData); queryClient.setQueryData(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
networkMode: 'always',
}); });
/** /**
@@ -736,48 +621,16 @@ export const useEntryActions = () => {
); );
return { return {
addEntry, addEvent,
applyDelay, applyDelay,
batchUpdateEvents, batchUpdateEvents,
clone, deleteEvent,
deleteEntry, deleteAllEvents,
deleteAllEntries, getEventById,
ungroup, reorderEvent,
getEntryById,
groupEntries,
move,
reorderEntry,
swapEvents, swapEvents,
updateEntry, updateEvent,
updateTimer, updateTimer,
updateCustomField, updateCustomField,
}; };
}; };
/**
* Utility to optimistically delete entries from client cache
*/
function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
const entries = { ...rundown.entries };
let order = [...rundown.order];
let flatOrder = [...rundown.flatOrder];
for (let i = 0; i < entryIds.length; i++) {
const entry = entries[entryIds[i]];
deleteEntry(entry);
}
function deleteEntry(entry: OntimeEntry) {
if (isOntimeBlock(entry) || !entry.parent) {
order = order.filter((id) => id !== entry.id);
} else {
const parent = entries[entry.parent] as OntimeBlock;
parent.events = parent.events.filter((event) => event !== entry.id);
}
delete entries[entry.id];
flatOrder = flatOrder.filter((id) => id !== entry.id);
}
return { entries, order, flatOrder };
}
@@ -12,7 +12,7 @@ type noop = (this: any, ...args: any[]) => any;
type PickFunction<T extends noop> = (this: ThisParameterType<T>, ...args: Parameters<T>) => ReturnType<T>; type PickFunction<T extends noop> = (this: ThisParameterType<T>, ...args: Parameters<T>) => ReturnType<T>;
const isFunction = (value: unknown): value is (...args: any) => any => typeof value === 'function'; export const isFunction = (value: unknown): value is (...args: any) => any => typeof value === 'function';
export default function useMemoisedFn<T extends noop>(fn: T) { export default function useMemoisedFn<T extends noop>(fn: T) {
if (isDev) { if (isDev) {
+32 -17
View File
@@ -1,4 +1,4 @@
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types'; import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime'; import { useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket'; import { socketSendJson } from '../utils/socket';
@@ -41,6 +41,17 @@ export const useExternalMessageInput = createSelector((state: RuntimeStore) => (
visible: state.message.timer.secondarySource === 'external', visible: state.message.timer.secondarySource === 'external',
})); }));
export const useTimerSchedule = createSelector((state: RuntimeStore) => ({
startedAt: state.timer.startedAt,
expectedFinish: state.timer.expectedFinish,
phase: state.timer.phase,
playback: state.timer.playback,
}));
export const useTimerCurrent = createSelector((state: RuntimeStore) => ({
current: state.timer.current,
}));
export const useMessagePreview = createSelector((state: RuntimeStore) => ({ export const useMessagePreview = createSelector((state: RuntimeStore) => ({
blink: state.message.timer.blink, blink: state.message.timer.blink,
blackout: state.message.timer.blackout, blackout: state.message.timer.blackout,
@@ -91,6 +102,14 @@ export const setPlayback = {
}, },
}; };
export const useInfoPanel = createSelector((state: RuntimeStore) => ({
eventNow: state.eventNow,
eventNext: state.eventNext,
playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
}));
export const useAuxTimerTime = createSelector((state: RuntimeStore) => state.auxtimer1.current); export const useAuxTimerTime = createSelector((state: RuntimeStore) => state.auxtimer1.current);
export const useAuxTimerControl = createSelector((state: RuntimeStore) => ({ export const useAuxTimerControl = createSelector((state: RuntimeStore) => ({
@@ -106,6 +125,15 @@ export const setAuxTimer = {
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }), setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
}; };
export const useTimerSpeed = createSelector((state: RuntimeStore) => ({
speed: state.timer.speed,
}));
export const setTimerSpeed = {
getSpeed: () => socketSendJson('get-speed'),
setSpeed: (speed: number) => socketSendJson('set-speed', speed),
};
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({ export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
selectedEventId: state.eventNow?.id ?? null, selectedEventId: state.eventNow?.id ?? null,
})); }));
@@ -137,6 +165,8 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({
timeDanger: state.eventNow?.timeDanger ?? null, timeDanger: state.eventNow?.timeDanger ?? null,
})); }));
export const setClientName = (newName: string) => socketSendJson('set-client-name', newName);
export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({ export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.runtime.plannedStart, plannedStart: state.runtime.plannedStart,
actualStart: state.runtime.actualStart, actualStart: state.runtime.actualStart,
@@ -150,7 +180,7 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) =
numEvents: state.runtime.numEvents, numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex, selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset, offset: state.runtime.offset,
currentBlock: state.currentBlock, currentBlock: state.currentBlock,
})); }));
@@ -160,15 +190,6 @@ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offset, offset: state.runtime.offset,
})); }));
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
offsetMode: state.runtime.offsetMode,
currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event
actualStart: state.runtime.actualStart,
plannedStart: state.runtime.plannedStart,
}));
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({ export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offset, offset: state.runtime.offset,
})); }));
@@ -182,12 +203,6 @@ export const useIsOnline = createSelector((state: RuntimeStore) => ({
isOnline: state.ping > 0, isOnline: state.ping > 0,
})); }));
export const useOffsetMode = createSelector((state: RuntimeStore) => ({
offsetMode: state.runtime.offsetMode,
}));
export const setOffsetMode = (payload: OffsetMode) => socketSendJson('offsetmode', payload);
export const usePlayback = () => { export const usePlayback = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback, playback: state.timer.playback,
+1 -1
View File
@@ -2,7 +2,7 @@ import { GetInfo } from 'ontime-types';
export const ontimePlaceholderInfo: GetInfo = { export const ontimePlaceholderInfo: GetInfo = {
networkInterfaces: [], networkInterfaces: [],
version: '4.0.0', version: '2.0.0',
serverPort: 4001, serverPort: 4001,
publicDir: '', publicDir: '',
}; };
@@ -1,7 +1,8 @@
import { Settings } from 'ontime-types'; import { Settings } from 'ontime-types';
export const ontimePlaceholderSettings: Settings = { export const ontimePlaceholderSettings: Settings = {
version: '4.0.0', app: 'ontime',
version: '2.0.0',
serverPort: 4001, serverPort: 4001,
editorKey: null, editorKey: null,
operatorKey: null, operatorKey: null,
-11
View File
@@ -1,20 +1,9 @@
import { QueryClient } from '@tanstack/react-query'; import { QueryClient } from '@tanstack/react-query';
import { isOntimeCloud } from '../externals';
export const ontimeQueryClient = new QueryClient({ export const ontimeQueryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
gcTime: 1000 * 60 * 10, // 10 min gcTime: 1000 * 60 * 10, // 10 min
}, },
mutations: {
/**
* React Query detects whether the client is online
* However, web access is not required for the clients when deployed locally
* - use 'always' for clients that may be online
* - use 'online' for clients that are connected to the cloud
*/
networkMode: isOntimeCloud ? 'online' : 'always',
},
}, },
}); });
+1 -1
View File
@@ -11,7 +11,7 @@ type LogStore = {
logs: Log[]; logs: Log[];
}; };
const logger = createStore<LogStore>(() => ({ export const logger = createStore<LogStore>(() => ({
logs: [], logs: [],
})); }));
+12
View File
@@ -14,6 +14,18 @@ export const runtimeStore = createWithEqualityFn<RuntimeStore>(
export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) => export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
useStoreWithEqualityFn(runtimeStore, selector, deepCompare); useStoreWithEqualityFn(runtimeStore, selector, deepCompare);
let batchStore: Partial<RuntimeStore> = {};
export function addToBatchUpdates<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]) {
batchStore[key] = value;
}
export function flushBatchUpdates() {
const state = runtimeStore.getState();
runtimeStore.setState({ ...state, ...batchStore });
batchStore = {};
}
/** /**
* Allows patching a property of the runtime store * Allows patching a property of the runtime store
*/ */
@@ -1,64 +0,0 @@
import { EndAction, EntryCustomFields, OntimeEvent, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
import { cloneEvent } from '../clone';
describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
const original: OntimeEvent = {
id: 'unique',
type: SupportedEntry.Event,
title: 'title',
cue: 'cue',
note: 'note',
timeStart: 0,
duration: 10,
timeEnd: 10,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
parent: 'test',
linkStart: false,
countToEnd: false,
endAction: EndAction.None,
isPublic: false,
skip: false,
colour: 'F00',
revision: 10,
timeWarning: 120000,
timeDanger: 60000,
delay: 0,
dayOffset: 0,
gap: 0,
custom: {
lighting: '3',
} as EntryCustomFields,
};
const cloned = cloneEvent(original);
expect(cloned).not.toBe(original);
expect(cloned.custom).not.toBe(original.custom);
expect(cloned).toMatchObject({
type: SupportedEntry.Event,
title: original.title,
note: original.note,
timeStart: original.timeStart,
duration: original.duration,
timeEnd: original.timeEnd,
timerType: original.timerType,
timeStrategy: original.timeStrategy,
parent: 'test',
countToEnd: original.countToEnd,
linkStart: original.linkStart,
endAction: original.endAction,
isPublic: original.isPublic,
skip: original.skip,
colour: original.colour,
revision: 0,
delay: original.delay,
dayOffset: original.dayOffset,
gap: 0,
timeWarning: original.timeWarning,
timeDanger: original.timeDanger,
});
});
});
@@ -1,6 +1,4 @@
import { OntimeEntry, ProjectRundowns, Rundown } from 'ontime-types'; import { makeCSVFromArrayOfArrays } from '../csv';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../csv';
describe('makeCSVFromArrayOfArrays()', () => { describe('makeCSVFromArrayOfArrays()', () => {
it('joins an array of arrays with commas and newlines', () => { it('joins an array of arrays with commas and newlines', () => {
@@ -13,34 +11,3 @@ after newline,after comma
`); `);
}); });
}); });
describe('aggregateRundowns()', () => {
it('flattens an object of rundowns into a single array', () => {
const rundowns = {
first: {
id: '',
title: '',
revision: 0,
order: ['1', '2'],
flatOrder: ['1', '2'],
entries: {
'1': { id: '1' } as OntimeEntry,
'2': { id: '2' } as OntimeEntry,
},
},
second: {
id: '',
title: '',
revision: 0,
order: ['3', '4'],
flatOrder: ['3', '4'],
entries: {
'3': { id: '3' } as OntimeEntry,
'4': { id: '4' } as OntimeEntry,
},
} as Rundown,
} as ProjectRundowns;
expect(aggregateRundowns(rundowns)).toStrictEqual([{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]);
});
});
@@ -0,0 +1,49 @@
import { EndAction, EventCustomFields, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types';
import { cloneEvent } from '../eventsManager';
describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
const original = {
id: 'unique',
type: SupportedEvent.Event,
title: 'title',
cue: 'cue',
note: 'note',
timeStart: 0,
duration: 10,
timeEnd: 10,
timerType: TimerType.CountDown,
endAction: EndAction.None,
isPublic: false,
skip: false,
colour: 'F00',
revision: 10,
timeWarning: 120000,
timeDanger: 60000,
custom: {
lighting: '3',
} as EventCustomFields,
} as OntimeEvent;
const cloned = cloneEvent(original);
expect(cloned).not.toBe(original);
// @ts-expect-error -- safeguarding this
expect(cloned?.id).toBe(undefined);
expect(cloned.title).toBe(original.title);
expect(cloned.note).toBe(original.note);
expect(cloned.endAction).toBe(original.endAction);
expect(cloned.timerType).toBe(original.timerType);
expect(cloned.timeStart).toBe(original.timeStart);
expect(cloned.timeEnd).toBe(original.timeEnd);
expect(cloned.duration).toBe(original.duration);
expect(cloned.isPublic).toBe(original.isPublic);
expect(cloned.skip).toBe(original.skip);
expect(cloned.colour).toBe(original.colour);
expect(cloned.type).toBe(SupportedEvent.Event);
expect(cloned.revision).toBe(0);
expect(cloned.timeWarning).toBe(original.timeWarning);
expect(cloned.timeDanger).toBe(original.timeDanger);
expect(cloned.custom).toStrictEqual({});
});
});
@@ -1,7 +1,4 @@
import { OffsetMode } from 'ontime-types'; import { formatTime, nowInMillis } from '../time';
import { dayInMs } from 'ontime-utils';
import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time';
describe('nowInMillis()', () => { describe('nowInMillis()', () => {
it('should return the current time in milliseconds', () => { it('should return the current time in milliseconds', () => {
@@ -41,257 +38,3 @@ describe('formatTime()', () => {
expect(time).toStrictEqual('-01:00'); expect(time).toStrictEqual('-01:00');
}); });
}); });
describe('calculateTimeUntilStart()', () => {
describe('Absolute offset mode', () => {
test('ontime', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 90,
offset: 0,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
});
test('running behind', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 90,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
});
test('running ahead', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 80,
offset: 10,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
});
test('running behind with enough gaps', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 20,
clock: 50,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50); // <-- when gap is enough to compensate for the running behind
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
});
test('running behind with too little gaps', () => {
const test = {
timeStart: 100,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 10,
clock: 50,
offset: -20,
offsetMode: OffsetMode.Absolute,
actualStart: 0,
plannedStart: 0,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60); // <-- when gap is not enough to compensate for the running behind it absorbs at much as possible
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
});
});
describe('Relative offset mode', () => {
test('basic function', () => {
const test = {
timeStart: 0,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
const timeStartEvent2 = 10;
const timeStartEvent3 = 20;
//event 1 is the currently running event
//event 2
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(10);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(10);
//event 3
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(20);
// When clock advances by 5ms, time until start should decrease by 5ms
test.clock = 105;
//event 2
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(5);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(5);
//event 3
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(15);
});
test('gaps', () => {
const test = {
timeStart: 20,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 10,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
// When clock advances by 5ms, time until start should decrease by 5ms
test.clock = 105;
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(15);
});
test('added/remove time', () => {
const test = {
timeStart: 20,
dayOffset: 0,
delay: 0,
currentDay: 0,
totalGap: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
test.offset = 5; // remove 5 with addtime - we are ahead of time
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // unlocked evets will stay on schedule
test.offset = -5; // add 5 with addtime - we are behind
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(25);
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(25);
});
test('next day', () => {
const test = {
delay: 0,
currentDay: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
};
// this event will start the current day
expect(
calculateTimeUntilStart({
...test,
timeStart: 10,
dayOffset: 0,
totalGap: 0,
isLinkedToLoaded: false,
}),
).toBe(10);
// this event will start the next day
// in absolute mode this would start in dayInMs - 100 since the gap would compensate
// but in relative mode with and actual start that is 100 offset it starts in dayInMs
expect(
calculateTimeUntilStart({
...test,
timeStart: 0,
dayOffset: 1,
totalGap: dayInMs - 20,
isLinkedToLoaded: false,
}),
).toBe(dayInMs);
// advancing 100ms
test.clock = 200;
expect(
calculateTimeUntilStart({
...test,
timeStart: 0,
dayOffset: 1,
totalGap: dayInMs - 20,
isLinkedToLoaded: false,
}),
).toBe(dayInMs - 100);
});
});
test('overlap with negative total gap', () => {
const test = {
dayOffset: 0,
delay: 0,
currentDay: 0,
clock: 100,
actualStart: 100,
plannedStart: 0,
offset: 0,
offsetMode: OffsetMode.Relative,
isLinkedToLoaded: false,
};
// the overlap will be pushed out to the expected available time
expect(calculateTimeUntilStart({ ...test, timeStart: 5, totalGap: -5 })).toBe(10);
test.clock = 105;
});
});
@@ -64,13 +64,13 @@ describe('getRouteFromPreset()', () => {
describe('handle url sharing edge cases', () => { describe('handle url sharing edge cases', () => {
it('finds the correct preset when the url contains extra arguments', () => { it('finds the correct preset when the url contains extra arguments', () => {
const location = resolvePath('/demopage?locked=true&token=123'); const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy(); expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy()
}); })
it('appends the feature params to the alias', () => { it('appends the feature params to the alias', () => {
const location = resolvePath('/demopage?locked=true&token=123'); const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123'); expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123')
}); })
}); });
}); });
@@ -83,26 +83,25 @@ describe('generatePathFromPreset()', () => {
}); });
test('appends the feature params to the alias', () => { test('appends the feature params to the alias', () => {
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe( expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe('timer?user=guest&alias=demopage&locked=true&token=123');
'timer?user=guest&alias=demopage&locked=true&token=123',
);
}); });
}); });
describe('arePathsEquivalent()', () => { describe('arePathsEquivalent()', () => {
it('checks whether the paths match', () => { it("checks whether the paths match", () => {
expect(arePathsEquivalent('demopage', 'timer')).toBeFalsy(); expect(arePathsEquivalent('demopage', 'timer')).toBeFalsy();
expect(arePathsEquivalent('timer', 'timer')).toBeTruthy(); expect(arePathsEquivalent('timer', 'timer')).toBeTruthy();
expect(arePathsEquivalent('timer?user=guest', 'timer?user=guest')).toBeTruthy(); expect(arePathsEquivalent('timer?user=guest', 'timer?user=guest')).toBeTruthy();
}); })
it('checks whether the params match', () => { it("checks whether the params match", () => {
expect(arePathsEquivalent('timer?test=a', 'timer?test=b')).toBeFalsy(); expect(arePathsEquivalent('timer?test=a', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy(); expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy();
}); })
it('considers edge cases for the url sharing feature', () => { it("considers edge cases for the url sharing feature", () => {
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy(); expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy(); expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
}); })
}); });
+3 -24
View File
@@ -1,31 +1,10 @@
import { stringify } from 'csv-stringify/browser/esm/sync'; import { stringify } from 'csv-stringify/browser/esm/sync';
import { OntimeEntry, ProjectRundowns } from 'ontime-types';
/** /**
* Converts an array of arrays to a CSV file * @description Converts an array of arrays to a CSV file
* @param {string[][]} arrayOfArrays
* @return {string}
*/ */
export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string { export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string {
return stringify(arrayOfArrays); return stringify(arrayOfArrays);
} }
/**
* Receives an object of rundowns, and flattens them into a single, linear rundown
* Used for CSV export
*/
export function aggregateRundowns(rundowns: ProjectRundowns): OntimeEntry[] {
const rundownKeys = Object.keys(rundowns);
if (rundownKeys.length === 0) return [];
const flatRundown: OntimeEntry[] = [];
for (const key of rundownKeys) {
const { order, entries } = rundowns[key];
for (let i = 0; i < order.length; i++) {
const entryId = order[i];
const entry = entries[entryId];
flatRundown.push(entry);
}
}
return flatRundown;
}
@@ -1,4 +1,4 @@
import { OntimeEvent, SupportedEntry } from 'ontime-types'; import { OntimeEvent, SupportedEvent } from 'ontime-types';
/** /**
* @description Creates a safe duplicate of an event * @description Creates a safe duplicate of an event
@@ -9,7 +9,7 @@ import { OntimeEvent, SupportedEntry } from 'ontime-types';
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>; type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
export const cloneEvent = (event: OntimeEvent): ClonedEvent => { export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
return { return {
type: SupportedEntry.Event, type: SupportedEvent.Event,
title: event.title, title: event.title,
note: event.note, note: event.note,
timeStart: event.timeStart, timeStart: event.timeStart,
@@ -23,13 +23,12 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
isPublic: event.isPublic, isPublic: event.isPublic,
skip: event.skip, skip: event.skip,
colour: event.colour, colour: event.colour,
parent: event.parent,
revision: 0, revision: 0,
delay: event.delay, // the events will be collocated, so having the same metadata is a good start delay: 0,
dayOffset: event.dayOffset, dayOffset: 0,
gap: 0, gap: 0,
timeWarning: event.timeWarning, timeWarning: event.timeWarning,
timeDanger: event.timeDanger, timeDanger: event.timeDanger,
custom: { ...event.custom }, custom: {},
}; };
}; };
@@ -1,12 +0,0 @@
/**
* Allows lazy evaluation and caching of a functions result
*/
export function lazyEvaluate<T>(fn: () => T): () => T {
let result: T | undefined;
return function () {
if (result === undefined) {
result = fn();
}
return result;
};
}
@@ -1,8 +0,0 @@
import { MaybeString } from 'ontime-types';
export default function safeParseNumber(value: MaybeString, defaultValue: number = 0): number {
if (!value) return defaultValue;
const number = Number(value);
if (isNaN(number)) return defaultValue;
return number;
}
+69 -15
View File
@@ -1,4 +1,4 @@
import { Log, Rundown, RuntimeStore } from 'ontime-types'; import { Log, RundownCached, RuntimeStore } from 'ontime-types';
import { isProduction, websocketUrl } from '../../externals'; import { isProduction, websocketUrl } from '../../externals';
import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants'; import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants';
@@ -14,12 +14,13 @@ import {
} from '../stores/clientStore'; } from '../stores/clientStore';
import { addDialog } from '../stores/dialogStore'; import { addDialog } from '../stores/dialogStore';
import { addLog } from '../stores/logger'; import { addLog } from '../stores/logger';
import { patchRuntime, patchRuntimeProperty } from '../stores/runtime'; import { addToBatchUpdates, flushBatchUpdates, patchRuntime, patchRuntimeProperty } from '../stores/runtime';
let websocket: WebSocket | null = null; export let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null; let reconnectTimeout: NodeJS.Timeout | null = null;
const reconnectInterval = 1000; const reconnectInterval = 1000;
export let shouldReconnect = true;
export let hasConnected = false; export let hasConnected = false;
export let reconnectAttempts = 0; export let reconnectAttempts = 0;
@@ -47,18 +48,17 @@ export const connectSocket = () => {
websocket.onclose = () => { websocket.onclose = () => {
console.warn('WebSocket disconnected'); console.warn('WebSocket disconnected');
// we decide to allows reconnect
reconnectTimeout = setTimeout(() => {
if (reconnectAttempts > 2) {
setOnlineStatus(false); setOnlineStatus(false);
}
if (shouldReconnect) {
reconnectTimeout = setTimeout(() => {
console.warn('WebSocket: attempting reconnect'); console.warn('WebSocket: attempting reconnect');
if (websocket && websocket.readyState === WebSocket.CLOSED) { if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1; reconnectAttempts += 1;
connectSocket(); connectSocket();
} }
}, reconnectInterval); }, reconnectInterval);
}
}; };
websocket.onerror = (error) => { websocket.onerror = (error) => {
@@ -141,10 +141,59 @@ export const connectSocket = () => {
updateDevTools(serverPayload); updateDevTools(serverPayload);
break; break;
} }
case 'ontime-patch': { case 'ontime-clock': {
const patch = payload as Partial<RuntimeStore>; addToBatchUpdates('clock', payload);
patchRuntime(patch); updateDevTools({ clock: payload });
updateDevTools(patch); break;
}
case 'ontime-timer': {
addToBatchUpdates('timer', payload);
updateDevTools({ timer: payload });
break;
}
case 'ontime-onAir': {
addToBatchUpdates('onAir', payload);
updateDevTools({ onAir: payload });
break;
}
case 'ontime-message': {
addToBatchUpdates('message', payload);
updateDevTools({ message: payload });
break;
}
case 'ontime-runtime': {
addToBatchUpdates('runtime', payload);
updateDevTools({ runtime: payload });
break;
}
case 'ontime-eventNow': {
addToBatchUpdates('eventNow', payload);
updateDevTools({ eventNow: payload });
break;
}
case 'ontime-currentBlock': {
addToBatchUpdates('currentBlock', payload);
updateDevTools({ currentBlock: payload });
break;
}
case 'ontime-publicEventNow': {
addToBatchUpdates('publicEventNow', payload);
updateDevTools({ publicEventNow: payload });
break;
}
case 'ontime-eventNext': {
addToBatchUpdates('eventNext', payload);
updateDevTools({ eventNext: payload });
break;
}
case 'ontime-publicEventNext': {
addToBatchUpdates('publicEventNext', payload);
updateDevTools({ publicEventNext: payload });
break;
}
case 'ontime-auxtimer1': {
addToBatchUpdates('auxtimer1', payload);
updateDevTools({ auxtimer1: payload });
break; break;
} }
case 'ontime-refetch': { case 'ontime-refetch': {
@@ -154,7 +203,7 @@ export const connectSocket = () => {
invalidateAllCaches(); invalidateAllCaches();
} else if (target === 'RUNDOWN') { } else if (target === 'RUNDOWN') {
const { revision } = payload; const { revision } = payload;
const currentRevision = ontimeQueryClient.getQueryData<Rundown>(RUNDOWN)?.revision ?? -1; const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
if (revision > currentRevision) { if (revision > currentRevision) {
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS }); ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
@@ -164,8 +213,8 @@ export const connectSocket = () => {
} }
break; break;
} }
default: { case 'ontime-flush': {
console.log('unknown WS message', type); flushBatchUpdates();
break; break;
} }
} }
@@ -175,6 +224,11 @@ export const connectSocket = () => {
}; };
}; };
export const disconnectSocket = () => {
shouldReconnect = false;
websocket?.close();
};
export const socketSend = (message: any) => { export const socketSend = (message: any) => {
if (websocket && websocket.readyState === WebSocket.OPEN) { if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(message); websocket.send(message);
+3 -90
View File
@@ -1,9 +1,8 @@
import { MaybeNumber, OffsetMode, OntimeEvent, Settings, TimeFormat } from 'ontime-types'; import { MaybeNumber, Settings, TimeFormat } from 'ontime-types';
import { dayInMs, formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils'; import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig'; import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
import { APP_SETTINGS } from '../api/constants'; import { APP_SETTINGS } from '../api/constants';
import { useTimeUntilData } from '../hooks/useSocket';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
/** /**
@@ -35,7 +34,7 @@ function getFormatFromParams() {
* Gets the format options from the applicaton settings * Gets the format options from the applicaton settings
* @returns a string equivalent to the format, ie: hh:mm:ss a or HH:mm:ss * @returns a string equivalent to the format, ie: hh:mm:ss a or HH:mm:ss
*/ */
function getFormatFromSettings(): TimeFormat { export function getFormatFromSettings(): TimeFormat {
const settings: Settings | undefined = ontimeQueryClient.getQueryData(APP_SETTINGS); const settings: Settings | undefined = ontimeQueryClient.getQueryData(APP_SETTINGS);
return settings?.timeFormat ?? '24'; return settings?.timeFormat ?? '24';
} }
@@ -126,89 +125,3 @@ export function formatDuration(duration: number, hideSeconds = true): string {
} }
return result; return result;
} }
/**
*
* @param totalGap accumulated gap from the current event
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
* @returns
*/
export function useTimeUntilStart(
// typed like this to make it very clear what the data is
data: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> & {
totalGap: number;
isLinkedToLoaded: boolean;
},
): number {
const { offset, clock, currentDay, offsetMode, actualStart, plannedStart } = useTimeUntilData();
return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart });
}
/**
*
* @param currentDay the day offset of the urrently running event
* @param totalGap accumulated gap from the current event
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
* @param clock
* @param offset
* @returns
*/
export function calculateTimeUntilStart(
data: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> & {
currentDay: number;
totalGap: number;
isLinkedToLoaded: boolean;
clock: number;
offset: number;
offsetMode: OffsetMode;
actualStart: MaybeNumber;
plannedStart: MaybeNumber;
},
): number {
const {
timeStart,
dayOffset,
currentDay,
totalGap,
isLinkedToLoaded,
clock,
offset,
delay,
offsetMode,
actualStart,
plannedStart,
} = data;
//How many days from the currently running event to this one
const relativeDayOffset = dayOffset - currentDay;
const delayedStart = Math.max(0, timeStart + delay);
//The normalised start time of this event relative to the currently running event
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
let relativeStartOffset = 0;
if (offsetMode === OffsetMode.Relative) {
relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0);
}
const scheduledTimeUntil = normalisedTimeStart - clock + relativeStartOffset;
const offsetTimeUntil = scheduledTimeUntil - offset;
if (isLinkedToLoaded) {
//if we are directly linked back to the loaded event we just follow the offset
return offsetTimeUntil;
}
const gapsCanCompensateForOffset = totalGap + offset >= 0;
if (gapsCanCompensateForOffset) {
// if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time
return scheduledTimeUntil;
}
// otherwise consume as much of the offset as possible with the gap
const offsetTimeUntilBufferedByGaps = offsetTimeUntil - totalGap;
return offsetTimeUntilBufferedByGaps;
}
+11
View File
@@ -0,0 +1,11 @@
import { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers';
import 'vitest';
// ugly hack because vite and pnpm are not playing ball with jest
// https://github.com/testing-library/jest-dom/issues/123
declare global {
namespace Vi {
type Assertion<T = any> = TestingLibraryMatchers<T, void>;
}
}
+2 -3
View File
@@ -19,10 +19,9 @@ export const buyMeACoffeeUrl = 'https://buymeacoffee.com/cpvalente';
export const appVersion = version; export const appVersion = version;
export const isProduction = import.meta.env.MODE === 'production'; export const isProduction = import.meta.env.MODE === 'production';
export const isDev = !isProduction; export const isDev = !isProduction;
export const currentHostName = window.location.hostname; export const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1';
export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER); export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
export const isOntimeCloud = currentHostName.includes('cloud.getontime.no'); export const isOntimeCloud = window.location.hostname.includes('cloud.getontime.no');
// resolve entrypoint URLs // resolve entrypoint URLs
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { IoClose } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import style from './PanelContent.module.scss'; import style from './PanelContent.module.scss';
@@ -207,9 +207,6 @@ $inner-padding: 1rem;
&.end { &.end {
justify-content: flex-end; justify-content: flex-end;
} }
&.apart {
justify-content: space-between;
}
} }
@keyframes animloader { @keyframes animloader {
@@ -1,6 +1,6 @@
import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react'; import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
@@ -113,8 +113,8 @@ export function BlockQuote({ children }: { children: ReactNode }) {
return <blockquote className={style.blockquote}>{children}</blockquote>; return <blockquote className={style.blockquote}>{children}</blockquote>;
} }
export function Error({ children, className }: { children: ReactNode } & JSX.IntrinsicElements['div']) { export function Error({ children }: { children: ReactNode }) {
return <div className={cx([style.fieldError, className])}>{children}</div>; return <div className={style.fieldError}>{children}</div>;
} }
export function Divider() { export function Divider() {
@@ -136,7 +136,7 @@ type AllowedInlineTags = 'div' | 'td';
type InlineProps<C extends AllowedInlineTags> = { type InlineProps<C extends AllowedInlineTags> = {
as?: C; as?: C;
relation?: 'inner' | 'component' | 'section'; relation?: 'inner' | 'component' | 'section';
align?: 'start' | 'end' | 'apart'; align?: 'start' | 'end';
className?: string; className?: string;
}; };
@@ -1,7 +1,8 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5';
import { Button, IconButton, Input, Radio, RadioGroup, Select } from '@chakra-ui/react'; import { Button, IconButton, Input, Radio, RadioGroup, Select } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { import {
Automation, Automation,
AutomationDTO, AutomationDTO,
@@ -30,7 +31,7 @@ import OntimeActionForm from './OntimeActionForm';
import style from './AutomationForm.module.scss'; import style from './AutomationForm.module.scss';
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation'; const integrationsDocsUrl = 'https://docs.getontime.no/api/integrations/#using-variables-in-integrations';
interface AutomationFormProps { interface AutomationFormProps {
automation: Automation | AutomationDTO; automation: Automation | AutomationDTO;
@@ -1,6 +1,8 @@
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
import { Button, IconButton } from '@chakra-ui/react'; import { Button, IconButton } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { AutomationDTO, NormalisedAutomation } from 'ontime-types'; import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
import { deleteAutomation } from '../../../../common/api/automation'; import { deleteAutomation } from '../../../../common/api/automation';
@@ -1,6 +1,6 @@
import { Fragment, useMemo, useState } from 'react'; import { Fragment, useMemo, useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { NormalisedAutomation, Trigger } from 'ontime-types'; import { NormalisedAutomation, Trigger } from 'ontime-types';
import { deleteTrigger } from '../../../../common/api/automation'; import { deleteTrigger } from '../../../../common/api/automation';
@@ -1,6 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react'; import { IconButton } from '@chakra-ui/react';
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types'; import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { IoTrashBin } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { IoTrashBin } from '@react-icons/all-files/io5/IoTrashBin';
import { deleteAllReport } from '../../../../common/api/report'; import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils'; import { createBlob, downloadBlob } from '../../../../common/api/utils';
@@ -29,8 +29,8 @@ export default function ReportSettings() {
}; };
const combinedReport = useMemo(() => { const combinedReport = useMemo(() => {
return getCombinedReport(reportData, data.entries, data.order); return getCombinedReport(reportData, data.rundown, data.order);
}, [reportData, data.entries, data.order]); }, [reportData, data.rundown, data.order]);
return ( return (
<Panel.Section> <Panel.Section>
@@ -1,7 +1,9 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoOpenOutline, IoTrash } from 'react-icons/io5';
import { Button, IconButton, Input, Switch } from '@chakra-ui/react'; import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { URLPreset } from 'ontime-types'; import { URLPreset } from 'ontime-types';
import { postUrlPresets } from '../../../../common/api/urlPresets'; import { postUrlPresets } from '../../../../common/api/urlPresets';
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoPencil, IoTrash } from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react'; import { IconButton } from '@chakra-ui/react';
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { CustomField, CustomFieldLabel } from 'ontime-types'; import { CustomField, CustomFieldLabel } from 'ontime-types';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag'; import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { CustomField, CustomFieldLabel } from 'ontime-types'; import { CustomField, CustomFieldLabel } from 'ontime-types';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields'; import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
@@ -1,4 +1,4 @@
import { EntryId, isOntimeEvent, MaybeNumber, OntimeReport, RundownEntries } from 'ontime-types'; import { isOntimeEvent, MaybeNumber, NormalisedRundown, OntimeReport } from 'ontime-types';
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv'; import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
import { formatTime } from '../../../../common/utils/time'; import { formatTime } from '../../../../common/utils/time';
@@ -16,7 +16,7 @@ export type CombinedReport = {
/** /**
* Creates a combined report with the rundown data * Creates a combined report with the rundown data
*/ */
export function getCombinedReport(report: OntimeReport, rundown: RundownEntries, order: EntryId[]): CombinedReport[] { export function getCombinedReport(report: OntimeReport, rundown: NormalisedRundown, order: string[]): CombinedReport[] {
if (Object.keys(report).length === 0) return []; if (Object.keys(report).length === 0) return [];
if (order.length === 0) return []; if (order.length === 0) return [];
@@ -13,6 +13,10 @@ import * as Panel from '../../panel-utils/PanelUtils';
import GeneralPinInput from './GeneralPinInput'; import GeneralPinInput from './GeneralPinInput';
export type GeneralPanelFormValues = {
filename: string;
};
export default function GeneralPanelForm() { export default function GeneralPanelForm() {
const { data, status, refetch } = useSettings(); const { data, status, refetch } = useSettings();
const { const {
@@ -1,7 +1,7 @@
import { PropsWithChildren, useState } from 'react'; import { PropsWithChildren, useState } from 'react';
import { UseFormRegister } from 'react-hook-form'; import { UseFormRegister } from 'react-hook-form';
import { IoEyeOutline } from 'react-icons/io5';
import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/react'; import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/react';
import { IoEyeOutline } from '@react-icons/all-files/io5/IoEyeOutline';
import { Settings } from 'ontime-types'; import { Settings } from 'ontime-types';
import { isAlphanumeric } from '../../../../common/utils/regex'; import { isAlphanumeric } from '../../../../common/utils/regex';
@@ -1,4 +0,0 @@
.wrapper {
max-height: 500px;
overflow-y: auto;
}
@@ -1,72 +0,0 @@
import { forwardRef, memo, useEffect, useImperativeHandle, useState } from 'react';
import Editor from 'react-simple-code-editor';
import Prism from 'prismjs/components/prism-core';
import 'prismjs/components/prism-css';
import 'prismjs/themes/prism-tomorrow.min.css';
import style from './StyleEditor.module.scss';
interface CodeEditorProps {
language: string;
initialValue: string;
isDirty: boolean;
setIsDirty: (value: boolean) => void;
}
const CodeEditor = forwardRef((props: CodeEditorProps, cssRef) => {
const { language, initialValue, isDirty, setIsDirty } = props;
const [code, setCode] = useState(initialValue);
const highlight = (code: string) => {
const grammar = Prism.languages[language];
return grammar ? Prism.highlight(code, grammar, language) : code;
};
const handleChange = (newCode: string) => {
setCode(newCode);
};
useImperativeHandle(cssRef, () => {
return {
getCss: () => code,
};
});
// add contents to editor on mount and any change in initialValue
useEffect(() => {
setCode(initialValue);
}, [initialValue]);
// handle dirty state on change
useEffect(() => {
if (initialValue.trim() !== code.trim() && !isDirty && code.length !== 0) {
setIsDirty(true);
}
if (initialValue.trim() === code.trim() && isDirty) {
setIsDirty(false);
}
}, [initialValue, code, isDirty, setIsDirty]);
return (
<div className={style.wrapper}>
<Editor
value={code}
padding={15}
onValueChange={handleChange}
highlight={highlight}
style={{
fontFamily: 'monospace',
fontSize: 12,
minHeight: 500,
background: '#2d2d2d', // Background of tomorrow theme
}}
/>
</div>
);
});
CodeEditor.displayName = 'StyleEditor';
export default memo(CodeEditor);
@@ -1,14 +0,0 @@
.editorActions {
width: 100%;
}
.right {
align-self: end;
text-align: right
}
.column {
align-items: start;
display: flex;
flex-direction: column;
}
@@ -1,129 +0,0 @@
import { lazy, useEffect, useRef, useState } from 'react';
import {
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../common/api/assets';
import Info from '../../../../common/components/info/Info';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './StyleEditorModal.module.scss';
const CodeEditor = lazy(() => import('./StyleEditor'));
interface CodeEditorModalProps {
isOpen: boolean;
onClose: () => void;
}
interface CSSRef {
getCss: () => string;
}
export default function CodeEditorModal(props: CodeEditorModalProps) {
const { isOpen, onClose } = props;
const [css, setCSS] = useState('');
const [isDirty, setIsDirty] = useState(false);
const [saveLoading, setSaveLoading] = useState(false);
const [resetLoading, setResetLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const cssRef = useRef<CSSRef>(null);
const handleRestore = async () => {
try {
setResetLoading(true);
const defaultCss = await restoreCSSContents();
setCSS(defaultCss);
} catch (_error) {
/** no error handling for now */
} finally {
setResetLoading(false);
}
};
const handleSave = async () => {
try {
setSaveLoading(true);
if (cssRef.current) {
await postCSSContents(cssRef.current.getCss());
setCSS(cssRef.current.getCss());
setIsDirty(false);
}
} catch (_error) {
/** no error handling for now */
} finally {
setSaveLoading(false);
}
};
const clear = () => setCSS('');
useEffect(() => {
async function fetchServerCSS() {
// check for isOpen to fetch recent css
if (isOpen) {
try {
const css = await getCSSContents();
setCSS(css);
} catch (_error) {
setError('Failed to load CSS from server');
/** no error handling for now */
}
}
}
fetchServerCSS();
}, [isOpen]);
return (
<Modal isOpen={isOpen} onClose={onClose} variant='ontime' isCentered>
<ModalOverlay />
<ModalContent maxWidth='max(800px, 40vw)'>
<ModalHeader>Edit CSS override</ModalHeader>
<ModalCloseButton />
<ModalBody>
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
</ModalBody>
<ModalFooter className={style.column}>
<Info>Invalid CSS will be refused by the browser</Info>
{error && <Panel.Error className={style.right}>{`Error: ${error}`}</Panel.Error>}
<Panel.InlineElements align='apart' className={style.editorActions}>
<Button
variant='ontime-ghosted'
onClick={handleRestore}
isDisabled={saveLoading || resetLoading}
isLoading={resetLoading}
>
Reset to example
</Button>
<Panel.InlineElements>
<Button variant='ontime-ghosted' onClick={clear}>
Clear
</Button>
<Button variant='ontime-subtle' onClick={onClose}>
Cancel
</Button>
<Button
variant='ontime-filled'
onClick={handleSave}
isDisabled={saveLoading || resetLoading || !isDirty}
isLoading={saveLoading}
>
Save changes
</Button>
</Panel.InlineElements>
</Panel.InlineElements>
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -1,6 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { Button, Input, Switch, useDisclosure } from '@chakra-ui/react'; import { Button, Input, Switch } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types'; import { ViewSettings } from 'ontime-types';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
@@ -14,14 +14,11 @@ import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOntimeCloud } from '../../../../externals'; import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import CodeEditorModal from './StyleEditorModal';
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/'; const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
export default function ViewSettingsForm() { export default function ViewSettingsForm() {
const { data, status, refetch } = useViewSettings(); const { data, status, refetch } = useViewSettings();
const { data: info, status: infoStatus } = useInfo(); const { data: info, status: infoStatus } = useInfo();
const { isOpen: isCodeEditorOpen, onOpen: onCodeEditorOpen, onClose: onCodeEditorClose } = useDisclosure();
const { const {
control, control,
@@ -106,7 +103,6 @@ export default function ViewSettingsForm() {
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={isLoading} /> <Panel.Loader isLoading={isLoading} />
<Panel.ListGroup> <Panel.ListGroup>
<CodeEditorModal isOpen={isCodeEditorOpen} onClose={onCodeEditorClose} />
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Override CSS styles' title='Override CSS styles'
@@ -119,15 +115,6 @@ export default function ViewSettingsForm() {
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} /> <Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)} )}
/> />
<Button
onClick={onCodeEditorOpen}
variant='ontime-subtle'
size='sm'
isDisabled={!data.overrideStyles}
width='fit-content'
>
Edit CSS override
</Button>
</Panel.ListItem> </Panel.ListItem>
</Panel.ListGroup> </Panel.ListGroup>
<Panel.ListGroup> <Panel.ListGroup>
@@ -1,5 +0,0 @@
declare module 'prismjs/components/prism-core' {
export * from 'prismjs';
}
declare module 'prismjs/components/prism-css';
@@ -102,6 +102,7 @@ export default function EditorSettingsForm() {
onChange={(event) => setDefaultEndAction(event.target.value as EndAction)} onChange={(event) => setDefaultEndAction(event.target.value as EndAction)}
> >
<option value={EndAction.None}>None</option> <option value={EndAction.None}>None</option>
<option value={EndAction.Stop}>Stop</option>
<option value={EndAction.LoadNext}>Load next</option> <option value={EndAction.LoadNext}>Load next</option>
<option value={EndAction.PlayNext}>Play next</option> <option value={EndAction.PlayNext}>Play next</option>
</Select> </Select>
@@ -3,12 +3,3 @@
background: $ui-white; background: $ui-white;
border-radius: 3px; border-radius: 3px;
} }
.column {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
color: $label-gray;
user-select: text;
}
@@ -6,12 +6,13 @@ import { Button, Select, Switch } from '@chakra-ui/react';
import { generateUrl } from '../../../../common/api/session'; import { generateUrl } from '../../../../common/api/session';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useInfo from '../../../../common/hooks-query/useInfo'; import useInfo from '../../../../common/hooks-query/useInfo';
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets'; import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import copyToClipboard from '../../../../common/utils/copyToClipboard'; import copyToClipboard from '../../../../common/utils/copyToClipboard';
import { preventEscape } from '../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../common/utils/keyEvent';
import { linkToOtherHost } from '../../../../common/utils/linkUtils'; import { linkToOtherHost } from '../../../../common/utils/linkUtils';
import { currentHostName, isOntimeCloud, serverURL } from '../../../../externals'; import { serverURL } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './GenerateLinkForm.module.scss'; import style from './GenerateLinkForm.module.scss';
@@ -38,12 +39,6 @@ export default function GenerateLinkForm() {
formState: { errors }, formState: { errors },
} = useForm<GenerateLinkFormOptions>({ } = useForm<GenerateLinkFormOptions>({
mode: 'onChange', mode: 'onChange',
defaultValues: {
baseUrl: currentHostName,
path: '',
lock: false,
authenticate: false,
},
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
}, },
@@ -77,11 +72,8 @@ export default function GenerateLinkForm() {
</Info> </Info>
<Panel.ListGroup> <Panel.ListGroup>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field title='Host IP' description='Which IP address will be used' />
title='Host IP' <Select variant='ontime' size='sm' {...register('baseUrl')}>
description={`Which IP address will be used${isOntimeCloud ? ' (not applicable in Ontime Cloud)' : ''}`}
/>
<Select variant='ontime' isDisabled={isOntimeCloud} size='sm' {...register('baseUrl')}>
{infoData.networkInterfaces.map((nif) => { {infoData.networkInterfaces.map((nif) => {
return ( return (
<option key={nif.name} value={nif.address}> <option key={nif.name} value={nif.address}>
@@ -136,9 +128,9 @@ export default function GenerateLinkForm() {
> >
{formState === 'success' ? 'Link copied to clipboard!' : 'Update share link'} {formState === 'success' ? 'Link copied to clipboard!' : 'Update share link'}
</Button> </Button>
<div className={style.column}> <div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<QRCode size={172} value={url} className={style.qrCode} /> <QRCode size={172} value={url} className={style.qrCode} />
<div>{url}</div> <ExternalLink href={url}>{url}</ExternalLink>
</div> </div>
</Panel.ListItem> </Panel.ListItem>
</Panel.ListGroup> </Panel.ListGroup>
@@ -1,4 +1,4 @@
import { IoArrowUp } from 'react-icons/io5'; import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import CopyTag from '../../../../common/components/copy-tag/CopyTag'; import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import useInfo from '../../../../common/hooks-query/useInfo'; import useInfo from '../../../../common/hooks-query/useInfo';
@@ -1,6 +1,6 @@
import { MouseEvent } from 'react'; import { MouseEvent } from 'react';
import { IoArrowUp } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { handleLinks } from '../../../../common/utils/linkUtils'; import { handleLinks } from '../../../../common/utils/linkUtils';
import Log from '../../../log/Log'; import Log from '../../../log/Log';
@@ -3,7 +3,7 @@ import { useEffect } from 'react';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { usePing } from '../../../../common/hooks/useSocket'; import { usePing } from '../../../../common/hooks/useSocket';
import { socketSendJson } from '../../../../common/utils/socket'; import { socketSendJson } from '../../../../common/utils/socket';
import { isDockerImage, isOntimeCloud } from '../../../../externals'; import { isDockerImage } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList'; import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import ClientControlPanel from '../client-control-panel/ClientControlPanel'; import ClientControlPanel from '../client-control-panel/ClientControlPanel';
@@ -30,12 +30,8 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
<Panel.Card> <Panel.Card>
<Panel.SubHeader>Share Ontime Link</Panel.SubHeader> <Panel.SubHeader>Share Ontime Link</Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
{!isOntimeCloud && (
<>
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph> <Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
<InfoNif /> <InfoNif />
</>
)}
<GenerateLinkForm /> <GenerateLinkForm />
</Panel.Card> </Panel.Card>
</Panel.Section> </Panel.Section>
@@ -1,7 +1,7 @@
import { ChangeEvent, useRef, useState } from 'react'; import { ChangeEvent, useRef, useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Button, Input } from '@chakra-ui/react'; import { Button, Input } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { uploadProjectFile } from '../../../../common/api/db'; import { uploadProjectFile } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils'; import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
@@ -1,7 +1,8 @@
import { ChangeEvent, useEffect, useRef } from 'react'; import { ChangeEvent, useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { IoDownloadOutline, IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react'; import { Button, Input, Textarea } from '@chakra-ui/react';
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { type ProjectData } from 'ontime-types'; import { type ProjectData } from 'ontime-types';
import { projectLogoPath } from '../../../../common/api/constants'; import { projectLogoPath } from '../../../../common/api/constants';
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react'; import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
import { import {
deleteProject, deleteProject,
@@ -6,7 +6,7 @@ export async function makeProjectPatch(data: DatabaseModel, mergeKeys: Record<st
for (const key in mergeKeys) { for (const key in mergeKeys) {
if (isKeyOfType(key, data) && mergeKeys[key]) { if (isKeyOfType(key, data) && mergeKeys[key]) {
// if the rundown is merged we also need the custom fields // if the rundown is merged we also need the custom fields
if (key === 'rundowns') { if (key === 'rundown') {
patchObject.customFields = data['customFields']; patchObject.customFields = data['customFields'];
} }
Object.assign(patchObject, { [key]: data[key] }); Object.assign(patchObject, { [key]: data[key] });
@@ -1,6 +1,7 @@
import { ChangeEvent, useEffect, useState } from 'react'; import { ChangeEvent, useEffect, useState } from 'react';
import { IoCheckmark, IoShieldCheckmarkOutline } from 'react-icons/io5';
import { Button, Input, Spinner } from '@chakra-ui/react'; import { Button, Input, Spinner } from '@chakra-ui/react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoShieldCheckmarkOutline } from '@react-icons/all-files/io5/IoShieldCheckmarkOutline';
import { getWorksheetNames } from '../../../../common/api/sheets'; import { getWorksheetNames } from '../../../../common/api/sheets';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { CustomFields, Rundown } from 'ontime-types'; import { CustomFields, OntimeRundown } from 'ontime-types';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -9,7 +9,7 @@ import useGoogleSheet from './useGoogleSheet';
import { useSheetStore } from './useSheetStore'; import { useSheetStore } from './useSheetStore';
interface ImportReviewProps { interface ImportReviewProps {
rundown: Rundown; rundown: OntimeRundown;
customFields: CustomFields; customFields: CustomFields;
onFinished: () => void; onFinished: () => void;
onCancel: () => void; onCancel: () => void;
@@ -29,12 +29,7 @@ export default function ImportReview(props: ImportReviewProps) {
const applyImport = async () => { const applyImport = async () => {
setLoading(true); setLoading(true);
await importRundown( await importRundown(rundown, customFields);
{
[rundown.id]: rundown,
},
customFields,
);
setLoading(false); setLoading(false);
onFinished(); onFinished();
}; };
@@ -1,6 +1,7 @@
import { ChangeEvent, useRef, useState } from 'react'; import { ChangeEvent, useRef, useState } from 'react';
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
import { Button, Input } from '@chakra-ui/react'; import { Button, Input } from '@chakra-ui/react';
import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
import { getErrorMessage, ImportMap } from 'ontime-utils'; import { getErrorMessage, ImportMap } from 'ontime-utils';
import { import {
@@ -103,7 +104,6 @@ export default function SourcesPanel() {
}; };
const handleSubmitImportPreview = async (importMap: ImportMap) => { const handleSubmitImportPreview = async (importMap: ImportMap) => {
setError(''); // to clear previous error
if (importFlow === 'excel') { if (importFlow === 'excel') {
try { try {
const previewData = await importRundownPreviewExcel(importMap); const previewData = await importRundownPreviewExcel(importMap);
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5';
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react'; import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils'; import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
@@ -28,7 +29,6 @@ export default function ImportMapForm(props: ImportMapFormProps) {
control, control,
handleSubmit, handleSubmit,
register, register,
setValue,
formState: { errors, isValid }, formState: { errors, isValid },
} = useForm<NamedImportMap>({ } = useForm<NamedImportMap>({
mode: 'onChange', mode: 'onChange',
@@ -46,14 +46,6 @@ export default function ImportMapForm(props: ImportMapFormProps) {
const [loading, setLoading] = useState<'' | 'export' | 'import'>(''); const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
// Set first sheet as default worksheet when 'event schedule' sheet is not there
useEffect(() => {
if (!worksheetNames || worksheetNames.length === 0) return;
if (!worksheetNames.includes(namedImportMap.Worksheet)) {
setValue('Worksheet', worksheetNames[0], { shouldValidate: true, shouldDirty: true });
}
}, [worksheetNames, setValue, namedImportMap.Worksheet]);
const handleExport = async (values: NamedImportMap) => { const handleExport = async (values: NamedImportMap) => {
setLoading('export'); setLoading('export');
const importMap = convertToImportMap(values); const importMap = convertToImportMap(values);
@@ -3,7 +3,7 @@ import { ImportCustom, ImportMap } from 'ontime-utils';
export type NamedImportMap = typeof namedImportMap; export type NamedImportMap = typeof namedImportMap;
// Record of label and import name // Record of label and import name
const namedImportMap = { export const namedImportMap = {
Worksheet: 'event schedule', Worksheet: 'event schedule',
Start: 'time start', Start: 'time start',
'Link start': 'link start', 'Link start': 'link start',
@@ -1,6 +1,6 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { IoLink } from 'react-icons/io5'; import { IoLink } from '@react-icons/all-files/io5/IoLink';
import { CustomFields, isOntimeBlock, isOntimeEvent, Rundown } from 'ontime-types'; import { CustomFields, isOntimeBlock, isOntimeEvent, OntimeRundown } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import Tag from '../../../../../common/components/tag/Tag'; import Tag from '../../../../../common/components/tag/Tag';
@@ -10,7 +10,7 @@ import * as Panel from '../../../panel-utils/PanelUtils';
import style from './PreviewRundown.module.scss'; import style from './PreviewRundown.module.scss';
interface PreviewRundownProps { interface PreviewRundownProps {
rundown: Rundown; rundown: OntimeRundown;
customFields: CustomFields; customFields: CustomFields;
} }
@@ -53,76 +53,75 @@ export default function PreviewRundown(props: PreviewRundownProps) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rundown.order.map((entryId) => { {rundown.map((event) => {
const entry = rundown.entries[entryId]; if (isOntimeBlock(event)) {
if (isOntimeBlock(entry)) {
return ( return (
<tr key={entry.id}> <tr key={event.id}>
<td className={style.center}> <td className={style.center}>
<Tag>-</Tag> <Tag>-</Tag>
</td> </td>
<td className={style.center}> <td className={style.center}>
<Tag>{entry.type}</Tag> <Tag>{event.type}</Tag>
</td> </td>
<td /> <td />
<td colSpan={99}>{entry.title}</td> <td colSpan={99}>{event.title}</td>
</tr> </tr>
); );
} }
if (!isOntimeEvent(entry)) { if (!isOntimeEvent(event)) {
return null; return null;
} }
eventIndex += 1; eventIndex += 1;
const colour = entry.colour ? getAccessibleColour(entry.colour) : {}; const colour = event.colour ? getAccessibleColour(event.colour) : {};
const countToEnd = booleanToText(entry.countToEnd); const countToEnd = booleanToText(event.countToEnd);
const isPublic = booleanToText(entry.isPublic); const isPublic = booleanToText(event.isPublic);
const skip = booleanToText(entry.skip); const skip = booleanToText(event.skip);
return ( return (
<Fragment key={entry.id}> <Fragment key={event.id}>
<tr> <tr>
<td className={style.center}> <td className={style.center}>
<Tag>{eventIndex}</Tag> <Tag>{eventIndex}</Tag>
</td> </td>
<td className={style.center}> <td className={style.center}>
<Tag>{entry.type}</Tag> <Tag>{event.type}</Tag>
</td> </td>
<td className={style.nowrap}>{entry.cue}</td> <td className={style.nowrap}>{event.cue}</td>
<td>{entry.title}</td> <td>{event.title}</td>
<td className={style.flex}> <td className={style.flex}>
<span className={entry.linkStart ? style.subdued : undefined}>{millisToString(entry.timeStart)}</span> <span className={event.linkStart ? style.subdued : undefined}>{millisToString(event.timeStart)}</span>
{entry.linkStart && <IoLink className={style.linkStartActive} />} {event.linkStart && <IoLink className={style.linkStartActive} />}
</td> </td>
<td>{millisToString(entry.timeEnd)}</td> <td>{millisToString(event.timeEnd)}</td>
<td>{millisToString(entry.duration)}</td> <td>{millisToString(event.duration)}</td>
<td>{millisToString(entry.timeWarning)}</td> <td>{millisToString(event.timeWarning)}</td>
<td>{millisToString(entry.timeDanger)}</td> <td>{millisToString(event.timeDanger)}</td>
<td className={style.center}>{countToEnd && <Tag>{countToEnd}</Tag>}</td> <td className={style.center}>{countToEnd && <Tag>{countToEnd}</Tag>}</td>
<td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td> <td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td>
<td>{skip && <Tag>{skip}</Tag>}</td> <td>{skip && <Tag>{skip}</Tag>}</td>
<td style={{ ...colour }}>{entry.colour}</td> <td style={{ ...colour }}>{event.colour}</td>
<td className={style.center}> <td className={style.center}>
<Tag>{entry.timerType}</Tag> <Tag>{event.timerType}</Tag>
</td> </td>
<td className={style.center}> <td className={style.center}>
<Tag>{entry.endAction}</Tag> <Tag>{event.endAction}</Tag>
</td> </td>
{isOntimeEvent(entry) && {isOntimeEvent(event) &&
fieldKeys.map((field) => { fieldKeys.map((field) => {
let value = ''; let value = '';
if (field in entry.custom) { if (field in event.custom) {
value = entry.custom[field]; value = event.custom[field];
} }
return <td key={field}>{value}</td>; return <td key={field}>{value}</td>;
})} })}
<td className={style.center}> <td className={style.center}>
<Tag>{entry.id}</Tag> <Tag>{event.id}</Tag>
</td> </td>
</tr> </tr>
{entry.note && ( {event.note && (
<tr> <tr>
<td colSpan={99} className={style.secondaryRow}> <td colSpan={99} className={style.secondaryRow}>
Note: {entry.note} Note: {event.note}
</td> </td>
</tr> </tr>
)} )}

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