Compare commits

...

49 Commits

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

* loadProject should not generate new names

* update comments

* extract and test getProjectNumber

* spell

* finish jsdoc

* use getProjectNumber

* cleanup loadProject

* create a `incrementProjectNumber` function

* spelling

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

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-05-14 20:07:42 +02:00
Alex Christoffer Rasmussen bdcec7a472 Refactor: WebSocket from flush queue to one patch (#1595)
* change flush to one patch

* remove unused types

* create batch

* merge patch into eventStore

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-14 20:07:42 +02:00
Alex Christoffer Rasmussen 0a596624d5 Refactor: ms for api calls (#1593)
* use ms for api calls

* change it in UI

* update comment

* rename variables

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

* add comment
2025-05-14 20:07:42 +02:00
Carlos Valente 92fbef3515 fix: rebase master 2025-05-14 20:07:42 +02:00
Carlos Valente 10c2f0cf6c chore: correct test path 2025-05-14 20:07:42 +02:00
Carlos Valente e30b4fa8d5 refactor: extract rundown parsing
refactor: implement groups in editor
2025-05-14 20:07:42 +02:00
Carlos Valente 52358d75d3 chore: improve convention entry <> event 2025-05-14 20:07:42 +02:00
Carlos Valente a55dfba4ef refactor: maintain flat orders 2025-05-14 20:07:42 +02:00
Carlos Valente ed7f8a0aa2 refactor: implement operations on nested events 2025-05-14 20:07:42 +02:00
Carlos Valente ba1292141b refactor: process events in rundown 2025-05-14 20:07:42 +02:00
Carlos Valente 57bd317dcf chore: improve convention entry <> event 2025-05-14 20:07:42 +02:00
Carlos Valente 0cae37d6c8 chore: rename currentBlock > parent 2025-05-14 20:07:42 +02:00
Carlos Valente 6c8ddeec23 refactor: fix delay positioning in gaps 2025-05-14 20:07:42 +02:00
Carlos Valente 3f3ecce7b2 refactor(e2e): skip flaky test 2025-05-14 20:07:42 +02:00
Carlos Valente 402d10055b refactor: improve project loading 2025-05-14 20:07:42 +02:00
Carlos Valente 8f5a4f4a0a refactor: gather group metadata 2025-05-14 20:07:42 +02:00
Carlos Valente 7a78ed4720 refactor: swap maintains schedule 2025-05-14 20:07:42 +02:00
Carlos Valente de0ac92052 chore: rename files 2025-05-14 20:07:42 +02:00
Carlos Valente 58023dce0d refactor: restructure model to contain an object of rundowns 2025-05-14 20:07:42 +02:00
Carlos Valente 0ae7178046 refactor: clearer relationship on rundown elements 2025-05-14 20:07:42 +02:00
Carlos Valente 6b01338118 refactor: use strict typing 2025-05-14 20:07:42 +02:00
Carlos Valente 7287cf8511 refactor: remove stop as a possible end action 2025-05-14 20:07:11 +02:00
Carlos Valente 6d17f188a6 refactor: restructure model to contain an object of rundowns 2025-05-14 20:07:11 +02:00
Carlos Valente 440a9b81b6 chore: remove IDE files 2025-05-14 19:58:30 +02:00
Carlos Valente 5fafa08723 refactor: remove unused and legacy code
- remove legacy migrations
- remove unused server code
- remove unused UI code
2025-05-14 19:58:30 +02:00
238 changed files with 8967 additions and 7737 deletions
-5
View File
@@ -1,5 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
-51
View File
@@ -1,51 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="CssUnknownProperty" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myCustomPropertiesEnabled" value="true" />
<option name="myIgnoreVendorSpecificProperties" value="false" />
<option name="myCustomPropertiesList">
<value>
<list size="3">
<item index="0" class="java.lang.String" itemvalue="scrollbar-color" />
<item index="1" class="java.lang.String" itemvalue="scrollbar-width" />
<item index="2" class="java.lang.String" itemvalue="app-region" />
</list>
</value>
</option>
</inspection_tool>
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredUrls">
<list>
<option value="http://localhost" />
<option value="http://127.0.0.1" />
<option value="http://0.0.0.0" />
<option value="http://www.w3.org/" />
<option value="http://json-schema.org/draft" />
<option value="http://java.sun.com/" />
<option value="http://xmlns.jcp.org/" />
<option value="http://javafx.com/javafx/" />
<option value="http://javafx.com/fxml" />
<option value="http://maven.apache.org/xsd/" />
<option value="http://maven.apache.org/POM/" />
<option value="http://www.springframework.org/schema/" />
<option value="http://www.springframework.org/tags" />
<option value="http://www.springframework.org/security/tags" />
<option value="http://www.thymeleaf.org" />
<option value="http://www.jboss.org/j2ee/schema/" />
<option value="http://www.jboss.com/xml/ns/" />
<option value="http://www.ibm.com/webservices/xsd" />
<option value="http://activemq.apache.org/schema/" />
<option value="http://schema.cloudfoundry.org/spring/" />
<option value="http://schemas.xmlsoap.org/" />
<option value="http://cxf.apache.org/schemas/" />
<option value="http://primefaces.org/ui" />
<option value="http://tiles.apache.org/" />
<option value="http://__IP__" />
</list>
</option>
</inspection_tool>
<inspection_tool class="Stylelint" enabled="true" level="ERROR" enabled_by_default="true" />
</profile>
</component>
+4 -4
View File
@@ -5,21 +5,21 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@chakra-ui/react": "^2.7.0", "@chakra-ui/react": "^2.7.0",
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emotion/is-prop-valid": "^1.3.1", "@emotion/is-prop-valid": "^1.3.1",
"@emotion/react": "^11.10.6", "@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6", "@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28", "@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.13.3", "@mantine/hooks": "^7.17.2",
"@sentry/react": "^8.43.0", "@sentry/react": "^8.43.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-devtools": "^5.62.7", "@tanstack/react-query-devtools": "^5.62.7",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.2.0", "axios": "^1.9.0",
"color": "^4.2.3", "color": "^4.2.3",
"csv-stringify": "^6.4.5", "csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0", "framer-motion": "^10.10.0",
+2 -6
View File
@@ -10,7 +10,6 @@ export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList']; export const PROJECT_LIST = ['projectList'];
export const RUNDOWN = ['rundown']; export const RUNDOWN = ['rundown'];
export const RUNTIME = ['runtimeStore']; export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState'];
export const URL_PRESETS = ['urlpresets']; export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings']; export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList']; export const CLIENT_LIST = ['clientList'];
@@ -19,11 +18,8 @@ export const REPORT = ['report'];
// API URLs // API URLs
export const apiEntryUrl = `${serverURL}/data`; export const apiEntryUrl = `${serverURL}/data`;
export const projectDataURL = `${serverURL}/project`; const userAssetsPath = 'user';
export const rundownURL = `${serverURL}/events`; const cssOverridePath = 'styles/override.css';
export const ontimeURL = `${serverURL}/ontime`;
export const userAssetsPath = 'user';
export const cssOverridePath = 'styles/override.css';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`; export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`; export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
+5 -3
View File
@@ -2,7 +2,7 @@ import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeTable } from '../../views/cuesheet/cuesheet.utils'; import { makeTable } from '../../views/cuesheet/cuesheet.utils';
import { makeCSVFromArrayOfArrays } from '../utils/csv'; import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils'; import { createBlob, downloadBlob } from './utils';
@@ -40,9 +40,11 @@ export async function downloadProject(fileName: string) {
export async function downloadCSV(fileName: string = 'rundown') { export async function downloadCSV(fileName: string = 'rundown') {
try { try {
const { data, name } = await fileDownload(fileName); const { data, name } = await fileDownload(fileName);
const { project, rundown, customFields } = data; const { project, rundowns, customFields } = data;
const flatRundowns = aggregateRundowns(rundowns);
const sheetData = makeTable(project, flatRundowns, customFields);
const sheetData = makeTable(project, rundown, customFields);
const fileContent = makeCSVFromArrayOfArrays(sheetData); const fileContent = makeCSVFromArrayOfArrays(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;'); const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
+5 -6
View File
@@ -1,16 +1,11 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { CustomFields, OntimeRundown } from 'ontime-types'; import { CustomFields, Rundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
const excelPath = `${apiEntryUrl}/excel`; const excelPath = `${apiEntryUrl}/excel`;
type PreviewSpreadsheetResponse = {
rundown: OntimeRundown;
customFields: CustomFields;
};
/** /**
* upload Excel file to server * upload Excel file to server
* @return string - file ID op the uploaded file * @return string - file ID op the uploaded file
@@ -34,6 +29,10 @@ export async function getWorksheetNames(): Promise<string[]> {
return response.data; return response.data;
} }
type PreviewSpreadsheetResponse = {
rundown: Rundown;
customFields: CustomFields;
};
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> { export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, { const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
options, options,
+1 -1
View File
@@ -11,7 +11,7 @@ export type HasUpdate = {
* HTTP request to get the latest version and url from github * HTTP request to get the latest version and url from github
*/ */
export async function getLatestVersion(): Promise<HasUpdate> { export async function getLatestVersion(): Promise<HasUpdate> {
const res = await axios.get(`${apiRepoLatest}`); const res = await axios.get(apiRepoLatest);
return { return {
url: res.data.html_url as string, url: res.data.html_url as string,
version: res.data.tag_name as string, version: res.data.tag_name as string,
+1 -1
View File
@@ -11,7 +11,7 @@ export const reportUrl = `${apiEntryUrl}/report`;
* HTTP request to fetch all reports * HTTP request to fetch all reports
*/ */
export async function fetchReport(): Promise<OntimeReport> { export async function fetchReport(): Promise<OntimeReport> {
const res = await axios.get(`${reportUrl}/`); const res = await axios.get(reportUrl);
return res.data; return res.data;
} }
+57 -20
View File
@@ -1,29 +1,45 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types'; import {
EntryId,
MessageResponse,
OntimeEntry,
OntimeEvent,
ProjectRundownsList,
Rundown,
TransientEventPayload,
} from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
const rundownPath = `${apiEntryUrl}/rundown`; const rundownPath = `${apiEntryUrl}/rundown`;
/** /**
* HTTP request to fetch all events * HTTP request to fetch a list of existing rundowns
*/ */
export async function fetchNormalisedRundown(): Promise<RundownCached> { export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
const res = await axios.get(`${rundownPath}/normalised`); const res = await axios.get(rundownPath);
return res.data; return res.data;
} }
/** /**
* HTTP request to post new event * HTTP request to fetch all events
*/ */
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeRundownEntry>> { export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`);
return res.data;
}
/**
* HTTP request to post new entry
*/
export async function postAddEntry(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(rundownPath, data); return axios.post(rundownPath, data);
} }
/** /**
* HTTP request to put new event * HTTP request to edit an entry
*/ */
export async function requestPutEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> { export async function putEditEntry(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(rundownPath, data); return axios.put(rundownPath, data);
} }
@@ -33,22 +49,22 @@ type BatchEditEntry = {
}; };
/** /**
* HTTP request to put multiple events * HTTP request to edit multiple events
*/ */
export async function requestBatchPutEvents(data: BatchEditEntry): Promise<AxiosResponse<MessageResponse>> { export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<MessageResponse>> {
return axios.put(`${rundownPath}/batch`, data); return axios.put(`${rundownPath}/batch`, data);
} }
export type ReorderEntry = { export type ReorderEntry = {
eventId: string; entryId: EntryId;
from: number; destinationId: EntryId;
to: number; order: 'before' | 'after' | 'insert';
}; };
/** /**
* HTTP request to reorder events * HTTP request to reorder an entry
*/ */
export async function requestReorderEvent(data: ReorderEntry): Promise<AxiosResponse<OntimeRundownEntry>> { export async function patchReorderEntry(data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/reorder`, data); return axios.patch(`${rundownPath}/reorder`, data);
} }
@@ -67,15 +83,36 @@ export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<M
/** /**
* HTTP request to request application of delay * HTTP request to request application of delay
*/ */
export async function requestApplyDelay(eventId: string): Promise<AxiosResponse<MessageResponse>> { export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<MessageResponse>> {
return axios.patch(`${rundownPath}/applydelay/${eventId}`); return axios.patch(`${rundownPath}/applydelay/${delayId}`);
} }
/** /**
* HTTP request to delete given event * HTTP request for cloning an entry
*/ */
export async function requestDelete(eventIds: string[]): Promise<AxiosResponse<MessageResponse>> { export async function postCloneEntry(entryId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.delete(rundownPath, { data: { ids: eventIds } }); return axios.post(`${rundownPath}/clone/${entryId}`);
}
/**
* HTTP request for dissolving of a block
*/
export async function requestUngroup(blockId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/ungroup/${blockId}`);
}
/**
* HTTP request for grouping a list of entries into a block
*/
export async function requestGroupEntries(entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/group`, { ids: entryIds });
}
/**
* HTTP request to delete entries
*/
export async function deleteEntries(entryIds: EntryId[]): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(rundownPath, { data: { ids: entryIds } });
} }
/** /**
+2 -2
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types'; import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -54,7 +54,7 @@ export const previewRundown = async (
sheetId: string, sheetId: string,
options: ImportMap, options: ImportMap,
): Promise<{ ): Promise<{
rundown: OntimeRundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
}> => { }> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options }); const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
+2 -2
View File
@@ -7,7 +7,7 @@ import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time'; import { nowInMillis } from '../utils/time';
/** /**
* Utility unrwap a potential axios error * Utility unwrap a potential axios error
* @param error * @param error
* @returns * @returns
*/ */
@@ -34,7 +34,7 @@ export function maybeAxiosError(error: unknown) {
} }
/** /**
* Utility unrwaps a potential axios error and sends to logger * Utility unwraps a potential axios error and sends to logger
* @param prepend * @param prepend
* @param error * @param error
*/ */
@@ -23,7 +23,7 @@ export type OptionWithoutGroup = {
withDivider?: boolean; withDivider?: boolean;
}; };
export type OptionWithGroup = { type OptionWithGroup = {
label: string; label: string;
group: Omit<OptionWithoutGroup, 'isGroup'>[]; group: Omit<OptionWithoutGroup, 'isGroup'>[];
}; };
@@ -3,8 +3,8 @@ import React from 'react';
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module. // skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import { runtimeStore } from '@/common/stores/runtime'; import { hasConnected, reconnectAttempts } from '../../../common/utils/socket';
import { hasConnected, reconnectAttempts, shouldReconnect } from '@/common/utils/socket'; import { runtimeStore } from '../../stores/runtime';
import style from './ErrorBoundary.module.scss'; import style from './ErrorBoundary.module.scss';
@@ -37,7 +37,7 @@ class ErrorBoundary extends React.Component {
scope.setExtras({ scope.setExtras({
error, error,
store: appState, store: appState,
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts }, hasSocket: { hasConnected, reconnectAttempts },
}); });
const eventId = Sentry.captureException(error); const eventId = Sentry.captureException(error);
this.setState({ eventId, info }); this.setState({ eventId, info });
@@ -2,7 +2,7 @@ import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { Input, Radio, RadioGroup } from '@chakra-ui/react'; import { Input, Radio, RadioGroup } from '@chakra-ui/react';
import { millisToString, parseUserTime } from 'ontime-utils'; import { millisToString, parseUserTime } from 'ontime-utils';
import { useEventAction } from '../../../hooks/useEventAction'; import { useEntryActions } from '../../../hooks/useEntryAction';
import style from './DelayInput.module.scss'; import style from './DelayInput.module.scss';
@@ -13,7 +13,7 @@ interface DelayInputProps {
export default function DelayInput(props: DelayInputProps) { export default function DelayInput(props: DelayInputProps) {
const { eventId, duration } = props; const { eventId, duration } = props;
const { updateEvent } = useEventAction(); const { updateEntry } = useEntryActions();
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
@@ -54,7 +54,7 @@ export default function DelayInput(props: DelayInputProps) {
}; };
const submitChange = (value: number) => { const submitChange = (value: number) => {
updateEvent({ updateEntry({
id: eventId, id: eventId,
duration: value, duration: value,
}); });
@@ -1,45 +0,0 @@
$loader-size: 4rem;
.overlay {
position: absolute;
z-index: 10;
width: 100%;
height: 100%;
display: grid;
place-content: center;
background-color: $black-10;
backdrop-filter: blur(5px);
}
.loader {
width: $loader-size;
height: $loader-size;
background: $blue-500;
display: inline-block;
border-radius: 50%;
box-sizing: border-box;
animation: animloader 1s ease-in infinite;
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@@ -1,9 +0,0 @@
import style from './LoaderOverlay.module.scss';
export default function LoaderOverlay() {
return (
<div className={style.overlay}>
<span className={style.loader} />
</div>
);
}
@@ -1,16 +0,0 @@
import { memo } from 'react';
import NavigationMenu from './NavigationMenu';
interface ProductionNavigationMenuProps {
isMenuOpen: boolean;
onMenuClose: () => void;
}
function ProductionNavigationMenu(props: ProductionNavigationMenuProps) {
const { isMenuOpen, onMenuClose } = props;
return <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />;
}
export default memo(ProductionNavigationMenu);
@@ -12,7 +12,7 @@ type OptionsField = {
defaultValue?: string; defaultValue?: string;
}; };
export type MultiselectOption = { value: string; label: string; colour: string }; type MultiselectOption = { value: string; label: string; colour: string };
export type MultiselectOptions = Record<string, MultiselectOption>; export type MultiselectOptions = Record<string, MultiselectOption>;
type MultiOptionsField = { type MultiOptionsField = {
type: 'multi-option'; type: 'multi-option';
@@ -1,32 +0,0 @@
// roughly from https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useMediaQuery/useMediaQuery.ts
import { useCallback, useEffect, useState } from 'react';
function getMatches(query: string): boolean {
return window.matchMedia(query).matches;
}
// TODO: debounce handleChange
export default function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(getMatches(query));
const handleChange = useCallback(() => {
setMatches(getMatches(query));
}, [query]);
useEffect(() => {
const matchMedia = window.matchMedia(query);
// Triggered at the first client-side load and if query changes
handleChange();
// Listen matchMedia
matchMedia.addEventListener('change', handleChange);
return () => {
matchMedia.removeEventListener('change', handleChange);
};
}, [handleChange, query]);
return matches;
}
@@ -1,11 +1,9 @@
import { useMutation, useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { editAutomationSettings, getAutomationSettings } from '../api/automation'; import { getAutomationSettings } from '../api/automation';
import { AUTOMATION } from '../api/constants'; import { AUTOMATION } from '../api/constants';
import { logAxiosError } from '../api/utils';
import { automationPlaceholderSettings } from '../models/AutomationSettings'; import { automationPlaceholderSettings } from '../models/AutomationSettings';
import { ontimeQueryClient } from '../queryClient';
export default function useAutomationSettings() { export default function useAutomationSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
@@ -20,15 +18,3 @@ export default function useAutomationSettings() {
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch }; return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
} }
export function useAutomationSettingsMutation() {
const { isPending, mutateAsync } = useMutation({
mutationFn: editAutomationSettings,
onError: (error) => logAxiosError('Error saving Automation settings', error),
onSuccess: (data) => {
ontimeQueryClient.setQueryData(AUTOMATION, data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: AUTOMATION }),
});
return { isPending, mutateAsync };
}
@@ -11,7 +11,7 @@ const placeholderProjectList: ProjectFileListResponse = {
lastLoadedProject: '', lastLoadedProject: '',
}; };
export function useProjectList() { function useProjectList() {
const { data, status, refetch } = useQuery({ const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST, queryKey: PROJECT_LIST,
queryFn: getProjects, queryFn: getProjects,
@@ -1,23 +1,30 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { NormalisedRundown, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types'; import { OntimeEntry, Rundown } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { fetchNormalisedRundown } from '../api/rundown'; import { fetchCurrentRundown } from '../api/rundown';
import useProjectData from './useProjectData'; import useProjectData from './useProjectData';
// revision is -1 so that the remote revision is higher // revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 }; const cachedRundownPlaceholder: Rundown = {
id: 'default',
title: '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
};
/** /**
* Normalised rundown data * Normalised rundown data
*/ */
export default function useRundown() { export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({ const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: RUNDOWN, queryKey: RUNDOWN,
queryFn: fetchNormalisedRundown, queryFn: fetchCurrentRundown,
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
@@ -37,16 +44,16 @@ export function useFlatRundown() {
const loadedProject = useRef<string>(''); const loadedProject = useRef<string>('');
const [prevRevision, setPrevRevision] = useState<number>(-1); const [prevRevision, setPrevRevision] = useState<number>(-1);
const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]); const [flatRundown, setFlatRundown] = useState<OntimeEntry[]>([]);
// update data whenever the revision changes // update data whenever the revision changes
useEffect(() => { useEffect(() => {
if (data.revision !== -1 && data.revision !== prevRevision) { if (data.revision !== -1 && data.revision !== prevRevision) {
const flatRundown = data.order.map((id) => data.rundown[id]); const flatRundown = data.order.map((id) => data.entries[id]);
setFlatRunDown(flatRundown); setFlatRundown(flatRundown);
setPrevRevision(data.revision); setPrevRevision(data.revision);
} }
}, [data.order, data.revision, data.rundown, prevRevision]); }, [data.entries, data.order, data.revision, prevRevision]);
// TODO: should we have a project id field? // TODO: should we have a project id field?
// invalidate current version if project changes // invalidate current version if project changes
@@ -57,13 +64,13 @@ export function useFlatRundown() {
} }
}, [projectData]); }, [projectData]);
return { data: flatRunDown, status }; return { data: flatRundown, status };
} }
/** /**
* Provides access to a partial rundown based on a filter callback * Provides access to a partial rundown based on a filter callback
*/ */
export function usePartialRundown(cb: (event: OntimeRundownEntry) => boolean) { export function usePartialRundown(cb: (event: OntimeEntry) => boolean) {
const { data, status } = useFlatRundown(); const { data, status } = useFlatRundown();
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
return data.filter(cb); return data.filter(cb);
@@ -1,29 +1,35 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
EntryId,
isOntimeBlock,
isOntimeEvent, isOntimeEvent,
MaybeString,
OntimeBlock, OntimeBlock,
OntimeDelay, OntimeEntry,
OntimeEvent, OntimeEvent,
OntimeRundownEntry, Rundown,
RundownCached,
TimeField, TimeField,
TimeStrategy, TimeStrategy,
TransientEventPayload, TransientEventPayload,
} from 'ontime-types'; } from 'ontime-types';
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils'; import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, swapEventData } from 'ontime-utils';
import { moveDown, moveUp } from '../../features/rundown/rundown.utils';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { import {
deleteEntries,
patchReorderEntry,
postAddEntry,
postCloneEntry,
putBatchEditEvents,
putEditEntry,
ReorderEntry, ReorderEntry,
requestApplyDelay, requestApplyDelay,
requestBatchPutEvents,
requestDelete,
requestDeleteAll, requestDeleteAll,
requestEventSwap, requestEventSwap,
requestPostEvent, requestGroupEntries,
requestPutEvent, requestUngroup,
requestReorderEvent,
SwapEntry, SwapEntry,
} from '../api/rundown'; } from '../api/rundown';
import { logAxiosError } from '../api/utils'; import { logAxiosError } from '../api/utils';
@@ -31,18 +37,18 @@ import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{ export type EventOptions = Partial<{
// options to any new block (event / delay / block) // options to any new block (event / delay / block)
after: string; after: MaybeString;
before: string; before: MaybeString;
// options to blocks of type OntimeEvent // options to blocks of type OntimeEvent
defaultPublic: boolean; defaultPublic: boolean;
linkPrevious: boolean; linkPrevious: boolean;
lastEventId: string; lastEventId: MaybeString;
}>; }>;
/** /**
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on * Gather utilities for actions on entries
*/ */
export const useEventAction = () => { export const useEntryActions = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { const {
defaultPublic, defaultPublic,
@@ -55,38 +61,36 @@ export const useEventAction = () => {
defaultEndAction, defaultEndAction,
} = useEditorSettings(); } = useEditorSettings();
const getEventById = useCallback( const getEntryById = useCallback(
(eventId: string) => { (eventId: string): OntimeEntry | undefined => {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN); const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.rundown) { if (!cachedRundown?.entries) {
return; return;
} }
return cachedRundown.rundown[eventId]; return cachedRundown.entries[eventId];
}, },
[queryClient], [queryClient],
); );
/** /**
* Calls mutation to add new event * Calls mutation to add new entry
* @private * @private
*/ */
const _addEventMutation = useMutation({ const _addEntryMutation = useMutation({
mutationFn: requestPostEvent, // TODO(v4): optimistic create entry
onSettled: () => { mutationFn: postAddEntry,
queryClient.invalidateQueries({ queryKey: RUNDOWN }); onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
},
networkMode: 'always',
}); });
/** /**
* Adds an event to rundown * Adds an entry to rundown
*/ */
const addEvent = useCallback( const addEntry = useCallback(
async (event: Partial<OntimeEvent | OntimeDelay | OntimeBlock>, options?: EventOptions) => { async (entry: Partial<OntimeEntry>, options?: EventOptions) => {
const newEvent: TransientEventPayload = { ...event }; const newEntry: TransientEventPayload = { ...entry, id: generateId() };
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEvent)) { if (isOntimeEvent(newEntry)) {
// merge creation time options with event settings // merge creation time options with event settings
const applicationOptions = { const applicationOptions = {
after: options?.after, after: options?.after,
@@ -96,66 +100,60 @@ export const useEventAction = () => {
linkPrevious: options?.linkPrevious ?? linkPrevious, linkPrevious: options?.linkPrevious ?? linkPrevious,
}; };
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) { if (applicationOptions?.lastEventId) {
newEvent.linkStart = applicationOptions.lastEventId;
} else if (applicationOptions?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!; const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
const { rundown } = rundownData; const previousEvent = rundownData.entries[applicationOptions.lastEventId];
const previousEvent = rundown[applicationOptions.lastEventId];
if (isOntimeEvent(previousEvent)) { if (isOntimeEvent(previousEvent)) {
newEvent.timeStart = previousEvent.timeEnd; newEntry.timeStart = previousEvent.timeEnd;
} }
} }
// Override event with options from editor settings // Override event with options from editor settings
if (applicationOptions.defaultPublic) { newEntry.linkStart = applicationOptions.linkPrevious;
newEvent.isPublic = true; newEntry.isPublic = applicationOptions.defaultPublic;
if (newEntry.duration === undefined && newEntry.timeEnd === undefined) {
newEntry.duration = parseUserTime(defaultDuration);
} }
if (newEvent.duration === undefined && newEvent.timeEnd === undefined) { if (newEntry.timeDanger === undefined) {
newEvent.duration = parseUserTime(defaultDuration); newEntry.timeDanger = parseUserTime(defaultDangerTime);
} }
if (newEvent.timeDanger === undefined) { if (newEntry.timeWarning === undefined) {
newEvent.timeDanger = parseUserTime(defaultDangerTime); newEntry.timeWarning = parseUserTime(defaultWarnTime);
} }
if (newEvent.timeWarning === undefined) { if (newEntry.timerType === undefined) {
newEvent.timeWarning = parseUserTime(defaultWarnTime); newEntry.timerType = defaultTimerType;
} }
if (newEvent.timerType === undefined) { if (newEntry.endAction === undefined) {
newEvent.timerType = defaultTimerType; newEntry.endAction = defaultEndAction;
} }
if (newEvent.endAction === undefined) { if (newEntry.timeStrategy === undefined) {
newEvent.endAction = defaultEndAction; newEntry.timeStrategy = defaultTimeStrategy;
}
if (newEvent.timeStrategy === undefined) {
newEvent.timeStrategy = defaultTimeStrategy;
} }
} }
// handle adding options that concern all event type // handle adding options that concern all event type
if (options?.after) { if (options?.after) {
// @ts-expect-error -- not sure how to type this, <after> is a transient property (newEntry as TransientEventPayload).after = options.after;
newEvent.after = options.after;
} }
if (options?.before) { if (options?.before) {
// @ts-expect-error -- not sure how to type this, <before> is a transient property (newEntry as TransientEventPayload).before = options.before;
newEvent.before = options.before;
} }
try { try {
await _addEventMutation.mutateAsync(newEvent as TransientEventPayload); await _addEntryMutation.mutateAsync(newEntry);
} catch (error) { } catch (error) {
logAxiosError('Failed adding event', error); logAxiosError('Failed adding event', error);
} }
}, },
[ [
_addEventMutation, _addEntryMutation,
defaultDangerTime, defaultDangerTime,
defaultDuration, defaultDuration,
defaultEndAction, defaultEndAction,
@@ -169,26 +167,56 @@ export const useEventAction = () => {
); );
/** /**
* Calls mutation to update existing event * Calls mutation to clone a selection
* @private * @private
*/ */
const _updateEventMutation = useMutation({ const _cloneMutation = useMutation({
mutationFn: requestPutEvent, mutationFn: postCloneEntry,
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
/**
* Clone a selection
*/
const clone = useCallback(
async (entryId: EntryId) => {
try {
await _cloneMutation.mutateAsync(entryId);
} catch (error) {
logAxiosError('Error cloning entry', error);
}
},
[_cloneMutation],
);
/**
* Calls mutation to update existing entry
* @private
*/
const _updateEntryMutation = useMutation({
mutationFn: putEditEntry,
// we optimistically update here // we optimistically update here
onMutate: async (newEvent) => { onMutate: async (newEvent) => {
// cancel ongoing queries // cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
const eventId = newEvent.id; const eventId = newEvent.id;
if (previousData && eventId) { if (previousData && eventId) {
// optimistically update object // optimistically update object
const newRundown = { ...previousData.rundown }; const newRundown = { ...previousData.entries };
// @ts-expect-error -- we expect the events to be of same type // @ts-expect-error -- we expect the events to be of same type
newRundown[eventId] = { ...newRundown[eventId], ...newEvent }; newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 }); queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
order: previousData.order,
flatOrder: previousData.flatOrder,
entries: newRundown,
revision: -1,
});
} }
// Return a context with the previous and new events // Return a context with the previous and new events
@@ -196,46 +224,45 @@ export const useEventAction = () => {
}, },
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => { onError: (_error, _newEvent, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
onSettled: async () => { onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN }); await queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
networkMode: 'always',
}); });
/** /**
* Updates existing event * Updates existing entry
*/ */
const updateEvent = useCallback( const updateEntry = useCallback(
async (event: Partial<OntimeRundownEntry>) => { async (event: Partial<OntimeEntry>) => {
try { try {
await _updateEventMutation.mutateAsync(event); await _updateEntryMutation.mutateAsync(event);
} catch (error) { } catch (error) {
logAxiosError('Error updating event', error); logAxiosError('Error updating event', error);
} }
}, },
[_updateEventMutation], [_updateEntryMutation],
); );
const updateCustomField = useCallback( const updateCustomField = useCallback(
async (eventId: string, field: string, value: string) => { async (entryId: EntryId, field: string, value: string) => {
updateEvent({ id: eventId, custom: { [field]: value } }); updateEntry({ id: entryId, custom: { [field]: value } });
}, },
[updateEvent], [updateEntry],
); );
/** /**
* Updates time of existing event * Updates time of existing event
* @param eventId {string} - id of the event * @param eventId {EntryId} - id of the event
* @param field {TimeField} - field to update * @param field {TimeField} - field to update
* @param value {string} - new value string to be parsed * @param value {string} - new value string to be parsed
* @param lockOnUpdate {boolean} - whether we will apply the lock / release on update * @param lockOnUpdate {boolean} - whether we will apply the lock / release on update
*/ */
const updateTimer = useCallback( const updateTimer = useCallback(
async (eventId: string, field: TimeField, value: string, lockOnUpdate?: boolean) => { async (eventId: EntryId, field: TimeField, value: string, lockOnUpdate?: boolean) => {
// an empty value with no lock has no domain validity // an empty value with no lock has no domain validity
if (!lockOnUpdate && value === '') { if (!lockOnUpdate && value === '') {
return; return;
@@ -257,7 +284,7 @@ export const useEventAction = () => {
newEvent.duration = value === '' ? undefined : calculateNewValue(); newEvent.duration = value === '' ? undefined : calculateNewValue();
} else if (field === 'timeStart') { } else if (field === 'timeStart') {
// an empty values means we should link to the previous // an empty values means we should link to the previous
newEvent.linkStart = value === '' ? 'true' : null; newEvent.linkStart = value === '';
newEvent.timeStart = value === '' ? undefined : calculateNewValue(); newEvent.timeStart = value === '' ? undefined : calculateNewValue();
} }
} else { } else {
@@ -265,7 +292,7 @@ export const useEventAction = () => {
} }
try { try {
await _updateEventMutation.mutateAsync(newEvent); await _updateEntryMutation.mutateAsync(newEvent);
} catch (error) { } catch (error) {
logAxiosError('Error updating event', error); logAxiosError('Error updating event', error);
} }
@@ -296,9 +323,9 @@ export const useEventAction = () => {
* Utility function to get the previous event end time * Utility function to get the previous event end time
*/ */
function getPreviousEnd(): number { function getPreviousEnd(): number {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN); const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.order || !cachedRundown?.rundown) { if (!cachedRundown?.order || !cachedRundown?.entries) {
return 0; return 0;
} }
@@ -308,7 +335,7 @@ export const useEventAction = () => {
} }
let previousEnd = 0; let previousEnd = 0;
for (let i = index - 1; i >= 0; i--) { for (let i = index - 1; i >= 0; i--) {
const event = cachedRundown.rundown[cachedRundown.order[i]]; const event = cachedRundown.entries[cachedRundown.order[i]];
if (isOntimeEvent(event)) { if (isOntimeEvent(event)) {
previousEnd = event.timeEnd; previousEnd = event.timeEnd;
break; break;
@@ -317,7 +344,7 @@ export const useEventAction = () => {
return previousEnd; return previousEnd;
} }
}, },
[_updateEventMutation, queryClient], [_updateEntryMutation, queryClient],
); );
/** /**
@@ -325,17 +352,17 @@ export const useEventAction = () => {
* @private * @private
*/ */
const _batchUpdateEventsMutation = useMutation({ const _batchUpdateEventsMutation = useMutation({
mutationFn: requestBatchPutEvents, mutationFn: putBatchEditEvents,
onMutate: async ({ ids, data }) => { onMutate: async ({ ids, data }) => {
// cancel ongoing queries // cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousEvents = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousEvents) { if (previousRundown) {
const eventIds = new Set(ids); const eventIds = new Set(ids);
const newRundown = { ...previousEvents.rundown }; const newRundown = { ...previousRundown.entries };
eventIds.forEach((eventId) => { eventIds.forEach((eventId) => {
if (Object.hasOwn(newRundown, eventId)) { if (Object.hasOwn(newRundown, eventId)) {
@@ -349,18 +376,25 @@ export const useEventAction = () => {
} }
}); });
queryClient.setQueryData(RUNDOWN, { order: previousEvents.order, rundown: newRundown, revision: -1 }); queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousRundown.id,
title: previousRundown.title,
order: previousRundown.order,
flatOrder: previousRundown.flatOrder,
entries: newRundown,
revision: -1,
});
} }
// Return a context with the previous and new events
return { previousEvents }; // Return a context with the previous rundown
return { previousRundown };
}, },
onSettled: async () => { onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN }); await queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
onError: (_error, _newEvent, context) => { onError: (_error, _newEvent, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousEvents); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousRundown);
}, },
networkMode: 'always',
}); });
const batchUpdateEvents = useCallback( const batchUpdateEvents = useCallback(
@@ -375,30 +409,29 @@ export const useEventAction = () => {
); );
/** /**
* Calls mutation to delete an event * Calls mutation to delete an entry
* @private * @private
*/ */
const _deleteEventMutation = useMutation({ const _deleteEntryMutation = useMutation({
mutationFn: requestDelete, mutationFn: deleteEntries,
// we optimistically update here // we optimistically update here
onMutate: async (eventIds: string[]) => { onMutate: async (entryIds: EntryId[]) => {
// cancel ongoing queries // cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) { if (previousData) {
// optimistically update object // optimistically update object
const newOrder = previousData.order.filter((id) => !eventIds.includes(id)); const { entries, order, flatOrder } = optimisticDeleteEntries(entryIds, previousData);
const newRundown = { ...previousData.rundown };
for (const eventId of eventIds) {
delete newRundown[eventId];
}
queryClient.setQueryData(RUNDOWN, { queryClient.setQueryData<Rundown>(RUNDOWN, {
order: newOrder, id: previousData.id,
rundown: newRundown, title: previousData.title,
order,
flatOrder,
entries,
revision: -1, revision: -1,
}); });
} }
@@ -408,36 +441,35 @@ export const useEventAction = () => {
}, },
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => { onError: (_error, _entryIds, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
networkMode: 'always',
}); });
/** /**
* Deletes an event form the list * Deletes an event entry from the rundown
*/ */
const deleteEvent = useCallback( const deleteEntry = useCallback(
async (eventIds: string[]) => { async (entryIds: EntryId[]) => {
try { try {
await _deleteEventMutation.mutateAsync(eventIds); await _deleteEntryMutation.mutateAsync(entryIds);
} catch (error) { } catch (error) {
logAxiosError('Error deleting event', error); logAxiosError('Error deleting event', error);
} }
}, },
[_deleteEventMutation], [_deleteEntryMutation],
); );
/** /**
* Calls mutation to delete all events * Calls mutation to delete all events
* @private * @private
*/ */
const _deleteAllEventsMutation = useMutation({ const _deleteAllEntriesMutation = useMutation({
mutationFn: requestDeleteAll, mutationFn: requestDeleteAll,
// we optimistically update here // we optimistically update here
onMutate: async () => { onMutate: async () => {
@@ -445,37 +477,43 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
// optimistically update object // optimistically update object
queryClient.setQueryData(RUNDOWN, { rundown: {}, order: [], revision: -1 }); queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData?.id ?? 'default',
title: previousData?.title ?? '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
});
// Return a context with the previous and new events // Return a context with the previous and new events
return { previousData }; return { previousData };
}, },
// Mutation fails, rollback undos optimist update // Mutation fails, rollback optimist update
onError: (_error, _eventId, context) => { onError: (_error, _, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
networkMode: 'always',
}); });
/** /**
* Deletes all events from list * Deletes all entries in the rundown
*/ */
const deleteAllEvents = useCallback(async () => { const deleteAllEntries = useCallback(async () => {
try { try {
await _deleteAllEventsMutation.mutateAsync(); await _deleteAllEntriesMutation.mutateAsync();
} catch (error) { } catch (error) {
logAxiosError('Error deleting events', error); logAxiosError('Error deleting events', error);
} }
}, [_deleteAllEventsMutation]); }, [_deleteAllEntriesMutation]);
/** /**
* Calls mutation to apply a delay * Calls mutation to apply a delay
@@ -487,14 +525,13 @@ export const useEventAction = () => {
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
networkMode: 'always',
}); });
/** /**
* Applies a given delay block * Applies a given delay
*/ */
const applyDelay = useCallback( const applyDelay = useCallback(
async (delayEventId: string) => { async (delayEventId: EntryId) => {
try { try {
await _applyDelayMutation.mutateAsync(delayEventId); await _applyDelayMutation.mutateAsync(delayEventId);
} catch (error) { } catch (error) {
@@ -505,61 +542,133 @@ export const useEventAction = () => {
); );
/** /**
* Calls mutation to reorder an event * Calls mutation to dissolve a block
* @private * @private
*/ */
const _reorderEventMutation = useMutation({ const _ungroupMutation = useMutation({
mutationFn: requestReorderEvent, mutationFn: requestUngroup,
// we optimistically update here onSuccess: (response) => {
onMutate: async (data) => { if (!response.data) return;
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value const { id, title, order, flatOrder, entries, revision } = response.data;
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); queryClient.setQueryData<Rundown>(RUNDOWN, {
id,
title,
order,
flatOrder,
entries,
revision,
});
},
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
if (previousData) { /**
// optimistically update object * Deletes a block and moves its events to the top level
const newOrder = reorderArray(previousData.order, data.from, data.to); */
const ungroup = useCallback(
queryClient.setQueryData(RUNDOWN, { order: newOrder, rundown: previousData.rundown, revision: -1 }); async (blockId: EntryId) => {
try {
await _ungroupMutation.mutateAsync(blockId);
} catch (error) {
logAxiosError('Error dissolving block', error);
} }
// Return a context with the previous and new events
return { previousData };
}, },
[_ungroupMutation],
);
// Mutation fails, rollback undoes optimist update /**
onError: (_error, _eventId, context) => { * Calls mutation to create a block with a selection
queryClient.setQueryData(RUNDOWN, context?.previousData); * @private
*/
const _groupEntriesMutation = useMutation({
mutationFn: requestGroupEntries,
onSuccess: (response) => {
if (!response.data) return;
const { id, title, order, flatOrder, entries, revision } = response.data;
queryClient.setQueryData<Rundown>(RUNDOWN, {
id,
title,
order,
flatOrder,
entries,
revision,
});
}, },
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
/**
* Create a block with a selection
*/
const groupEntries = useCallback(
async (entryIds: EntryId[]) => {
try {
await _groupEntriesMutation.mutateAsync(entryIds);
} catch (error) {
logAxiosError('Error grouping entries', error);
}
},
[_groupEntriesMutation],
);
/**
* Calls mutation to reorder an entry
* @private
*/
const _reorderEntryMutation = useMutation({
mutationFn: patchReorderEntry,
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
networkMode: 'always',
}); });
/** /**
* Reorders a given event * Reorders a given entry
*/ */
const reorderEvent = useCallback( const reorderEntry = useCallback(
async (eventId: string, from: number, to: number) => { async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => {
try { try {
const reorderObject: ReorderEntry = { const reorderObject: ReorderEntry = {
eventId, entryId,
from, destinationId,
to, order,
}; };
await _reorderEventMutation.mutateAsync(reorderObject); await _reorderEntryMutation.mutateAsync(reorderObject);
} catch (error) { } catch (error) {
logAxiosError('Error re-ordering event', error); logAxiosError('Error re-ordering event', error);
} }
}, },
[_reorderEventMutation], [_reorderEntryMutation],
); );
const move = useCallback(async (entryId: EntryId, direction: 'up' | 'down') => {
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.order) {
return;
}
const { destinationId, order } =
direction === 'up'
? moveUp(entryId, cachedRundown.order, cachedRundown.entries)
: moveDown(entryId, cachedRundown.order, cachedRundown.entries);
if (destinationId) {
try {
const reorderObject: ReorderEntry = {
entryId,
destinationId,
order: order as 'before' | 'after' | 'insert',
};
await _reorderEntryMutation.mutateAsync(reorderObject);
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
}
}, []);
/** /**
* Calls mutation to swap events * Calls mutation to swap events
* @private * @private
@@ -572,22 +681,29 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) { if (previousData) {
// optimistically update object // optimistically update object
const newRundown = { ...previousData.rundown }; const newRundown = { ...previousData.entries };
const eventA = previousData.rundown[from]; const eventA = previousData.entries[from];
const eventB = previousData.rundown[to]; const eventB = previousData.entries[to];
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) { if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
return; return;
} }
const { newA, newB } = swapEventData(eventA, eventB); const [newA, newB] = swapEventData(eventA, eventB);
newRundown[from] = newA; newRundown[from] = newA;
newRundown[to] = newB; newRundown[to] = newB;
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 }); queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
order: previousData.order,
flatOrder: previousData.flatOrder,
entries: newRundown,
revision: -1,
});
} }
// Return a context with the previous events // Return a context with the previous events
@@ -596,14 +712,13 @@ export const useEventAction = () => {
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => { onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
networkMode: 'always',
}); });
/** /**
@@ -621,16 +736,48 @@ export const useEventAction = () => {
); );
return { return {
addEvent, addEntry,
applyDelay, applyDelay,
batchUpdateEvents, batchUpdateEvents,
deleteEvent, clone,
deleteAllEvents, deleteEntry,
getEventById, deleteAllEntries,
reorderEvent, ungroup,
getEntryById,
groupEntries,
move,
reorderEntry,
swapEvents, swapEvents,
updateEvent, updateEntry,
updateTimer, updateTimer,
updateCustomField, updateCustomField,
}; };
}; };
/**
* Utility to optimistically delete entries from client cache
*/
function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
const entries = { ...rundown.entries };
let order = [...rundown.order];
let flatOrder = [...rundown.flatOrder];
for (let i = 0; i < entryIds.length; i++) {
const entry = entries[entryIds[i]];
deleteEntry(entry);
}
function deleteEntry(entry: OntimeEntry) {
if (isOntimeBlock(entry) || !entry.parent) {
order = order.filter((id) => id !== entry.id);
} else {
const parent = entries[entry.parent] as OntimeBlock;
parent.events = parent.events.filter((event) => event !== entry.id);
}
delete entries[entry.id];
flatOrder = flatOrder.filter((id) => id !== entry.id);
}
return { entries, order, flatOrder };
}
@@ -12,7 +12,7 @@ type noop = (this: any, ...args: any[]) => any;
type PickFunction<T extends noop> = (this: ThisParameterType<T>, ...args: Parameters<T>) => ReturnType<T>; type PickFunction<T extends noop> = (this: ThisParameterType<T>, ...args: Parameters<T>) => ReturnType<T>;
export const isFunction = (value: unknown): value is (...args: any) => any => typeof value === 'function'; const isFunction = (value: unknown): value is (...args: any) => any => typeof value === 'function';
export default function useMemoisedFn<T extends noop>(fn: T) { export default function useMemoisedFn<T extends noop>(fn: T) {
if (isDev) { if (isDev) {
-10
View File
@@ -91,14 +91,6 @@ export const setPlayback = {
}, },
}; };
export const useInfoPanel = createSelector((state: RuntimeStore) => ({
eventNow: state.eventNow,
eventNext: state.eventNext,
playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
}));
export const useAuxTimerTime = createSelector((state: RuntimeStore) => state.auxtimer1.current); export const useAuxTimerTime = createSelector((state: RuntimeStore) => state.auxtimer1.current);
export const useAuxTimerControl = createSelector((state: RuntimeStore) => ({ export const useAuxTimerControl = createSelector((state: RuntimeStore) => ({
@@ -145,8 +137,6 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({
timeDanger: state.eventNow?.timeDanger ?? null, timeDanger: state.eventNow?.timeDanger ?? null,
})); }));
export const setClientName = (newName: string) => socketSendJson('set-client-name', newName);
export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({ export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.runtime.plannedStart, plannedStart: state.runtime.plannedStart,
actualStart: state.runtime.actualStart, actualStart: state.runtime.actualStart,
+1 -1
View File
@@ -2,7 +2,7 @@ import { GetInfo } from 'ontime-types';
export const ontimePlaceholderInfo: GetInfo = { export const ontimePlaceholderInfo: GetInfo = {
networkInterfaces: [], networkInterfaces: [],
version: '2.0.0', version: '4.0.0',
serverPort: 4001, serverPort: 4001,
publicDir: '', publicDir: '',
}; };
@@ -1,8 +1,7 @@
import { Settings } from 'ontime-types'; import { Settings } from 'ontime-types';
export const ontimePlaceholderSettings: Settings = { export const ontimePlaceholderSettings: Settings = {
app: 'ontime', version: '4.0.0',
version: '2.0.0',
serverPort: 4001, serverPort: 4001,
editorKey: null, editorKey: null,
operatorKey: null, operatorKey: null,
+11
View File
@@ -1,9 +1,20 @@
import { QueryClient } from '@tanstack/react-query'; import { QueryClient } from '@tanstack/react-query';
import { isOntimeCloud } from '../externals';
export const ontimeQueryClient = new QueryClient({ export const ontimeQueryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
gcTime: 1000 * 60 * 10, // 10 min gcTime: 1000 * 60 * 10, // 10 min
}, },
mutations: {
/**
* React Query detects whether the client is online
* However, web access is not required for the clients when deployed locally
* - use 'always' for clients that may be online
* - use 'online' for clients that are connected to the cloud
*/
networkMode: isOntimeCloud ? 'online' : 'always',
},
}, },
}); });
+1 -1
View File
@@ -11,7 +11,7 @@ type LogStore = {
logs: Log[]; logs: Log[];
}; };
export const logger = createStore<LogStore>(() => ({ const logger = createStore<LogStore>(() => ({
logs: [], logs: [],
})); }));
-12
View File
@@ -14,18 +14,6 @@ export const runtimeStore = createWithEqualityFn<RuntimeStore>(
export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) => export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
useStoreWithEqualityFn(runtimeStore, selector, deepCompare); useStoreWithEqualityFn(runtimeStore, selector, deepCompare);
let batchStore: Partial<RuntimeStore> = {};
export function addToBatchUpdates<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]) {
batchStore[key] = value;
}
export function flushBatchUpdates() {
const state = runtimeStore.getState();
runtimeStore.setState({ ...state, ...batchStore });
batchStore = {};
}
/** /**
* Allows patching a property of the runtime store * Allows patching a property of the runtime store
*/ */
@@ -1,12 +1,12 @@
import { EndAction, EventCustomFields, OntimeEvent, SupportedEvent, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, EntryCustomFields, OntimeEvent, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
import { cloneEvent } from '../eventsManager'; import { cloneEvent } from '../clone';
describe('cloneEvent()', () => { describe('cloneEvent()', () => {
it('creates a stem from a given event', () => { it('creates a stem from a given event', () => {
const original: OntimeEvent = { const original: OntimeEvent = {
id: 'unique', id: 'unique',
type: SupportedEvent.Event, type: SupportedEntry.Event,
title: 'title', title: 'title',
cue: 'cue', cue: 'cue',
note: 'note', note: 'note',
@@ -15,7 +15,8 @@ describe('cloneEvent()', () => {
timeEnd: 10, timeEnd: 10,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, parent: 'test',
linkStart: false,
countToEnd: false, countToEnd: false,
endAction: EndAction.None, endAction: EndAction.None,
isPublic: false, isPublic: false,
@@ -29,7 +30,7 @@ describe('cloneEvent()', () => {
gap: 0, gap: 0,
custom: { custom: {
lighting: '3', lighting: '3',
} as EventCustomFields, } as EntryCustomFields,
}; };
const cloned = cloneEvent(original); const cloned = cloneEvent(original);
@@ -37,7 +38,7 @@ describe('cloneEvent()', () => {
expect(cloned.custom).not.toBe(original.custom); expect(cloned.custom).not.toBe(original.custom);
expect(cloned).toMatchObject({ expect(cloned).toMatchObject({
type: SupportedEvent.Event, type: SupportedEntry.Event,
title: original.title, title: original.title,
note: original.note, note: original.note,
timeStart: original.timeStart, timeStart: original.timeStart,
@@ -45,6 +46,7 @@ describe('cloneEvent()', () => {
timeEnd: original.timeEnd, timeEnd: original.timeEnd,
timerType: original.timerType, timerType: original.timerType,
timeStrategy: original.timeStrategy, timeStrategy: original.timeStrategy,
parent: 'test',
countToEnd: original.countToEnd, countToEnd: original.countToEnd,
linkStart: original.linkStart, linkStart: original.linkStart,
endAction: original.endAction, endAction: original.endAction,
@@ -1,4 +1,6 @@
import { makeCSVFromArrayOfArrays } from '../csv'; import { OntimeEntry, ProjectRundowns, Rundown } from 'ontime-types';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../csv';
describe('makeCSVFromArrayOfArrays()', () => { describe('makeCSVFromArrayOfArrays()', () => {
it('joins an array of arrays with commas and newlines', () => { it('joins an array of arrays with commas and newlines', () => {
@@ -11,3 +13,34 @@ after newline,after comma
`); `);
}); });
}); });
describe('aggregateRundowns()', () => {
it('flattens an object of rundowns into a single array', () => {
const rundowns = {
first: {
id: '',
title: '',
revision: 0,
order: ['1', '2'],
flatOrder: ['1', '2'],
entries: {
'1': { id: '1' } as OntimeEntry,
'2': { id: '2' } as OntimeEntry,
},
},
second: {
id: '',
title: '',
revision: 0,
order: ['3', '4'],
flatOrder: ['3', '4'],
entries: {
'3': { id: '3' } as OntimeEntry,
'4': { id: '4' } as OntimeEntry,
},
} as Rundown,
} as ProjectRundowns;
expect(aggregateRundowns(rundowns)).toStrictEqual([{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]);
});
});
@@ -64,13 +64,13 @@ describe('getRouteFromPreset()', () => {
describe('handle url sharing edge cases', () => { describe('handle url sharing edge cases', () => {
it('finds the correct preset when the url contains extra arguments', () => { it('finds the correct preset when the url contains extra arguments', () => {
const location = resolvePath('/demopage?locked=true&token=123'); const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy() expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy();
}) });
it('appends the feature params to the alias', () => { it('appends the feature params to the alias', () => {
const location = resolvePath('/demopage?locked=true&token=123'); const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123') expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123');
}) });
}); });
}); });
@@ -83,25 +83,26 @@ describe('generatePathFromPreset()', () => {
}); });
test('appends the feature params to the alias', () => { test('appends the feature params to the alias', () => {
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe('timer?user=guest&alias=demopage&locked=true&token=123'); expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe(
'timer?user=guest&alias=demopage&locked=true&token=123',
);
}); });
}); });
describe('arePathsEquivalent()', () => { describe('arePathsEquivalent()', () => {
it("checks whether the paths match", () => { it('checks whether the paths match', () => {
expect(arePathsEquivalent('demopage', 'timer')).toBeFalsy(); expect(arePathsEquivalent('demopage', 'timer')).toBeFalsy();
expect(arePathsEquivalent('timer', 'timer')).toBeTruthy(); expect(arePathsEquivalent('timer', 'timer')).toBeTruthy();
expect(arePathsEquivalent('timer?user=guest', 'timer?user=guest')).toBeTruthy(); expect(arePathsEquivalent('timer?user=guest', 'timer?user=guest')).toBeTruthy();
}) });
it("checks whether the params match", () => { 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();
}) });
}); });
@@ -1,4 +1,4 @@
import { OntimeEvent, SupportedEvent } from 'ontime-types'; import { OntimeEvent, SupportedEntry } from 'ontime-types';
/** /**
* @description Creates a safe duplicate of an event * @description Creates a safe duplicate of an event
@@ -9,7 +9,7 @@ import { OntimeEvent, SupportedEvent } from 'ontime-types';
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>; type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
export const cloneEvent = (event: OntimeEvent): ClonedEvent => { export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
return { return {
type: SupportedEvent.Event, type: SupportedEntry.Event,
title: event.title, title: event.title,
note: event.note, note: event.note,
timeStart: event.timeStart, timeStart: event.timeStart,
@@ -23,6 +23,7 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
isPublic: event.isPublic, isPublic: event.isPublic,
skip: event.skip, skip: event.skip,
colour: event.colour, colour: event.colour,
parent: event.parent,
revision: 0, revision: 0,
delay: event.delay, // the events will be collocated, so having the same metadata is a good start delay: event.delay, // the events will be collocated, so having the same metadata is a good start
dayOffset: event.dayOffset, dayOffset: event.dayOffset,
+24 -3
View File
@@ -1,10 +1,31 @@
import { stringify } from 'csv-stringify/browser/esm/sync'; import { stringify } from 'csv-stringify/browser/esm/sync';
import { OntimeEntry, ProjectRundowns } from 'ontime-types';
/** /**
* @description Converts an array of arrays to a CSV file * Converts an array of arrays to a CSV file
* @param {string[][]} arrayOfArrays
* @return {string}
*/ */
export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string { export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string {
return stringify(arrayOfArrays); return stringify(arrayOfArrays);
} }
/**
* Receives an object of rundowns, and flattens them into a single, linear rundown
* Used for CSV export
*/
export function aggregateRundowns(rundowns: ProjectRundowns): OntimeEntry[] {
const rundownKeys = Object.keys(rundowns);
if (rundownKeys.length === 0) return [];
const flatRundown: OntimeEntry[] = [];
for (const key of rundownKeys) {
const { order, entries } = rundowns[key];
for (let i = 0; i < order.length; i++) {
const entryId = order[i];
const entry = entries[entryId];
flatRundown.push(entry);
}
}
return flatRundown;
}
+21 -77
View File
@@ -1,4 +1,4 @@
import { Log, RundownCached, RuntimeStore } from 'ontime-types'; import { Log, Rundown, RuntimeStore } from 'ontime-types';
import { isProduction, websocketUrl } from '../../externals'; import { isProduction, websocketUrl } from '../../externals';
import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants'; import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants';
@@ -14,13 +14,12 @@ import {
} from '../stores/clientStore'; } from '../stores/clientStore';
import { addDialog } from '../stores/dialogStore'; import { addDialog } from '../stores/dialogStore';
import { addLog } from '../stores/logger'; import { addLog } from '../stores/logger';
import { addToBatchUpdates, flushBatchUpdates, patchRuntime, patchRuntimeProperty } from '../stores/runtime'; import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
export let websocket: WebSocket | null = null; let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null; let reconnectTimeout: NodeJS.Timeout | null = null;
const reconnectInterval = 1000; const reconnectInterval = 1000;
export let shouldReconnect = true;
export let hasConnected = false; export let hasConnected = false;
export let reconnectAttempts = 0; export let reconnectAttempts = 0;
@@ -49,18 +48,17 @@ export const connectSocket = () => {
websocket.onclose = () => { websocket.onclose = () => {
console.warn('WebSocket disconnected'); console.warn('WebSocket disconnected');
if (shouldReconnect) { // we decide to allows reconnect
reconnectTimeout = setTimeout(() => { reconnectTimeout = setTimeout(() => {
if (reconnectAttempts > 2) { if (reconnectAttempts > 2) {
setOnlineStatus(false); setOnlineStatus(false);
} }
console.warn('WebSocket: attempting reconnect'); console.warn('WebSocket: attempting reconnect');
if (websocket && websocket.readyState === WebSocket.CLOSED) { if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1; reconnectAttempts += 1;
connectSocket(); connectSocket();
} }
}, reconnectInterval); }, reconnectInterval);
}
}; };
websocket.onerror = (error) => { websocket.onerror = (error) => {
@@ -143,59 +141,10 @@ export const connectSocket = () => {
updateDevTools(serverPayload); updateDevTools(serverPayload);
break; break;
} }
case 'ontime-clock': { case 'ontime-patch': {
addToBatchUpdates('clock', payload); const patch = payload as Partial<RuntimeStore>;
updateDevTools({ clock: payload }); patchRuntime(patch);
break; updateDevTools(patch);
}
case 'ontime-timer': {
addToBatchUpdates('timer', payload);
updateDevTools({ timer: payload });
break;
}
case 'ontime-onAir': {
addToBatchUpdates('onAir', payload);
updateDevTools({ onAir: payload });
break;
}
case 'ontime-message': {
addToBatchUpdates('message', payload);
updateDevTools({ message: payload });
break;
}
case 'ontime-runtime': {
addToBatchUpdates('runtime', payload);
updateDevTools({ runtime: payload });
break;
}
case 'ontime-eventNow': {
addToBatchUpdates('eventNow', payload);
updateDevTools({ eventNow: payload });
break;
}
case 'ontime-currentBlock': {
addToBatchUpdates('currentBlock', payload);
updateDevTools({ currentBlock: payload });
break;
}
case 'ontime-publicEventNow': {
addToBatchUpdates('publicEventNow', payload);
updateDevTools({ publicEventNow: payload });
break;
}
case 'ontime-eventNext': {
addToBatchUpdates('eventNext', payload);
updateDevTools({ eventNext: payload });
break;
}
case 'ontime-publicEventNext': {
addToBatchUpdates('publicEventNext', payload);
updateDevTools({ publicEventNext: payload });
break;
}
case 'ontime-auxtimer1': {
addToBatchUpdates('auxtimer1', payload);
updateDevTools({ auxtimer1: payload });
break; break;
} }
case 'ontime-refetch': { case 'ontime-refetch': {
@@ -205,7 +154,7 @@ export const connectSocket = () => {
invalidateAllCaches(); invalidateAllCaches();
} else if (target === 'RUNDOWN') { } else if (target === 'RUNDOWN') {
const { revision } = payload; const { revision } = payload;
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1; const currentRevision = ontimeQueryClient.getQueryData<Rundown>(RUNDOWN)?.revision ?? -1;
if (revision > currentRevision) { if (revision > currentRevision) {
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS }); ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
@@ -215,8 +164,8 @@ export const connectSocket = () => {
} }
break; break;
} }
case 'ontime-flush': { default: {
flushBatchUpdates(); console.log('unknown WS message', type);
break; break;
} }
} }
@@ -226,11 +175,6 @@ export const connectSocket = () => {
}; };
}; };
export const disconnectSocket = () => {
shouldReconnect = false;
websocket?.close();
};
export const socketSend = (message: any) => { export const socketSend = (message: any) => {
if (websocket && websocket.readyState === WebSocket.OPEN) { if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(message); websocket.send(message);
+1 -1
View File
@@ -35,7 +35,7 @@ function getFormatFromParams() {
* Gets the format options from the applicaton settings * Gets the format options from the applicaton settings
* @returns a string equivalent to the format, ie: hh:mm:ss a or HH:mm:ss * @returns a string equivalent to the format, ie: hh:mm:ss a or HH:mm:ss
*/ */
export function getFormatFromSettings(): TimeFormat { function getFormatFromSettings(): TimeFormat {
const settings: Settings | undefined = ontimeQueryClient.getQueryData(APP_SETTINGS); const settings: Settings | undefined = ontimeQueryClient.getQueryData(APP_SETTINGS);
return settings?.timeFormat ?? '24'; return settings?.timeFormat ?? '24';
} }
-11
View File
@@ -1,11 +0,0 @@
import { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers';
import 'vitest';
// ugly hack because vite and pnpm are not playing ball with jest
// https://github.com/testing-library/jest-dom/issues/123
declare global {
namespace Vi {
type Assertion<T = any> = TestingLibraryMatchers<T, void>;
}
}
@@ -29,8 +29,8 @@ export default function ReportSettings() {
}; };
const combinedReport = useMemo(() => { const combinedReport = useMemo(() => {
return getCombinedReport(reportData, data.rundown, data.order); return getCombinedReport(reportData, data.entries, data.order);
}, [reportData, data.rundown, data.order]); }, [reportData, data.entries, data.order]);
return ( return (
<Panel.Section> <Panel.Section>
@@ -1,4 +1,4 @@
import { isOntimeEvent, MaybeNumber, NormalisedRundown, OntimeReport } from 'ontime-types'; import { EntryId, isOntimeEvent, MaybeNumber, OntimeReport, RundownEntries } from 'ontime-types';
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv'; import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
import { formatTime } from '../../../../common/utils/time'; import { formatTime } from '../../../../common/utils/time';
@@ -16,7 +16,7 @@ export type CombinedReport = {
/** /**
* Creates a combined report with the rundown data * Creates a combined report with the rundown data
*/ */
export function getCombinedReport(report: OntimeReport, rundown: NormalisedRundown, order: string[]): CombinedReport[] { export function getCombinedReport(report: OntimeReport, rundown: RundownEntries, order: EntryId[]): CombinedReport[] {
if (Object.keys(report).length === 0) return []; if (Object.keys(report).length === 0) return [];
if (order.length === 0) return []; if (order.length === 0) return [];
@@ -13,10 +13,6 @@ import * as Panel from '../../panel-utils/PanelUtils';
import GeneralPinInput from './GeneralPinInput'; import GeneralPinInput from './GeneralPinInput';
export type GeneralPanelFormValues = {
filename: string;
};
export default function GeneralPanelForm() { export default function GeneralPanelForm() {
const { data, status, refetch } = useSettings(); const { data, status, refetch } = useSettings();
const { const {
@@ -102,7 +102,6 @@ export default function EditorSettingsForm() {
onChange={(event) => setDefaultEndAction(event.target.value as EndAction)} onChange={(event) => setDefaultEndAction(event.target.value as EndAction)}
> >
<option value={EndAction.None}>None</option> <option value={EndAction.None}>None</option>
<option value={EndAction.Stop}>Stop</option>
<option value={EndAction.LoadNext}>Load next</option> <option value={EndAction.LoadNext}>Load next</option>
<option value={EndAction.PlayNext}>Play next</option> <option value={EndAction.PlayNext}>Play next</option>
</Select> </Select>
@@ -6,7 +6,7 @@ export async function makeProjectPatch(data: DatabaseModel, mergeKeys: Record<st
for (const key in mergeKeys) { for (const key in mergeKeys) {
if (isKeyOfType(key, data) && mergeKeys[key]) { if (isKeyOfType(key, data) && mergeKeys[key]) {
// if the rundown is merged we also need the custom fields // if the rundown is merged we also need the custom fields
if (key === 'rundown') { if (key === 'rundowns') {
patchObject.customFields = data['customFields']; patchObject.customFields = data['customFields'];
} }
Object.assign(patchObject, { [key]: data[key] }); Object.assign(patchObject, { [key]: data[key] });
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { CustomFields, OntimeRundown } from 'ontime-types'; import { CustomFields, Rundown } from 'ontime-types';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -9,7 +9,7 @@ import useGoogleSheet from './useGoogleSheet';
import { useSheetStore } from './useSheetStore'; import { useSheetStore } from './useSheetStore';
interface ImportReviewProps { interface ImportReviewProps {
rundown: OntimeRundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
onFinished: () => void; onFinished: () => void;
onCancel: () => void; onCancel: () => void;
@@ -29,7 +29,12 @@ export default function ImportReview(props: ImportReviewProps) {
const applyImport = async () => { const applyImport = async () => {
setLoading(true); setLoading(true);
await importRundown(rundown, customFields); await importRundown(
{
[rundown.id]: rundown,
},
customFields,
);
setLoading(false); setLoading(false);
onFinished(); onFinished();
}; };
@@ -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
export const namedImportMap = { const namedImportMap = {
Worksheet: 'event schedule', Worksheet: 'event schedule',
Start: 'time start', Start: 'time start',
'Link start': 'link start', 'Link start': 'link start',
@@ -1,6 +1,6 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { IoLink } from 'react-icons/io5'; import { IoLink } from 'react-icons/io5';
import { CustomFields, isOntimeBlock, isOntimeEvent, OntimeRundown } from 'ontime-types'; import { CustomFields, isOntimeBlock, isOntimeEvent, Rundown } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import Tag from '../../../../../common/components/tag/Tag'; import Tag from '../../../../../common/components/tag/Tag';
@@ -10,7 +10,7 @@ import * as Panel from '../../../panel-utils/PanelUtils';
import style from './PreviewRundown.module.scss'; import style from './PreviewRundown.module.scss';
interface PreviewRundownProps { interface PreviewRundownProps {
rundown: OntimeRundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
} }
@@ -53,75 +53,76 @@ export default function PreviewRundown(props: PreviewRundownProps) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rundown.map((event) => { {rundown.order.map((entryId) => {
if (isOntimeBlock(event)) { const entry = rundown.entries[entryId];
if (isOntimeBlock(entry)) {
return ( return (
<tr key={event.id}> <tr key={entry.id}>
<td className={style.center}> <td className={style.center}>
<Tag>-</Tag> <Tag>-</Tag>
</td> </td>
<td className={style.center}> <td className={style.center}>
<Tag>{event.type}</Tag> <Tag>{entry.type}</Tag>
</td> </td>
<td /> <td />
<td colSpan={99}>{event.title}</td> <td colSpan={99}>{entry.title}</td>
</tr> </tr>
); );
} }
if (!isOntimeEvent(event)) { if (!isOntimeEvent(entry)) {
return null; return null;
} }
eventIndex += 1; eventIndex += 1;
const colour = event.colour ? getAccessibleColour(event.colour) : {}; const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
const countToEnd = booleanToText(event.countToEnd); const countToEnd = booleanToText(entry.countToEnd);
const isPublic = booleanToText(event.isPublic); const isPublic = booleanToText(entry.isPublic);
const skip = booleanToText(event.skip); const skip = booleanToText(entry.skip);
return ( return (
<Fragment key={event.id}> <Fragment key={entry.id}>
<tr> <tr>
<td className={style.center}> <td className={style.center}>
<Tag>{eventIndex}</Tag> <Tag>{eventIndex}</Tag>
</td> </td>
<td className={style.center}> <td className={style.center}>
<Tag>{event.type}</Tag> <Tag>{entry.type}</Tag>
</td> </td>
<td className={style.nowrap}>{event.cue}</td> <td className={style.nowrap}>{entry.cue}</td>
<td>{event.title}</td> <td>{entry.title}</td>
<td className={style.flex}> <td className={style.flex}>
<span className={event.linkStart ? style.subdued : undefined}>{millisToString(event.timeStart)}</span> <span className={entry.linkStart ? style.subdued : undefined}>{millisToString(entry.timeStart)}</span>
{event.linkStart && <IoLink className={style.linkStartActive} />} {entry.linkStart && <IoLink className={style.linkStartActive} />}
</td> </td>
<td>{millisToString(event.timeEnd)}</td> <td>{millisToString(entry.timeEnd)}</td>
<td>{millisToString(event.duration)}</td> <td>{millisToString(entry.duration)}</td>
<td>{millisToString(event.timeWarning)}</td> <td>{millisToString(entry.timeWarning)}</td>
<td>{millisToString(event.timeDanger)}</td> <td>{millisToString(entry.timeDanger)}</td>
<td className={style.center}>{countToEnd && <Tag>{countToEnd}</Tag>}</td> <td className={style.center}>{countToEnd && <Tag>{countToEnd}</Tag>}</td>
<td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td> <td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td>
<td>{skip && <Tag>{skip}</Tag>}</td> <td>{skip && <Tag>{skip}</Tag>}</td>
<td style={{ ...colour }}>{event.colour}</td> <td style={{ ...colour }}>{entry.colour}</td>
<td className={style.center}> <td className={style.center}>
<Tag>{event.timerType}</Tag> <Tag>{entry.timerType}</Tag>
</td> </td>
<td className={style.center}> <td className={style.center}>
<Tag>{event.endAction}</Tag> <Tag>{entry.endAction}</Tag>
</td> </td>
{isOntimeEvent(event) && {isOntimeEvent(entry) &&
fieldKeys.map((field) => { fieldKeys.map((field) => {
let value = ''; let value = '';
if (field in event.custom) { if (field in entry.custom) {
value = event.custom[field]; value = entry.custom[field];
} }
return <td key={field}>{value}</td>; return <td key={field}>{value}</td>;
})} })}
<td className={style.center}> <td className={style.center}>
<Tag>{event.id}</Tag> <Tag>{entry.id}</Tag>
</td> </td>
</tr> </tr>
{event.note && ( {entry.note && (
<tr> <tr>
<td colSpan={99} className={style.secondaryRow}> <td colSpan={99} className={style.secondaryRow}>
Note: {event.note} Note: {entry.note}
</td> </td>
</tr> </tr>
)} )}
@@ -1,5 +1,5 @@
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types'; import { AuthenticationStatus, CustomFields, ProjectRundowns } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../common/api/constants'; import { CUSTOM_FIELDS, RUNDOWN } from '../../../../common/api/constants';
@@ -75,9 +75,9 @@ export default function useGoogleSheet() {
}; };
/** applies rundown and customFields to current project */ /** applies rundown and customFields to current project */
const importRundown = async (rundown: OntimeRundown, customFields: CustomFields) => { const importRundown = async (rundowns: ProjectRundowns, customFields: CustomFields) => {
try { try {
await patchData({ rundown, customFields }); await patchData({ rundowns, customFields });
// we are unable to optimistically set the rundown since we need // we are unable to optimistically set the rundown since we need
// it to be normalised // it to be normalised
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
@@ -1,4 +1,4 @@
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types'; import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types';
import { defaultImportMap, ImportMap } from 'ontime-utils'; import { defaultImportMap, ImportMap } from 'ontime-utils';
import { create } from 'zustand'; import { create } from 'zustand';
@@ -15,8 +15,8 @@ type SheetStore = {
setAuthenticationStatus: (status: AuthenticationStatus) => void; setAuthenticationStatus: (status: AuthenticationStatus) => void;
// we get this from a preview response // we get this from a preview response
rundown: OntimeRundown | null; rundown: Rundown | null;
setRundown: (rundown: OntimeRundown | null) => void; setRundown: (rundown: Rundown | null) => void;
// we get this from a preview response // we get this from a preview response
customFields: CustomFields | null; customFields: CustomFields | null;
@@ -60,7 +60,7 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
setAuthenticationStatus: (status: AuthenticationStatus) => set({ authenticationStatus: status }), setAuthenticationStatus: (status: AuthenticationStatus) => set({ authenticationStatus: status }),
setRundown: (rundown: OntimeRundown | null) => set({ rundown }), setRundown: (rundown: Rundown | null) => set({ rundown }),
setCustomFields: (customFields: CustomFields | null) => set({ customFields }), setCustomFields: (customFields: CustomFields | null) => set({ customFields }),
@@ -2,7 +2,7 @@ import { IoAdd, IoRemove } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { Playback } from 'ontime-types'; import { Playback } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils'; import { MILLIS_PER_HOUR, parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput'; import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { setPlayback } from '../../../../common/hooks/useSocket'; import { setPlayback } from '../../../../common/hooks/useSocket';
@@ -17,29 +17,29 @@ interface AddTimeProps {
export default function AddTime(props: AddTimeProps) { export default function AddTime(props: AddTimeProps) {
const { playback } = props; const { playback } = props;
const [time, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes const [timeInMs, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes
const handleTimeChange = (_field: string, value: string) => { const handleTimeChange = (_field: string, value: string) => {
const newTime = parseUserTime(value); const newTimeInMs = parseUserTime(value);
// cap add time to 1 hour // cap add time to 1 hour
setTime(Math.min(newTime, MILLIS_PER_HOUR)); setTime(Math.min(newTimeInMs, MILLIS_PER_HOUR));
}; };
const handleAddTime = (direction: 'add' | 'remove') => { const handleAddTime = (direction: 'add' | 'remove') => {
// API expects input in seconds // API expects input in milliseconds
if (direction === 'add') { if (direction === 'add') {
setPlayback.addTime(time / MILLIS_PER_SECOND); setPlayback.addTime(timeInMs);
} else { } else {
setPlayback.addTime((-1 * time) / MILLIS_PER_SECOND); setPlayback.addTime(-1 * timeInMs);
} }
}; };
const canAddTime = playback === Playback.Play || playback === Playback.Pause; const canAddTime = playback === Playback.Play || playback === Playback.Pause;
const doDisableButtons = !canAddTime || time === 0; const doDisableButtons = !canAddTime || timeInMs === 0;
return ( return (
<div className={style.addTime}> <div className={style.addTime}>
<TimeInput name='addtime' submitHandler={handleTimeChange} time={time} placeholder='Add time' /> <TimeInput name='addtime' submitHandler={handleTimeChange} time={timeInMs} placeholder='Add time' />
<div className={style.addButtons}> <div className={style.addButtons}>
<Tooltip label='Remove time' openDelay={tooltipDelayMid} shouldWrapChildren> <Tooltip label='Remove time' openDelay={tooltipDelayMid} shouldWrapChildren>
<TapButton onClick={() => handleAddTime('remove')} disabled={doDisableButtons} className={style.tallButtons}> <TapButton onClick={() => handleAddTime('remove')} disabled={doDisableButtons} className={style.tallButtons}>
@@ -59,15 +59,20 @@ export function AuxTimer() {
} }
function AuxTimerInput() { function AuxTimerInput() {
const time = useAuxTimerTime(); const newTimeInMs = useAuxTimerTime();
const { setDuration } = setAuxTimer; const { setDuration } = setAuxTimer;
const handleTimeUpdate = (_field: string, value: string) => { const handleTimeUpdate = (_field: string, value: string) => {
const newTime = parseUserTime(value); const newTimeInMs = parseUserTime(value);
setDuration(newTime / 1000); // frontend api is seconds based setDuration(newTimeInMs);
}; };
return ( return (
<TimeInput<'auxTimer'> submitHandler={handleTimeUpdate} name='auxTimer' time={time} placeholder='Aux Timer 1' /> <TimeInput<'auxTimer'>
submitHandler={handleTimeUpdate}
name='auxTimer'
time={newTimeInMs}
placeholder='Aux Timer 1'
/>
); );
} }
+1 -8
View File
@@ -10,7 +10,6 @@ import AppSettings from '../app-settings/AppSettings';
import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation'; import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation';
import { EditorOverview } from '../overview/Overview'; import { EditorOverview } from '../overview/Overview';
import Finder from './finder/Finder';
import WelcomePlacement from './welcome/WelcomePlacement'; import WelcomePlacement from './welcome/WelcomePlacement';
import styles from './Editor.module.scss'; import styles from './Editor.module.scss';
@@ -22,7 +21,6 @@ const MessageControl = lazy(() => import('../control/message/MessageControlExpor
export default function Editor() { export default function Editor() {
const { isOpen: isSettingsOpen, setLocation, close } = useAppSettingsNavigation(); const { isOpen: isSettingsOpen, setLocation, close } = useAppSettingsNavigation();
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure(); const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isFinderOpen, onToggle: onFinderToggle, onClose: onFinderClose } = useDisclosure();
useWindowTitle('Editor'); useWindowTitle('Editor');
@@ -46,16 +44,11 @@ export default function Editor() {
} }
}, [close, isSettingsOpen, setLocation]); }, [close, isSettingsOpen, setLocation]);
useHotkeys([ useHotkeys([['mod + ,', toggleSettings]]);
['mod + ,', toggleSettings],
['mod + f', onFinderToggle],
['Escape', onFinderClose],
]);
return ( return (
<div className={styles.mainContainer} data-testid='event-editor'> <div className={styles.mainContainer} data-testid='event-editor'>
<WelcomePlacement /> <WelcomePlacement />
<Finder isOpen={isFinderOpen} onClose={onFinderClose} />
<NavigationMenu isOpen={isMenuOpen} onClose={onClose} /> <NavigationMenu isOpen={isMenuOpen} onClose={onClose} />
<EditorOverview> <EditorOverview>
<IconButton <IconButton
@@ -1,7 +1,7 @@
import { KeyboardEvent, useState } from 'react'; import { KeyboardEvent, useState } from 'react';
import { Input, Modal, ModalBody, ModalContent, ModalFooter, ModalOverlay } from '@chakra-ui/react'; import { Input, Modal, ModalBody, ModalContent, ModalFooter, ModalOverlay } from '@chakra-ui/react';
import { useDebouncedCallback } from '@mantine/hooks'; import { useDebouncedCallback } from '@mantine/hooks';
import { SupportedEvent } from 'ontime-types'; import { SupportedEntry } from 'ontime-types';
import { useEventSelection } from '../../rundown/useEventSelection'; import { useEventSelection } from '../../rundown/useEventSelection';
@@ -67,9 +67,9 @@ export default function Finder(props: FinderProps) {
{results.length > 0 && {results.length > 0 &&
results.map((entry, index) => { results.map((entry, index) => {
const isSelected = selected === index; const isSelected = selected === index;
const displayIndex = entry.type === SupportedEvent.Event ? entry.eventIndex : '-'; const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-';
const displayCue = entry.type === SupportedEvent.Event ? entry.cue : ''; const displayCue = entry.type === SupportedEntry.Event ? entry.cue : '';
const colour = entry.type === SupportedEvent.Event ? entry.colour : ''; const colour = entry.type === SupportedEntry.Event ? entry.colour : '';
return ( return (
<li <li
@@ -1,19 +1,19 @@
import { ChangeEvent, useEffect, useRef, useState } from 'react'; import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { isOntimeBlock, isOntimeEvent, MaybeString, SupportedEvent } from 'ontime-types'; import { isOntimeBlock, isOntimeEvent, MaybeString, SupportedEntry } from 'ontime-types';
import { useFlatRundown } from '../../../common/hooks-query/useRundown'; import { useFlatRundown } from '../../../common/hooks-query/useRundown';
const maxResults = 12; const maxResults = 12;
type FilterableBlock = { type FilterableBlock = {
type: SupportedEvent.Block; type: SupportedEntry.Block;
id: string; id: string;
index: number; index: number;
title: string; title: string;
}; };
type FilterableEvent = { type FilterableEvent = {
type: SupportedEvent.Event; type: SupportedEntry.Event;
id: string; id: string;
index: number; index: number;
eventIndex: number; eventIndex: number;
@@ -59,7 +59,7 @@ export default function useFinder() {
if (isOntimeEvent(event)) { if (isOntimeEvent(event)) {
if (eventIndex === searchIndex) { if (eventIndex === searchIndex) {
results.push({ results.push({
type: SupportedEvent.Event, type: SupportedEntry.Event,
id: event.id, id: event.id,
index: i, index: i,
eventIndex, eventIndex,
@@ -93,7 +93,7 @@ export default function useFinder() {
if (event.cue.toLowerCase().includes(searchString)) { if (event.cue.toLowerCase().includes(searchString)) {
remaining--; remaining--;
results.push({ results.push({
type: SupportedEvent.Event, type: SupportedEntry.Event,
id: event.id, id: event.id,
index: i, index: i,
eventIndex, eventIndex,
@@ -126,7 +126,7 @@ export default function useFinder() {
if (event.title.toLowerCase().includes(searchString)) { if (event.title.toLowerCase().includes(searchString)) {
remaining--; remaining--;
results.push({ results.push({
type: SupportedEvent.Event, type: SupportedEntry.Event,
id: event.id, id: event.id,
index: i, index: i,
eventIndex, eventIndex,
@@ -141,7 +141,7 @@ export default function useFinder() {
if (event.title.toLowerCase().includes(searchString)) { if (event.title.toLowerCase().includes(searchString)) {
remaining--; remaining--;
results.push({ results.push({
type: SupportedEvent.Block, type: SupportedEntry.Block,
id: event.id, id: event.id,
index: i, index: i,
title: event.title, title: event.title,
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types'; import { isOntimeEvent, OntimeEvent, SupportedEntry } from 'ontime-types';
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils'; import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
@@ -126,8 +126,8 @@ export default function Operator() {
let isPast = Boolean(featureData.selectedEventId); let isPast = Boolean(featureData.selectedEventId);
const hidePast = isStringBoolean(searchParams.get('hidepast')); const hidePast = isStringBoolean(searchParams.get('hidepast'));
const { firstEvent } = getFirstEventNormal(data.rundown, data.order); const { firstEvent } = getFirstEventNormal(data.entries, data.order);
const { lastEvent } = getLastEventNormal(data.rundown, data.order); const { lastEvent } = getLastEventNormal(data.entries, data.order);
return ( return (
<div className={style.operatorContainer}> <div className={style.operatorContainer}>
@@ -152,7 +152,7 @@ export default function Operator() {
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> <div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{data.order.map((eventId) => { {data.order.map((eventId) => {
const entry = data.rundown[eventId]; const entry = data.entries[eventId];
if (isOntimeEvent(entry)) { if (isOntimeEvent(entry)) {
const isSelected = featureData.selectedEventId === entry.id; const isSelected = featureData.selectedEventId === entry.id;
if (isSelected) { if (isSelected) {
@@ -197,7 +197,7 @@ export default function Operator() {
); );
} }
if (entry.type === SupportedEvent.Block) { if (entry.type === SupportedEntry.Block) {
return <OperatorBlock key={entry.id} title={entry.title} />; return <OperatorBlock key={entry.id} title={entry.title} />;
} }
return null; return null;
@@ -2,7 +2,7 @@ import { useRef, useState } from 'react';
import { Button, Textarea } from '@chakra-ui/react'; import { Button, Textarea } from '@chakra-ui/react';
import { OntimeEvent } from 'ontime-types'; import { OntimeEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import type { EditEvent } from '../Operator'; import type { EditEvent } from '../Operator';
import style from './EditModal.module.scss'; import style from './EditModal.module.scss';
@@ -15,7 +15,7 @@ interface EditModalProps {
export default function EditModal(props: EditModalProps) { export default function EditModal(props: EditModalProps) {
const { event, onClose } = props; const { event, onClose } = props;
const { updateEvent } = useEventAction(); const { updateEntry } = useEntryActions();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLTextAreaElement[]>(new Array<HTMLTextAreaElement>()); const inputRef = useRef<HTMLTextAreaElement[]>(new Array<HTMLTextAreaElement>());
@@ -36,7 +36,7 @@ export default function EditModal(props: EditModalProps) {
}); });
if (patchObject.custom) { if (patchObject.custom) {
await updateEvent(patchObject); await updateEntry(patchObject);
} }
setLoading(false); setLoading(false);
@@ -0,0 +1,11 @@
.empty {
padding-block: 1.5rem;
text-align: center;
}
.inline {
display: flex;
align-items: center;
justify-content: center;
gap: 2rem;
}
@@ -7,7 +7,6 @@
margin-top: 1rem; margin-top: 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-right: 4px; // size of scrollbar
overflow-y: scroll; overflow-y: scroll;
height: 100%; height: 100%;
} }
@@ -16,6 +15,7 @@
overflow-x: clip; overflow-x: clip;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-inline: 1rem;
} }
.empty { .empty {
@@ -23,15 +23,6 @@
align-self: center; align-self: center;
} }
.alignCenter {
text-align: center;
flex-direction: column;
.spaceTop {
margin-top: 1.5rem;
}
}
.spacer { .spacer {
min-height: 50vh; min-height: 50vh;
} }
@@ -40,6 +31,7 @@
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
background-color: color-mix(in srgb, var(--user-bg, transparent) 10%, transparent 90%);
} }
.entryIndex { .entryIndex {
+288 -146
View File
@@ -1,36 +1,48 @@
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react'; import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useHotkeys } from '@mantine/hooks';
import { import {
closestCenter,
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useHotkeys, useSessionStorage } from '@mantine/hooks';
import {
type EntryId,
type MaybeString,
type Rundown,
isOntimeBlock, isOntimeBlock,
isOntimeEvent, isOntimeEvent,
isPlayableEvent, OntimeBlock,
MaybeString, OntimeEntry,
PlayableEvent,
Playback, Playback,
RundownCached, SupportedEntry,
SupportedEvent,
} from 'ontime-types'; } from 'ontime-types';
import { import {
checkIsNextDay,
getFirstNormal, getFirstNormal,
getLastNormal, getLastNormal,
getNextBlockNormal, getNextBlockNormal,
getNextNormal, getNextNormal,
getPreviousBlockNormal, getPreviousBlockNormal,
getPreviousNormal, getPreviousNormal,
isNewLatest, reorderArray,
} from 'ontime-utils'; } from 'ontime-utils';
import { type EventOptions, useEventAction } from '../../common/hooks/useEventAction'; import { type EventOptions, useEntryActions } from '../../common/hooks/useEntryAction';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket'; import { useRundownEditor } from '../../common/hooks/useSocket';
import { AppMode, useAppMode } from '../../common/stores/appModeStore'; import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { useEntryCopy } from '../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { cloneEvent } from '../../common/utils/eventsManager'; import { cloneEvent } from '../../common/utils/clone';
import BlockBlock from './block-block/BlockBlock';
import BlockEnd from './block-block/BlockEnd';
import QuickAddBlock from './quick-add-block/QuickAddBlock'; import QuickAddBlock from './quick-add-block/QuickAddBlock';
import { makeRundownMetadata, makeSortableList, moveDown, moveUp } from './rundown.utils';
import RundownEmpty from './RundownEmpty'; import RundownEmpty from './RundownEmpty';
import { useEventSelection } from './useEventSelection'; import { useEventSelection } from './useEventSelection';
@@ -39,15 +51,21 @@ import style from './Rundown.module.scss';
const RundownEntry = lazy(() => import('./RundownEntry')); const RundownEntry = lazy(() => import('./RundownEntry'));
interface RundownProps { interface RundownProps {
data: RundownCached; data: Rundown;
} }
export default function Rundown({ data }: RundownProps) { export default function Rundown({ data }: RundownProps) {
const { order, rundown } = data; const { order, entries, id } = data;
const [statefulEntries, setStatefulEntries] = useState(order); // we create a copy of the rundown with a data structured aligned with what dnd-kit needs
const featureData = useRundownEditor(); const featureData = useRundownEditor();
const { addEvent, reorderEvent, deleteEvent } = useEventAction(); const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries));
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
// we ensure that this is unique to the rundown
key: `rundown.${id}-editor-collapsed-groups`,
defaultValue: [],
});
const { addEntry, reorderEntry, deleteEntry } = useEntryActions();
const { entryCopyId, setEntryCopyId } = useEntryCopy(); const { entryCopyId, setEntryCopyId } = useEntryCopy();
@@ -60,39 +78,39 @@ export default function Rundown({ data }: RundownProps) {
useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: appMode === AppMode.Run }); useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: appMode === AppMode.Run });
// DND KIT // DND KIT
const sensors = useSensors(useSensor(PointerSensor)); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
const deleteAtCursor = useCallback( const deleteAtCursor = useCallback(
(cursor: string | null) => { (cursor: string | null) => {
if (!cursor) return; if (!cursor) return;
const { entry, index } = getPreviousNormal(rundown, order, cursor); const { entry, index } = getPreviousNormal(entries, order, cursor);
deleteEvent([cursor]); deleteEntry([cursor]);
if (entry && index !== null) { if (entry && index !== null) {
setSelectedEvents({ id: entry.id, selectMode: 'click', index }); setSelectedEvents({ id: entry.id, selectMode: 'click', index });
} }
}, },
[rundown, order, deleteEvent, setSelectedEvents], [entries, order, deleteEntry, setSelectedEvents],
); );
const insertCopyAtId = useCallback( const insertCopyAtId = useCallback(
(atId: string | null, copyId: string | null, above = false) => { (atId: string | null, copyId: string | null, above = false) => {
const adjustedCursor = above ? getPreviousNormal(rundown, order, atId ?? '').entry?.id ?? null : atId; const adjustedCursor = above ? getPreviousNormal(entries, order, atId ?? '').entry?.id ?? null : atId;
if (copyId === null) { if (copyId === null) {
// we cant clone without selection // we cant clone without selection
return; return;
} }
const cloneEntry = rundown[copyId]; const cloneEntry = entries[copyId];
if (cloneEntry?.type === SupportedEvent.Event) { if (cloneEntry?.type === SupportedEntry.Event) {
//if we don't have a cursor add the new event on top //if we don't have a cursor add the new event on top
const newEvent = cloneEvent(cloneEntry); const newEvent = cloneEvent(cloneEntry);
addEvent(newEvent, { after: adjustedCursor ?? undefined }); addEntry(newEvent, { after: adjustedCursor ?? undefined });
} }
}, },
[addEvent, order, rundown], [addEntry, order, entries],
); );
const insertAtId = useCallback( const insertAtId = useCallback(
(type: SupportedEvent, id: MaybeString, above = false) => { (patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: MaybeString, above = false) => {
const options: EventOptions = const options: EventOptions =
id === null id === null
? {} ? {}
@@ -101,19 +119,12 @@ export default function Rundown({ data }: RundownProps) {
before: above ? id : undefined, before: above ? id : undefined,
}; };
if (type === SupportedEvent.Event) { if (!above && id) {
const newEvent = { options.lastEventId = id;
type: SupportedEvent.Event,
};
if (!above && id) {
options.lastEventId = id;
}
addEvent(newEvent, options);
} else {
addEvent({ type }, options);
} }
addEntry(patch, options);
}, },
[addEvent], [addEntry],
); );
const selectBlock = useCallback( const selectBlock = useCallback(
@@ -124,7 +135,7 @@ export default function Rundown({ data }: RundownProps) {
let newCursor = cursor; let newCursor = cursor;
if (cursor === null) { if (cursor === null) {
// there is no cursor, we select the first or last depending on direction // there is no cursor, we select the first or last depending on direction
const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order); const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);
if (isOntimeBlock(selected)) { if (isOntimeBlock(selected)) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 }); setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
@@ -140,14 +151,14 @@ export default function Rundown({ data }: RundownProps) {
// otherwise we select the next or previous // otherwise we select the next or previous
const selected = const selected =
direction === 'up' direction === 'up'
? getPreviousBlockNormal(rundown, order, newCursor) ? getPreviousBlockNormal(entries, order, newCursor)
: getNextBlockNormal(rundown, order, newCursor); : getNextBlockNormal(entries, order, newCursor);
if (selected.entry !== null && selected.index !== null) { if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index }); setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
} }
}, },
[order, rundown, setSelectedEvents], [order, entries, setSelectedEvents],
); );
const selectEntry = useCallback( const selectEntry = useCallback(
@@ -158,7 +169,7 @@ export default function Rundown({ data }: RundownProps) {
if (cursor === null) { if (cursor === null) {
// there is no cursor, we select the first or last depending on direction if it exists // there is no cursor, we select the first or last depending on direction if it exists
const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order); const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);
if (selected !== null) { if (selected !== null) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 }); setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
} }
@@ -167,29 +178,36 @@ export default function Rundown({ data }: RundownProps) {
// otherwise we select the next or previous // otherwise we select the next or previous
const selected = const selected =
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor); direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor);
if (selected.entry !== null && selected.index !== null) { if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index }); setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
} }
}, },
[order, rundown, setSelectedEvents], [order, entries, setSelectedEvents],
); );
const moveEntry = useCallback( const moveEntry = useCallback(
(cursor: string | null, direction: 'up' | 'down') => { (cursor: EntryId | null, direction: 'up' | 'down') => {
if (order.length < 2 || cursor == null) { if (sortableData.length < 2 || cursor == null) {
return; return;
} }
const { index } =
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor);
if (index !== null) { const { destinationId, order, isBlock } =
const offsetIndex = direction === 'up' ? index + 1 : index - 1; direction === 'up' ? moveUp(cursor, sortableData, entries) : moveDown(cursor, sortableData, entries);
reorderEvent(cursor, offsetIndex, index);
if (!destinationId) {
return;
} }
// if we are moving into a block, we need to make sure it is expanded
if (isBlock) {
handleCollapseGroup(false, destinationId);
}
reorderEntry(cursor, destinationId, order as 'before' | 'after' | 'insert');
}, },
[order, reorderEvent, rundown], [sortableData, reorderEntry],
); );
// shortcuts // shortcuts
@@ -207,14 +225,14 @@ export default function Rundown({ data }: RundownProps) {
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }], ['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
['alt + E', () => insertAtId(SupportedEvent.Event, cursor), { preventDefault: true }], ['alt + E', () => insertAtId({ type: SupportedEntry.Event }, cursor), { preventDefault: true }],
['alt + shift + E', () => insertAtId(SupportedEvent.Event, cursor, true), { preventDefault: true }], ['alt + shift + E', () => insertAtId({ type: SupportedEntry.Event }, cursor, true), { preventDefault: true }],
['alt + B', () => insertAtId(SupportedEvent.Block, cursor), { preventDefault: true }], ['alt + B', () => insertAtId({ type: SupportedEntry.Block }, cursor), { preventDefault: true }],
['alt + shift + B', () => insertAtId(SupportedEvent.Block, cursor, true), { preventDefault: true }], ['alt + shift + B', () => insertAtId({ type: SupportedEntry.Block }, cursor, true), { preventDefault: true }],
['alt + D', () => insertAtId(SupportedEvent.Delay, cursor), { preventDefault: true }], ['alt + D', () => insertAtId({ type: SupportedEntry.Delay }, cursor), { preventDefault: true }],
['alt + shift + D', () => insertAtId(SupportedEvent.Delay, cursor, true), { preventDefault: true }], ['alt + shift + D', () => insertAtId({ type: SupportedEntry.Delay }, cursor, true), { preventDefault: true }],
['mod + C', () => setEntryCopyId(cursor)], ['mod + C', () => setEntryCopyId(cursor)],
['mod + V', () => insertCopyAtId(cursor, entryCopyId)], ['mod + V', () => insertCopyAtId(cursor, entryCopyId)],
@@ -226,11 +244,11 @@ export default function Rundown({ data }: RundownProps) {
// we copy the state from the store here // we copy the state from the store here
// to workaround async updates on the drag mutations // to workaround async updates on the drag mutations
useEffect(() => { useEffect(() => {
setStatefulEntries(order); setSortableData(makeSortableList(order, entries));
}, [order]); }, [order, entries]);
// in run mode, we follow selection
useEffect(() => { useEffect(() => {
// in run mode, we follow selection
if (appMode !== AppMode.Run || !featureData?.selectedEventId) { if (appMode !== AppMode.Run || !featureData?.selectedEventId) {
return; return;
} }
@@ -238,111 +256,235 @@ export default function Rundown({ data }: RundownProps) {
setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index }); setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
}, [appMode, featureData.selectedEventId, order, setSelectedEvents]); }, [appMode, featureData.selectedEventId, order, setSelectedEvents]);
/**
* Checks whether a block is collapsed
*/
const getIsCollapsed = useCallback(
(blockId: EntryId): boolean => {
return Boolean(collapsedGroups.find((id) => id === blockId));
},
[collapsedGroups],
);
/**
* Handles logic for collapsing groups
*/
const handleCollapseGroup = useCallback(
(collapsed: boolean, groupId: EntryId) => {
setCollapsedGroups((prev) => {
const isCollapsed = getIsCollapsed(groupId);
if (collapsed && !isCollapsed) {
const newSet = new Set(prev).add(groupId);
return [...newSet];
}
if (!collapsed && isCollapsed) {
return [...prev].filter((id) => id !== groupId);
}
return prev;
});
},
[getIsCollapsed, setCollapsedGroups],
);
/**
* On drag end, we reorder the events
*/
const handleOnDragEnd = (event: DragEndEvent) => { const handleOnDragEnd = (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
if (over?.id) { if (!over?.id || active.id === over.id) {
if (active.id !== over?.id) { return;
const fromIndex = active.data.current?.sortable.index; }
const toIndex = over.data.current?.sortable.index;
// ugly hack to handle inconsistencies between dnd-kit and async store updates const fromIndex = active.data.current?.sortable.index;
setStatefulEntries((currentEntries) => { const toIndex = over.data.current?.sortable.index;
return arrayMove(currentEntries, fromIndex, toIndex);
}); // we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
reorderEvent(String(active.id), fromIndex, toIndex); setSortableData((currentEntries) => {
} return reorderArray(currentEntries, fromIndex, toIndex);
});
let destinationId = over.id as EntryId;
let order: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
/**
* We need to specially handle the end blocks
* Dragging before and end block will add the entry to the end of the block
* Dragging after an end block will add the event after the block itself
*/
if (destinationId.startsWith('end-')) {
destinationId = destinationId.replace('end-', '');
// if we are moving before the end, we use the insert operation
order = 'insert';
}
reorderEntry(active.id as EntryId, destinationId, order);
};
/**
* When we drag a block, we force collapse it
* This avoids strange scenarios like dropping a block inside itself
*/
const collapseDraggedBlocks = (event: DragStartEvent) => {
const isBlock = event.active.data.current?.type === 'block';
if (isBlock) {
handleCollapseGroup(true, event.active.id as EntryId);
} }
}; };
if (statefulEntries.length < 1) { /**
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />; * When we drag over a block, we expand it if it is collapsed
*/
const expandOverBlock = (event: DragOverEvent) => {
// if we are dragging a block, the drop operation is invalid so we dont expand
if (event.active.data.current?.type === 'block') {
return;
}
if (event.over?.data.current?.type !== 'block') {
return;
}
const blockId = event.over?.id as EntryId;
const isCollapsed = getIsCollapsed(blockId);
if (isCollapsed) {
handleCollapseGroup(false, blockId);
}
};
if (sortableData.length < 1) {
return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />;
} }
// last event is used to calculate relative timings // 1. gather presentation options
let lastEvent: PlayableEvent | undefined; // used by indicators
let thisEvent: PlayableEvent | undefined;
// previous entry is used to infer position in the rundown for new events
let previousEntryId: string | undefined;
let thisId = previousEntryId;
let eventIndex = 0;
// all events before the current selected are in the past
let isPast = Boolean(featureData?.selectedEventId);
let isNextDay = false;
let totalGap = 0;
const isEditMode = appMode === AppMode.Edit; const isEditMode = appMode === AppMode.Edit;
let isLinkedToLoaded = true; //check if the event can link all the way back to the currently playing event
// 2. initialise rundown metadata
const { metadata, process } = makeRundownMetadata(featureData?.selectedEventId);
// keep a single reference to the metadata which we override for every entry
let rundownMetadata = metadata;
return ( return (
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'> <div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}> <DndContext
<SortableContext items={statefulEntries} strategy={verticalListSortingStrategy}> onDragEnd={handleOnDragEnd}
onDragStart={collapseDraggedBlocks}
onDragOver={expandOverBlock}
sensors={sensors}
collisionDetection={closestCenter}
>
<SortableContext items={sortableData} strategy={verticalListSortingStrategy}>
<div className={style.list}> <div className={style.list}>
{statefulEntries.map((entryId, index) => { {sortableData.map((entryId, index) => {
// we iterate through a stateful copy of order to make the operations smoother const isFirst = index === 0;
// this means that this can be out of sync with order until the useEffect runs const isLast = index === sortableData.length - 1;
// instead of writing all the logic guards, we simply short circuit rendering here
const entry = rundown[entryId];
if (!entry) {
return null;
}
if (index === 0) {
eventIndex = 0;
}
isNextDay = false;
previousEntryId = thisId;
thisId = entryId;
if (isOntimeEvent(entry)) {
// event indexes are 1 based in frontend
eventIndex++;
lastEvent = thisEvent;
if (isPlayableEvent(entry)) { // the entry might be a pseudo block-end which does not generate metadata and should not be processed
isNextDay = checkIsNextDay(entry, lastEvent); if (entryId.startsWith('end-')) {
if (!isPast) { const parentId = entryId.split('end-')[1];
totalGap += entry.gap; const isBlockCollapsed = getIsCollapsed(parentId);
// We also include countToEnd in this test as the behavior of a linked event coming after a countToEnd is simelar to an unlinked event
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null && !lastEvent?.countToEnd; if (isBlockCollapsed && isEditMode && isLast) {
} return <QuickAddBlock key={entryId} previousEventId={parentId} parentBlock={null} />;
if (isNewLatest(entry, lastEvent)) { } else if (isBlockCollapsed) {
// populate previous entry return null;
thisEvent = entry; } else {
} const parentColour = (entries[parentId] as OntimeBlock | undefined)?.colour;
// if the previous element is selected, it will have its own QuickAddBlock
// we use thisId instead of previousEntryId because the block end does not process
// and it does not cause the reassignment of the iteration id to the previous entry
const showPrependingQuickAdd = isEditMode && cursor !== rundownMetadata.thisId;
return (
<Fragment key={entryId}>
{showPrependingQuickAdd && (
<QuickAddBlock
previousEventId={rundownMetadata.thisId}
parentBlock={parentId}
backgroundColor={parentColour}
/>
)}
<BlockEnd key={entryId} id={entryId} colour={parentColour} />
{isEditMode && isLast && <QuickAddBlock previousEventId={parentId} parentBlock={null} />}
</Fragment>
);
} }
} }
const isFirst = index === 0;
const isLast = index === order.length - 1; // we iterate through a stateful copy of order to make the dnd operations smoother
const isLoaded = featureData?.selectedEventId === entry.id; // this means that this can be out of sync with order until the useEffect runs
// instead of writing all the logic guards, we simply short circuit rendering here
const entry = entries[entryId];
if (!entry) return null;
rundownMetadata = process(entry);
// if the entry has a parent, and it is collapsed, render nothing
if (
entry.type !== SupportedEntry.Block &&
rundownMetadata.groupId !== null &&
getIsCollapsed(rundownMetadata.groupId)
) {
return null;
}
const isNext = featureData?.nextEventId === entry.id; const isNext = featureData?.nextEventId === entry.id;
const hasCursor = entry.id === cursor; const hasCursor = entry.id === cursor;
if (isLoaded) {
isPast = false; /**
} * Outside a block, the value will be undefined
* If the colour is empty string ''
* ie: we are inside a block, but there is no defined colour
* we default to $gray-1050 #303030
*/
const blockColour = rundownMetadata.groupColour === '' ? '#303030' : rundownMetadata.groupColour;
return ( return (
<Fragment key={entry.id}> <Fragment key={entry.id}>
{isEditMode && (hasCursor || isFirst) && <QuickAddBlock previousEventId={previousEntryId} />} {isEditMode && (hasCursor || isFirst) && (
<div className={style.entryWrapper} data-testid={`entry-${eventIndex}`}> <QuickAddBlock
{isOntimeEvent(entry) && <div className={style.entryIndex}>{eventIndex}</div>} previousEventId={rundownMetadata.previousEntryId}
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}> parentBlock={isFirst ? null : rundownMetadata.groupId}
<RundownEntry backgroundColor={isFirst ? undefined : blockColour}
type={entry.type} />
isPast={isPast} )}
eventIndex={eventIndex} {isOntimeBlock(entry) ? (
data={entry} <BlockBlock
loaded={isLoaded} data={entry}
hasCursor={hasCursor} hasCursor={hasCursor}
isNext={isNext} collapsed={getIsCollapsed(entry.id)}
previousEntryId={previousEntryId} onCollapse={handleCollapseGroup}
previousEventId={lastEvent?.id} />
playback={isLoaded ? featureData.playback : undefined} ) : (
isRolling={featureData.playback === Playback.Roll} <div
isNextDay={isNextDay} className={style.entryWrapper}
totalGap={totalGap} data-testid={`entry-${rundownMetadata.eventIndex}`}
isLinkedToLoaded={isLinkedToLoaded} style={blockColour ? { '--user-bg': blockColour } : {}}
/> >
{isOntimeEvent(entry) && <div className={style.entryIndex}>{rundownMetadata.eventIndex}</div>}
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry
type={entry.type}
isPast={rundownMetadata.isPast}
eventIndex={rundownMetadata.eventIndex}
data={entry}
loaded={rundownMetadata.isLoaded}
hasCursor={hasCursor}
isNext={isNext}
previousEntryId={rundownMetadata.previousEntryId}
previousEventId={rundownMetadata.previousEvent?.id}
playback={rundownMetadata.isLoaded ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
isNextDay={rundownMetadata.isNextDay}
totalGap={rundownMetadata.totalGap}
isLinkedToLoaded={rundownMetadata.isLinkedToLoaded}
/>
</div>
</div> </div>
</div> )}
{isEditMode && (hasCursor || isLast) && <QuickAddBlock previousEventId={entry.id} />} {isEditMode && (hasCursor || isLast) && (
<QuickAddBlock
previousEventId={entry.id}
parentBlock={rundownMetadata.groupId}
backgroundColor={blockColour}
/>
)}
</Fragment> </Fragment>
); );
})} })}
@@ -1,23 +1,30 @@
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { SupportedEntry } from 'ontime-types';
import Empty from '../../common/components/state/Empty'; import Empty from '../../common/components/state/Empty';
import style from './Rundown.module.scss'; import style from './Empty.module.scss';
interface RundownEmptyProps { interface RundownEmptyProps {
handleAddNew: () => void; handleAddNew: (type: SupportedEntry) => void;
} }
export default function RundownEmpty(props: RundownEmptyProps) { export default function RundownEmpty(props: RundownEmptyProps) {
const { handleAddNew } = props; const { handleAddNew } = props;
return ( return (
<div className={style.alignCenter}> <div className={style.empty}>
<Empty style={{ marginTop: '7vh' }} /> <Empty style={{ marginTop: '5vh', marginBottom: '3rem' }} />
<Button onClick={handleAddNew} variant='ontime-filled' className={style.spaceTop} leftIcon={<IoAdd />}> <div className={style.inline}>
Create Event <Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='ontime-filled' leftIcon={<IoAdd />}>
</Button> Create Event
</Button>
<Button onClick={() => handleAddNew(SupportedEntry.Block)} variant='ontime-filled' leftIcon={<IoAdd />}>
Create Block
</Button>
</div>
</div> </div>
); );
} }
@@ -1,40 +1,46 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types'; import {
isOntimeDelay,
isOntimeEvent,
MaybeString,
OntimeEntry,
OntimeEvent,
Playback,
SupportedEntry,
} from 'ontime-types';
import { useEventAction } from '../../common/hooks/useEventAction'; import { useEntryActions } from '../../common/hooks/useEntryAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn'; import useMemoisedFn from '../../common/hooks/useMemoisedFn';
import { useEmitLog } from '../../common/stores/logger'; import { useEmitLog } from '../../common/stores/logger';
import { cloneEvent } from '../../common/utils/eventsManager'; import { cloneEvent } from '../../common/utils/clone';
import BlockBlock from './block-block/BlockBlock';
import DelayBlock from './delay-block/DelayBlock'; import DelayBlock from './delay-block/DelayBlock';
import EventBlock from './event-block/EventBlock'; import EventBlock from './event-block/EventBlock';
import { useEventSelection } from './useEventSelection'; import { useEventSelection } from './useEventSelection';
export type EventItemActions = export type EventItemActions =
| 'set-cursor'
| 'event' | 'event'
| 'event-before' | 'event-before'
| 'delay' | 'delay'
| 'delay-before' | 'delay-before'
| 'block' | 'block'
| 'block-before' | 'block-before'
| 'swap'
| 'delete' | 'delete'
| 'clone' | 'clone'
| 'update' | 'group'
| 'swap' | 'update';
| 'clear-report';
interface RundownEntryProps { interface RundownEntryProps {
type: SupportedEvent; type: SupportedEntry;
isPast: boolean; isPast: boolean;
data: OntimeRundownEntry; data: OntimeEntry;
loaded: boolean; loaded: boolean;
eventIndex: number; eventIndex: number;
hasCursor: boolean; hasCursor: boolean;
isNext: boolean; isNext: boolean;
isNextDay: boolean; isNextDay: boolean;
previousEntryId?: string; previousEntryId: MaybeString;
previousEventId?: string; previousEventId?: string;
playback?: Playback; // we only care about this if this event is playing playback?: Playback; // we only care about this if this event is playing
isRolling: boolean; // we need to know even if not related to this event isRolling: boolean; // we need to know even if not related to this event
@@ -59,7 +65,7 @@ export default function RundownEntry(props: RundownEntryProps) {
isLinkedToLoaded, isLinkedToLoaded,
} = props; } = props;
const { emitError } = useEmitLog(); const { emitError } = useEmitLog();
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction(); const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions();
const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection(); const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection();
const removeOpenEvent = useCallback(() => { const removeOpenEvent = useCallback(() => {
@@ -79,31 +85,31 @@ export default function RundownEntry(props: RundownEntryProps) {
const actionHandler = useMemoisedFn((action: EventItemActions, payload?: number | FieldValue) => { const actionHandler = useMemoisedFn((action: EventItemActions, payload?: number | FieldValue) => {
switch (action) { switch (action) {
case 'event': { case 'event': {
const newEvent = { type: SupportedEvent.Event }; const newEvent = { type: SupportedEntry.Event };
const options = { const options = {
after: data.id, after: data.id,
lastEventId: previousEventId, lastEventId: previousEventId,
}; };
return addEvent(newEvent, options); return addEntry(newEvent, options);
} }
case 'event-before': { case 'event-before': {
const newEvent = { type: SupportedEvent.Event }; const newEvent = { type: SupportedEntry.Event };
const options = { const options = {
after: previousEntryId, after: previousEntryId,
}; };
return addEvent(newEvent, options); return addEntry(newEvent, options);
} }
case 'delay': { case 'delay': {
return addEvent({ type: SupportedEvent.Delay }, { after: data.id }); return addEntry({ type: SupportedEntry.Delay }, { after: data.id });
} }
case 'delay-before': { case 'delay-before': {
return addEvent({ type: SupportedEvent.Delay }, { after: previousEntryId }); return addEntry({ type: SupportedEntry.Delay }, { after: previousEntryId });
} }
case 'block': { case 'block': {
return addEvent({ type: SupportedEvent.Block }, { after: data.id }); return addEntry({ type: SupportedEntry.Block }, { after: data.id });
} }
case 'block-before': { case 'block-before': {
return addEvent({ type: SupportedEvent.Block }, { after: previousEntryId }); return addEntry({ type: SupportedEntry.Block }, { after: previousEntryId });
} }
case 'swap': { case 'swap': {
const { value } = payload as FieldValue; const { value } = payload as FieldValue;
@@ -112,14 +118,21 @@ export default function RundownEntry(props: RundownEntryProps) {
case 'delete': { case 'delete': {
if (selectedEvents.size > 1) { if (selectedEvents.size > 1) {
clearMultiSelection(); clearMultiSelection();
return deleteEvent(Array.from(selectedEvents)); return deleteEntry(Array.from(selectedEvents));
} }
removeOpenEvent(); removeOpenEvent();
return deleteEvent([data.id]); return deleteEntry([data.id]);
} }
case 'clone': { case 'clone': {
const newEvent = cloneEvent(data as OntimeEvent); const newEvent = cloneEvent(data as OntimeEvent);
addEvent(newEvent, { after: data.id }); addEntry(newEvent, { after: data.id });
break;
}
case 'group': {
if (selectedEvents.size > 1) {
clearMultiSelection();
return groupEntries(Array.from(selectedEvents));
}
break; break;
} }
case 'update': { case 'update': {
@@ -140,7 +153,7 @@ export default function RundownEntry(props: RundownEntryProps) {
if (field in data) { if (field in data) {
// @ts-expect-error -- not sure how to type this // @ts-expect-error -- not sure how to type this
newData[field] = value; newData[field] = value;
return updateEvent(newData); return updateEntry(newData);
} }
return emitError(`Unknown field: ${field}`); return emitError(`Unknown field: ${field}`);
@@ -150,7 +163,7 @@ export default function RundownEntry(props: RundownEntryProps) {
} }
}); });
if (data.type === SupportedEvent.Event) { if (isOntimeEvent(data)) {
return ( return (
<EventBlock <EventBlock
eventId={data.id} eventId={data.id}
@@ -167,11 +180,12 @@ export default function RundownEntry(props: RundownEntryProps) {
timerType={data.timerType} timerType={data.timerType}
title={data.title} title={data.title}
note={data.note} note={data.note}
delay={data.delay ?? 0} delay={data.delay}
colour={data.colour} colour={data.colour}
isPast={isPast} isPast={isPast}
isNext={isNext} isNext={isNext}
skip={data.skip} skip={data.skip}
parent={data.parent}
loaded={loaded} loaded={loaded}
hasCursor={hasCursor} hasCursor={hasCursor}
playback={playback} playback={playback}
@@ -184,9 +198,7 @@ export default function RundownEntry(props: RundownEntryProps) {
actionHandler={actionHandler} actionHandler={actionHandler}
/> />
); );
} else if (data.type === SupportedEvent.Block) { } else if (isOntimeDelay(data)) {
return <BlockBlock data={data} hasCursor={hasCursor} onDelete={() => actionHandler('delete')} />;
} else if (data.type === SupportedEvent.Delay) {
return <DelayBlock data={data} hasCursor={hasCursor} />; return <DelayBlock data={data} hasCursor={hasCursor} />;
} }
return null; return null;
@@ -25,7 +25,7 @@
.list { .list {
@include editor.panel; @include editor.panel;
height: inherit; height: inherit;
padding-left: 0; padding-inline: 0;
box-shadow: $box-shadow-right; box-shadow: $box-shadow-right;
flex: 1 2 auto; /* flex-grow: 1, flex-shrink: 2, flex-basis: auto */ flex: 1 2 auto; /* flex-grow: 1, flex-shrink: 2, flex-basis: auto */
@@ -8,11 +8,14 @@ import { cx } from '../../common/utils/styleUtils';
import { Corner } from '../editors/editor-utils/EditorUtils'; import { Corner } from '../editors/editor-utils/EditorUtils';
import RundownEventEditor from './event-editor/RundownEventEditor'; import RundownEventEditor from './event-editor/RundownEventEditor';
import FinderPlacement from './placements/FinderPlacement';
import RundownWrapper from './RundownWrapper'; import RundownWrapper from './RundownWrapper';
import style from './RundownExport.module.scss'; import style from './RundownExport.module.scss';
const RundownExport = () => { export default memo(RundownExport);
function RundownExport() {
const isExtracted = window.location.pathname.includes('/rundown'); const isExtracted = window.location.pathname.includes('/rundown');
const appMode = useAppMode((state) => state.mode); const appMode = useAppMode((state) => state.mode);
const hideSideBar = isExtracted && appMode === 'run'; const hideSideBar = isExtracted && appMode === 'run';
@@ -21,6 +24,7 @@ const RundownExport = () => {
return ( return (
<div className={classes} data-testid='panel-rundown'> <div className={classes} data-testid='panel-rundown'>
<FinderPlacement />
<div className={style.rundown}> <div className={style.rundown}>
<div className={style.list}> <div className={style.list}>
<ErrorBoundary> <ErrorBoundary>
@@ -40,6 +44,4 @@ const RundownExport = () => {
</div> </div>
</div> </div>
); );
}; }
export default memo(RundownExport);
@@ -0,0 +1,399 @@
import { EntryId, OntimeBlock, OntimeDelay, OntimeEvent, RundownEntries, SupportedEntry } from 'ontime-types';
import { makeRundownMetadata, makeSortableList, moveDown, moveUp } from '../rundown.utils';
describe('makeRundownMetadata()', () => {
it('processes nested rundown data', () => {
const selectedEventId = '12';
const demoEvents = {
'1': {
id: '1',
type: SupportedEntry.Event,
parent: null,
timeStart: 0,
timeEnd: 1,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
block: {
id: 'block',
type: SupportedEntry.Block,
events: ['11', 'delay', '12', '13'],
colour: 'red',
} as OntimeBlock,
'11': {
id: '11',
type: SupportedEntry.Event,
parent: 'block',
timeStart: 10,
timeEnd: 11,
duration: 1,
dayOffset: 0,
gap: 10,
skip: false,
linkStart: false,
} as OntimeEvent,
delay: {
id: 'delay',
type: SupportedEntry.Delay,
parent: 'block',
duration: 0,
} as OntimeDelay,
'12': {
id: '12',
type: SupportedEntry.Event,
parent: 'block',
timeStart: 11,
timeEnd: 12,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: true,
} as OntimeEvent,
'13': {
id: '13',
type: SupportedEntry.Event,
parent: 'block',
timeStart: 12,
timeEnd: 13,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: true,
} as OntimeEvent,
'2': {
id: '2',
type: SupportedEntry.Event,
parent: null,
timeStart: 20,
timeEnd: 21,
duration: 1,
dayOffset: 0,
gap: 7,
skip: false,
linkStart: false,
} as OntimeEvent,
};
const { metadata, process } = makeRundownMetadata(selectedEventId);
expect(metadata).toStrictEqual({
previousEvent: null,
latestEvent: null,
previousEntryId: null,
thisId: null,
eventIndex: 0,
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
});
expect(process(demoEvents['1'])).toStrictEqual({
previousEvent: null,
latestEvent: demoEvents['1'],
previousEntryId: null,
thisId: demoEvents['1'].id,
eventIndex: 1, // UI indexes are 1 based
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
});
expect(process(demoEvents['block'])).toMatchObject({
previousEvent: demoEvents['1'],
latestEvent: demoEvents['1'],
previousEntryId: demoEvents['1'].id,
thisId: demoEvents['block'].id,
eventIndex: 1,
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'block',
groupColour: 'red',
});
expect(process(demoEvents['11'])).toMatchObject({
previousEvent: demoEvents['1'],
latestEvent: demoEvents['11'],
previousEntryId: demoEvents['block'].id,
thisId: demoEvents['11'].id,
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'block',
groupColour: 'red',
});
expect(process(demoEvents['delay'])).toMatchObject({
previousEvent: demoEvents['11'],
latestEvent: demoEvents['11'],
previousEntryId: demoEvents['11'].id,
thisId: demoEvents['delay'].id,
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'block',
groupColour: 'red',
});
expect(process(demoEvents['12'])).toMatchObject({
previousEvent: demoEvents['11'],
latestEvent: demoEvents['12'],
previousEntryId: demoEvents['delay'].id,
thisId: demoEvents['12'].id,
eventIndex: 3,
isPast: false,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLoaded: true,
groupId: 'block',
groupColour: 'red',
});
expect(process(demoEvents['13'])).toMatchObject({
previousEvent: demoEvents['12'],
latestEvent: demoEvents['13'],
previousEntryId: demoEvents['12'].id,
thisId: demoEvents['13'].id,
eventIndex: 4,
isPast: false,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: true,
isLoaded: false,
groupId: 'block',
groupColour: 'red',
});
expect(process(demoEvents['2'])).toMatchObject({
previousEvent: demoEvents['13'],
latestEvent: demoEvents['2'],
previousEntryId: demoEvents['13'].id,
thisId: demoEvents['2'].id,
eventIndex: 5,
isPast: false,
isNextDay: false,
totalGap: 17,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
});
});
it('populates previousEntries in blocks', () => {
const rundownStartsWithBlock = {
block: {
id: 'block',
type: SupportedEntry.Block,
colour: 'red',
events: ['1', '2'],
} as OntimeBlock,
'1': {
id: '1',
type: SupportedEntry.Event,
parent: 'block',
timeStart: 1,
timeEnd: 2,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
'2': {
id: '2',
type: SupportedEntry.Event,
parent: 'block',
timeStart: 2,
timeEnd: 3,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
};
const { process } = makeRundownMetadata(null);
expect(process(rundownStartsWithBlock.block)).toStrictEqual({
previousEvent: null,
latestEvent: null,
previousEntryId: null,
thisId: rundownStartsWithBlock.block.id,
eventIndex: 0,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithBlock.block.id,
groupColour: 'red',
});
expect(process(rundownStartsWithBlock['1'])).toStrictEqual({
previousEvent: null,
latestEvent: rundownStartsWithBlock['1'],
previousEntryId: rundownStartsWithBlock.block.id,
thisId: rundownStartsWithBlock['1'].id,
eventIndex: 1,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithBlock.block.id,
groupColour: 'red',
});
expect(process(rundownStartsWithBlock['2'])).toStrictEqual({
previousEvent: rundownStartsWithBlock['1'],
latestEvent: rundownStartsWithBlock['2'],
previousEntryId: rundownStartsWithBlock['1'].id,
thisId: rundownStartsWithBlock['2'].id,
eventIndex: 2,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithBlock.block.id,
groupColour: 'red',
});
});
});
describe('makeSortableList()', () => {
it('generates a list with block ends', () => {
const order = ['block-1', '2', 'block-3', 'block-4'];
const entries: RundownEntries = {
'block-1': { type: SupportedEntry.Block, id: 'block-1', events: ['11'] } as OntimeBlock,
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
'2': { type: SupportedEntry.Event, id: '2', parent: null } as OntimeEvent,
'block-3': { type: SupportedEntry.Block, id: 'block-3', events: ['31'] } as OntimeBlock,
'31': { type: SupportedEntry.Event, id: '31', parent: 'block-3' } as OntimeEvent,
'block-4': { type: SupportedEntry.Block, id: 'block-4', events: [] as string[] } as OntimeBlock,
};
const sortableList = makeSortableList(order, entries);
expect(sortableList).toStrictEqual([
'block-1',
'11',
'end-block-1',
'2',
'block-3',
'31',
'end-block-3',
'block-4',
'end-block-4',
]);
});
it('closes dangling blocks', () => {
const order = ['block'];
const entries: RundownEntries = {
block: { type: SupportedEntry.Block, id: 'block-1', events: ['11', '12'] } as OntimeBlock,
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
'12': { type: SupportedEntry.Event, id: '12', parent: 'block-1' } as OntimeEvent,
};
const sortableList = makeSortableList(order, entries);
expect(sortableList).toStrictEqual(['block-1', '11', '12', 'end-block-1']);
});
it('handles a list with a with just blocks', () => {
const order = ['block-1', 'block-2'];
const entries: RundownEntries = {
'block-1': { type: SupportedEntry.Block, id: 'block-1', events: [] as string[] } as OntimeBlock,
'block-2': { type: SupportedEntry.Block, id: 'block-2', events: [] as string[] } as OntimeBlock,
};
const sortableList = makeSortableList(order, entries);
expect(sortableList).toStrictEqual(['block-1', 'end-block-1', 'block-2', 'end-block-2']);
});
});
describe('moveUp()', () => {
const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3'];
const entries = {
event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent,
event2: { type: 'event', id: 'event2', parent: null }as OntimeEvent,
block1: { type: 'block', id: 'block1', events: ['event3'] } as OntimeBlock,
event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent,
block2: { type: 'block', id: 'block2', events: [] as EntryId[] } as OntimeBlock,
event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent,
};
it('moves an event up in the list', () => {
const result = moveUp('event2', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'event1', order: 'before', isBlock: false });
})
it.todo('disallows nesting blocks', () => {
const result = moveUp('block2', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'block1', order: 'before', isBlock: false });
})
it('moves an event into a block', () => {
const result = moveUp('event3', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'block2', order: 'insert', isBlock: true });
})
it('moving up from top is noop', () => {
const result = moveUp('event1', sortableData, entries);
expect(result).toMatchObject({ destinationId: null });
})
});
describe('moveDown()', () => {
const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3'];
const entries = {
event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent,
event2: { type: 'event', id: 'event2', parent: null }as OntimeEvent,
block1: { type: 'block', id: 'block1', events: ['event11'] } as OntimeBlock,
event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent,
block2: { type: 'block', id: 'block2', events: [] as EntryId[] } as OntimeBlock,
event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent,
};
it('moves an event down in the list', () => {
const result = moveDown('event1', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'event2', order: 'after', isBlock: false });
})
it.todo('disallows nesting blocks', () => {
const result = moveDown('block1', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'block2', order: 'before', isBlock: false });
})
it('moves an event into a block', () => {
const result = moveDown('event2', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'event11', order: 'before', isBlock: true });
})
it('moving down from bottom is noop', () => {
const result = moveDown('event3', sortableData, entries);
expect(result).toMatchObject({ destinationId: null });
})
});
@@ -1,7 +1,7 @@
@use '../../theme/ontimeColours' as *; @use '../../theme/ontimeColours' as *;
@use '../../theme/ontimeStyles' as *; @use '../../theme/ontimeStyles' as *;
$block-width: 33rem; $block-width: 32rem;
$block-gap: 0.25rem; $block-gap: 0.25rem;
$block-element-spacing: 0.25rem; $block-element-spacing: 0.25rem;
@@ -20,28 +20,23 @@ $block-cursor-color: $orange-400;
box-sizing: content-box; box-sizing: content-box;
border: 1px solid $white-7; border: 1px solid $white-7;
border-radius: $block-border-radius; border-radius: $block-border-radius;
margin-block: 0.25rem;
margin-right: 0.125rem;
position: relative; position: relative;
color: $block-text-color; color: $block-text-color;
min-width: $block-width; min-width: $block-width;
} }
@mixin block-spacing() {
padding-right: 0.5rem;
gap: 2px;
}
@mixin drag-style() { @mixin drag-style() {
font-size: 20px; font-size: 20px;
justify-self: center; justify-self: center;
opacity: 0.3; opacity: 0.3;
cursor: grab; cursor: grab;
transition: opacity 0.3s; transition: opacity 0.3s;
&:hover { &:hover {
opacity: 1; opacity: 1;
} }
&:focus { &:focus {
box-shadow: none; box-shadow: none;
outline: none; outline: none;
@@ -1,28 +1,74 @@
@use '../blockMixins' as *; @use '../blockMixins' as *;
.block { .block {
@include block-spacing;
@include block-styling; @include block-styling;
margin-left: 0.5rem; overflow: hidden;
background-color: $block-bg2;
min-width: 34rem;
display: grid; display: grid;
grid-template-columns: 2rem 1fr auto; grid-template-columns: 2rem 1fr;
grid-template-areas: 'binder header';
align-items: center; align-items: center;
height: $secondary-block-height; // TODO(style fix): groups have an extra bottom margin which interrupt colour
gap: 0.5rem; margin-block: 0.25rem;
&.hasCursor { &.hasCursor {
outline: 1px solid $block-cursor-color; outline: 1px solid $block-cursor-color;
} }
&.expanded {
border-radius: $block-border-radius $block-border-radius 0 0;
}
.binder {
grid-area: binder;
height: 100%;
background-color: var(--block-color, $gray-1050);
color: $section-white;
font-size: 1rem;
display: grid;
justify-content: center;
padding-top: 0.25rem;
}
.header {
grid-area: header;
padding-inline: 0.5rem;
background-color: $block-bg2;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.titleRow {
display: flex;
align-items: center;
gap: 0.5rem;
}
.metaRow {
display: flex;
gap: 3rem;
margin-bottom: 0.25rem;
}
.metaEntry {
width: 4.5em;
:first-child {
font-size: calc(1rem - 3px);
color: $label-gray;
}
}
} }
.drag { .drag {
@include drag-style; @include drag-style;
}
.actionMenu { &.isDragging {
justify-self: flex-end; cursor: grabbing;
}
&.notAllowed {
cursor: not-allowed;
}
} }
@@ -1,26 +1,57 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { IoReorderTwo } from 'react-icons/io5'; import {
IoChevronDown,
IoChevronUp,
IoDuplicateOutline,
IoFolderOpenOutline,
IoReorderTwo,
IoTrash,
} from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { OntimeBlock } from 'ontime-types'; import { EntryId, OntimeBlock } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time';
import EditableBlockTitle from '../common/EditableBlockTitle'; import EditableBlockTitle from '../common/EditableBlockTitle';
import { canDrop } from '../rundown.utils';
import BlockDelete from './BlockDelete';
import style from './BlockBlock.module.scss'; import style from './BlockBlock.module.scss';
interface BlockBlockProps { interface BlockBlockProps {
data: OntimeBlock; data: OntimeBlock;
hasCursor: boolean; hasCursor: boolean;
onDelete: () => void; collapsed: boolean;
onCollapse: (collapsed: boolean, groupId: EntryId) => void;
} }
export default function BlockBlock(props: BlockBlockProps) { export default function BlockBlock(props: BlockBlockProps) {
const { data, hasCursor, onDelete } = props; const { data, hasCursor, collapsed, onCollapse } = props;
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
const { clone, ungroup, deleteEntry } = useEntryActions();
const [onContextMenu] = useContextMenu<HTMLDivElement>([
{
label: 'Clone Block',
icon: IoDuplicateOutline,
onClick: () => clone(data.id),
},
{
label: 'Ungroup',
icon: IoFolderOpenOutline,
onClick: () => ungroup(data.id),
isDisabled: data.events.length === 0,
},
{
label: 'Delete Block',
icon: IoTrash,
onClick: () => deleteEntry([data.id]),
withDivider: true,
},
]);
const { const {
attributes: dragAttributes, attributes: dragAttributes,
@@ -28,25 +59,79 @@ export default function BlockBlock(props: BlockBlockProps) {
setNodeRef, setNodeRef,
transform, transform,
transition, transition,
isDragging,
isOver,
over,
} = useSortable({ } = useSortable({
id: data.id, id: data.id,
data: {
type: 'block',
},
animateLayoutChanges: () => false, animateLayoutChanges: () => false,
}); });
const binderColours = data.colour && getAccessibleColour(data.colour);
const isValidDrop = over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
const dragStyle = { const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform), transform: CSS.Translate.toString(transform),
transition, transition,
cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'default',
}; };
const blockClasses = cx([style.block, hasCursor ? style.hasCursor : null]);
return ( return (
<div className={blockClasses} ref={setNodeRef} style={dragStyle}> <div
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}> className={cx([style.block, hasCursor && style.hasCursor, !collapsed && style.expanded])}
<IoReorderTwo /> ref={setNodeRef}
</span> onContextMenu={onContextMenu}
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' /> style={{
<BlockDelete onDelete={onDelete} /> ...(binderColours ? { '--user-bg': binderColours.backgroundColor } : {}),
...dragStyle,
}}
>
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
<span
className={cx([style.drag, isDragging && style.isDragging, isDragging && !isValidDrop && style.notAllowed])}
ref={handleRef}
{...dragAttributes}
{...dragListeners}
>
<IoReorderTwo />
</span>
</div>
<div className={style.header}>
<div className={style.titleRow}>
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
<IconButton
aria-label='Collapse'
onClick={() => onCollapse(!collapsed, data.id)}
color='#e2e2e2' // $gray-200
variant='ontime-ghosted'
size='sm'
>
{collapsed ? <IoChevronUp /> : <IoChevronDown />}
</IconButton>
</div>
<div className={style.metaRow}>
<div className={style.metaEntry}>
<div>Start</div>
<div>{formatTime(data.startTime)}</div>
</div>
<div className={style.metaEntry}>
<div>End</div>
<div>{formatTime(data.endTime)}</div>
</div>
<div className={style.metaEntry}>
<div>Duration</div>
<div>{formatDuration(data.duration)}</div>
</div>
<div className={style.metaEntry}>
<div>Events</div>
<div>{data.events.length}</div>
</div>
</div>
</div>
</div> </div>
); );
} }
@@ -1,27 +0,0 @@
import { IoTrash } from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react';
import { AppMode, useAppMode } from '../../../common/stores/appModeStore';
interface BlockDeleteProps {
onDelete: () => void;
}
export default function BlockDelete(props: BlockDeleteProps) {
const { onDelete } = props;
const mode = useAppMode((state) => state.mode);
const isRunMode = mode === AppMode.Run;
return (
<IconButton
aria-label='Delete'
size='sm'
icon={<IoTrash />}
variant='ontime-subtle'
color='#FA5656'
onClick={onDelete}
isDisabled={isRunMode}
/>
);
}
@@ -0,0 +1,10 @@
@use '../blockMixins' as *;
.blockEnd {
cursor: default;
height: 0.5rem;
background-color: var(--user-bg, $gray-1050);
border-radius: 0 0 $block-border-radius $block-border-radius;
margin-bottom: 0.25rem;
}
@@ -0,0 +1,42 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import style from './BlockEnd.module.scss';
interface BlockEndProps {
id: string;
colour?: string;
}
export default function BlockEnd(props: BlockEndProps) {
const { id, colour } = props;
const {
attributes: dragAttributes,
listeners: dragListeners,
setNodeRef,
transform,
transition,
} = useSortable({
id,
animateLayoutChanges: () => false,
disabled: true, // we do not want to drag end blocks
});
const dragStyle = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
className={style.blockEnd}
ref={setNodeRef}
{...dragAttributes}
{...dragListeners}
style={{
...dragStyle,
...(colour ? { '--user-bg': colour } : {}),
}}
/>
);
}
@@ -2,7 +2,7 @@ import { useCallback, useRef } from 'react';
import { Input } from '@chakra-ui/react'; import { Input } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput'; import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import style from './TitleEditor.module.scss'; import style from './TitleEditor.module.scss';
@@ -16,7 +16,7 @@ interface TitleEditorProps {
export default function EditableBlockTitle(props: TitleEditorProps) { export default function EditableBlockTitle(props: TitleEditorProps) {
const { title, eventId, placeholder, className } = props; const { title, eventId, placeholder, className } = props;
const { updateEvent } = useEventAction(); const { updateEntry } = useEntryActions();
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback( const submitCallback = useCallback(
(text: string) => { (text: string) => {
@@ -25,9 +25,9 @@ export default function EditableBlockTitle(props: TitleEditorProps) {
} }
const cleanVal = text.trim(); const cleanVal = text.trim();
updateEvent({ id: eventId, title: cleanVal }); updateEntry({ id: eventId, title: cleanVal });
}, },
[title, updateEvent, eventId], [title, updateEntry, eventId],
); );
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(title, submitCallback, ref, { const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(title, submitCallback, ref, {
@@ -1,11 +1,11 @@
@use '../blockMixins' as *; @use '../blockMixins' as *;
.delay { .delay {
@include block-spacing;
@include block-styling; @include block-styling;
margin-left: 0.5rem;
margin-block: 0.25rem;
background-color: $block-bg2; background-color: $block-bg2;
padding-right: 0.5rem;
display: grid; display: grid;
grid-template-columns: 2rem 1fr auto; grid-template-columns: 2rem 1fr auto;
@@ -6,7 +6,7 @@ import { CSS } from '@dnd-kit/utilities';
import { OntimeDelay } from 'ontime-types'; import { OntimeDelay } from 'ontime-types';
import DelayInput from '../../../common/components/input/delay-input/DelayInput'; import DelayInput from '../../../common/components/input/delay-input/DelayInput';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import style from './DelayBlock.module.scss'; import style from './DelayBlock.module.scss';
@@ -18,21 +18,26 @@ interface DelayBlockProps {
export default function DelayBlock(props: DelayBlockProps) { export default function DelayBlock(props: DelayBlockProps) {
const { data, hasCursor } = props; const { data, hasCursor } = props;
const { applyDelay, deleteEvent } = useEventAction(); const { applyDelay, deleteEntry } = useEntryActions();
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
const { const {
attributes: dragAttributes, attributes: dragAttributes,
listeners: dragListeners, listeners: dragListeners,
setNodeRef, setNodeRef,
isDragging,
transform, transform,
transition, transition,
} = useSortable({ } = useSortable({
id: data.id, id: data.id,
data: {
type: 'delay',
},
animateLayoutChanges: () => false, animateLayoutChanges: () => false,
}); });
const dragStyle = { const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform), transform: CSS.Translate.toString(transform),
transition, transition,
}; };
@@ -48,7 +53,7 @@ export default function DelayBlock(props: DelayBlockProps) {
}; };
const cancelDelayHandler = () => { const cancelDelayHandler = () => {
deleteEvent([data.id]); deleteEntry([data.id]);
}; };
const blockClasses = cx([style.delay, hasCursor ? style.hasCursor : null]); const blockClasses = cx([style.delay, hasCursor ? style.hasCursor : null]);
@@ -5,6 +5,7 @@ $skip-opacity: 0.2;
.eventBlock { .eventBlock {
@include block-styling; @include block-styling;
background-color: $block-bg; background-color: $block-bg;
margin-block: 0.25rem;
display: grid; display: grid;
grid-template-areas: grid-template-areas:
@@ -2,6 +2,7 @@ import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { import {
IoAdd, IoAdd,
IoDuplicateOutline, IoDuplicateOutline,
IoFolder,
IoLink, IoLink,
IoPeople, IoPeople,
IoPeopleOutline, IoPeopleOutline,
@@ -12,7 +13,7 @@ import {
} from 'react-icons/io5'; } from 'react-icons/io5';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { EndAction, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, EntryId, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
@@ -26,13 +27,13 @@ import RundownIndicators from './RundownIndicators';
import style from './EventBlock.module.scss'; import style from './EventBlock.module.scss';
interface EventBlockProps { interface EventBlockProps {
eventId: string; eventId: EntryId;
cue: string; cue: string;
timeStart: number; timeStart: number;
timeEnd: number; timeEnd: number;
duration: number; duration: number;
timeStrategy: TimeStrategy; timeStrategy: TimeStrategy;
linkStart: MaybeString; linkStart: boolean;
countToEnd: boolean; countToEnd: boolean;
eventIndex: number; eventIndex: number;
isPublic: boolean; isPublic: boolean;
@@ -45,6 +46,7 @@ interface EventBlockProps {
isPast: boolean; isPast: boolean;
isNext: boolean; isNext: boolean;
skip: boolean; skip: boolean;
parent: EntryId | null;
loaded: boolean; loaded: boolean;
hasCursor: boolean; hasCursor: boolean;
playback?: Playback; playback?: Playback;
@@ -86,6 +88,7 @@ export default function EventBlock(props: EventBlockProps) {
isPast, isPast,
isNext, isNext,
skip = false, skip = false,
parent,
loaded, loaded,
hasCursor, hasCursor,
playback, playback,
@@ -142,6 +145,7 @@ export default function EventBlock(props: EventBlockProps) {
value: false, value: false,
}), }),
}, },
{ withDivider: true, label: 'Group', icon: IoFolder, onClick: () => actionHandler('group') },
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') }, { withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
] ]
: [ : [
@@ -151,7 +155,7 @@ export default function EventBlock(props: EventBlockProps) {
onClick: () => onClick: () =>
actionHandler('update', { actionHandler('update', {
field: 'linkStart', field: 'linkStart',
value: linkStart ? null : 'true', value: linkStart,
}), }),
}, },
{ {
@@ -193,6 +197,10 @@ export default function EventBlock(props: EventBlockProps) {
transition, transition,
} = useSortable({ } = useSortable({
id: eventId, id: eventId,
data: {
type: 'event',
parent,
},
animateLayoutChanges: () => false, animateLayoutChanges: () => false,
}); });
@@ -8,11 +8,10 @@ import {
IoPlay, IoPlay,
IoPlayForward, IoPlayForward,
IoPlaySkipForward, IoPlaySkipForward,
IoStop,
IoTime, IoTime,
} from 'react-icons/io5'; } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayMid } from '../../../ontimeConfig'; import { tooltipDelayMid } from '../../../ontimeConfig';
@@ -31,7 +30,7 @@ interface EventBlockInnerProps {
timeEnd: number; timeEnd: number;
duration: number; duration: number;
timeStrategy: TimeStrategy; timeStrategy: TimeStrategy;
linkStart: MaybeString; linkStart: boolean;
countToEnd: boolean; countToEnd: boolean;
eventIndex: number; eventIndex: number;
isPublic: boolean; isPublic: boolean;
@@ -177,9 +176,6 @@ function EndActionIcon(props: { action: EndAction; className: string }) {
if (action === EndAction.PlayNext) { if (action === EndAction.PlayNext) {
return <IoPlayForward className={maybeActiveClasses} />; return <IoPlayForward className={maybeActiveClasses} />;
} }
if (action === EndAction.Stop) {
return <IoStop className={maybeActiveClasses} />;
}
return <IoPlay className={className} />; return <IoPlay className={className} />;
} }
@@ -2,7 +2,7 @@ import { memo, MouseEvent } from 'react';
import { IoPause, IoPlay, IoReload, IoRemoveCircle, IoRemoveCircleOutline } from 'react-icons/io5'; import { IoPause, IoPlay, IoReload, IoRemoveCircle, IoRemoveCircleOutline } from 'react-icons/io5';
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn'; import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
import { useEventAction } from '../../../../common/hooks/useEventAction'; import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { setEventPlayback } from '../../../../common/hooks/useSocket'; import { setEventPlayback } from '../../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../../ontimeConfig'; import { tooltipDelayMid } from '../../../../ontimeConfig';
@@ -34,11 +34,11 @@ interface EventBlockPlaybackProps {
const EventBlockPlayback = (props: EventBlockPlaybackProps) => { const EventBlockPlayback = (props: EventBlockPlaybackProps) => {
const { eventId, skip, isPlaying, isPaused, loaded, disablePlayback } = props; const { eventId, skip, isPlaying, isPaused, loaded, disablePlayback } = props;
const { updateEvent } = useEventAction(); const { updateEntry } = useEntryActions();
const toggleSkip = (event: MouseEvent) => { const toggleSkip = (event: MouseEvent) => {
event.stopPropagation(); event.stopPropagation();
updateEvent({ id: eventId, skip: !skip }); updateEntry({ id: eventId, skip: !skip });
}; };
const actionHandler = (event: MouseEvent) => { const actionHandler = (event: MouseEvent) => {
@@ -15,23 +15,22 @@ interface CuesheetEventEditorProps {
export default function CuesheetEventEditor(props: CuesheetEventEditorProps) { export default function CuesheetEventEditor(props: CuesheetEventEditorProps) {
const { eventId } = props; const { eventId } = props;
const { data } = useRundown(); const { data } = useRundown();
const { order, rundown } = data;
const [event, setEvent] = useState<OntimeEvent | null>(null); const [event, setEvent] = useState<OntimeEvent | null>(null);
useEffect(() => { useEffect(() => {
if (order.length === 0) { if (data.order.length === 0) {
setEvent(null); setEvent(null);
return; return;
} }
const event = rundown[eventId]; const event = data.entries[eventId];
if (event && isOntimeEvent(event)) { if (event && isOntimeEvent(event)) {
setEvent(event); setEvent(event);
} else { } else {
setEvent(null); setEvent(null);
} }
}, [data, eventId, order, rundown]); }, [eventId, data.order, data.entries]);
if (!event) { if (!event) {
return null; return null;
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { CustomFieldLabel, OntimeEvent } from 'ontime-types'; import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
import AppLink from '../../../common/components/link/app-link/AppLink'; import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import * as Editor from '../../editors/editor-utils/EditorUtils'; import * as Editor from '../../editors/editor-utils/EditorUtils';
@@ -14,8 +14,6 @@ import EventEditorEmpty from './EventEditorEmpty';
import style from './EventEditor.module.scss'; import style from './EventEditor.module.scss';
export type EventEditorSubmitActions = keyof OntimeEvent;
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel; export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel;
interface EventEditorProps { interface EventEditorProps {
@@ -25,7 +23,7 @@ interface EventEditorProps {
export default function EventEditor(props: EventEditorProps) { export default function EventEditor(props: EventEditorProps) {
const { event } = props; const { event } = props;
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const { updateEvent } = useEventAction(); const { updateEntry } = useEntryActions();
const isEditor = window.location.pathname.includes('editor'); const isEditor = window.location.pathname.includes('editor');
@@ -33,12 +31,12 @@ export default function EventEditor(props: EventEditorProps) {
(field: EditorUpdateFields, value: string) => { (field: EditorUpdateFields, value: string) => {
if (field.startsWith('custom-')) { if (field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1]; const fieldLabel = field.split('custom-')[1];
updateEvent({ id: event?.id, custom: { [fieldLabel]: value } }); updateEntry({ id: event?.id, custom: { [fieldLabel]: value } });
} else { } else {
updateEvent({ id: event?.id, [field]: value }); updateEntry({ id: event?.id, [field]: value });
} }
}, },
[event?.id, updateEvent], [event?.id, updateEntry],
); );
if (!event) { if (!event) {
@@ -13,29 +13,28 @@ import style from './EventEditor.module.scss';
export default function RundownEventEditor() { export default function RundownEventEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown(); const { data } = useRundown();
const { order, rundown } = data;
const [event, setEvent] = useState<OntimeEvent | null>(null); const [event, setEvent] = useState<OntimeEvent | null>(null);
useEffect(() => { useEffect(() => {
if (order.length === 0) { if (data.order.length === 0) {
setEvent(null); setEvent(null);
return; return;
} }
const selectedEventId = order.find((eventId) => selectedEvents.has(eventId)); const selectedEventId = Array.from(selectedEvents).at(0);
if (!selectedEventId) { if (!selectedEventId) {
setEvent(null); setEvent(null);
return; return;
} }
const event = rundown[selectedEventId]; const event = data.entries[selectedEventId];
if (event && isOntimeEvent(event)) { if (event && isOntimeEvent(event)) {
setEvent(event); setEvent(event);
} else { } else {
setEvent(null); setEvent(null);
} }
}, [order, rundown, selectedEvents]); }, [data.order, data.entries, selectedEvents]);
if (!event) { if (!event) {
return <EventEditorEmpty />; return <EventEditorEmpty />;
@@ -1,11 +1,11 @@
import { memo } from 'react'; import { memo } from 'react';
import { IoInformationCircle } from 'react-icons/io5'; import { IoInformationCircle } from 'react-icons/io5';
import { Select, Switch, Tooltip } from '@chakra-ui/react'; import { Select, Switch, Tooltip } from '@chakra-ui/react';
import { EndAction, MaybeString, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
import { millisToString, parseUserTime } from 'ontime-utils'; import { millisToString, parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput'; import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../../common/hooks/useEventAction'; import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { millisToDelayString } from '../../../../common/utils/dateConfig'; import { millisToDelayString } from '../../../../common/utils/dateConfig';
import * as Editor from '../../../editors/editor-utils/EditorUtils'; import * as Editor from '../../../editors/editor-utils/EditorUtils';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow'; import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
@@ -18,7 +18,7 @@ interface EventEditorTimesProps {
timeEnd: number; timeEnd: number;
duration: number; duration: number;
timeStrategy: TimeStrategy; timeStrategy: TimeStrategy;
linkStart: MaybeString; linkStart: boolean;
countToEnd: boolean; countToEnd: boolean;
delay: number; delay: number;
isPublic: boolean; isPublic: boolean;
@@ -46,27 +46,27 @@ function EventEditorTimes(props: EventEditorTimesProps) {
timeWarning, timeWarning,
timeDanger, timeDanger,
} = props; } = props;
const { updateEvent } = useEventAction(); const { updateEntry } = useEntryActions();
const handleSubmit = (field: HandledActions, value: string | boolean) => { const handleSubmit = (field: HandledActions, value: string | boolean) => {
if (field === 'isPublic') { if (field === 'isPublic') {
updateEvent({ id: eventId, isPublic: !(value as boolean) }); updateEntry({ id: eventId, isPublic: !(value as boolean) });
return; return;
} }
if (field === 'countToEnd') { if (field === 'countToEnd') {
updateEvent({ id: eventId, countToEnd: !(value as boolean) }); updateEntry({ id: eventId, countToEnd: !(value as boolean) });
return; return;
} }
if (field === 'timeWarning' || field === 'timeDanger') { if (field === 'timeWarning' || field === 'timeDanger') {
const newTime = parseUserTime(value as string); const newTime = parseUserTime(value as string);
updateEvent({ id: eventId, [field]: newTime }); updateEntry({ id: eventId, [field]: newTime });
return; return;
} }
if (field === 'timerType' || field === 'endAction') { if (field === 'timerType' || field === 'endAction') {
updateEvent({ id: eventId, [field]: value }); updateEntry({ id: eventId, [field]: value });
return; return;
} }
}; };
@@ -114,7 +114,6 @@ function EventEditorTimes(props: EventEditorTimesProps) {
variant='ontime' variant='ontime'
> >
<option value={EndAction.None}>None</option> <option value={EndAction.None}>None</option>
<option value={EndAction.Stop}>Stop rundown</option>
<option value={EndAction.LoadNext}>Load next event</option> <option value={EndAction.LoadNext}>Load next event</option>
<option value={EndAction.PlayNext}>Play next event</option> <option value={EndAction.PlayNext}>Play next event</option>
</Select> </Select>
@@ -5,7 +5,7 @@ import { TimerLifeCycle, timerLifecycleValues, Trigger } from 'ontime-types';
import { generateId } from 'ontime-utils'; import { generateId } from 'ontime-utils';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import { useEventAction } from '../../../../common/hooks/useEventAction'; import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { eventTriggerOptions } from './eventTrigger.constants'; import { eventTriggerOptions } from './eventTrigger.constants';
@@ -37,7 +37,7 @@ interface EventTriggerFormProps {
function EventTriggerForm(props: EventTriggerFormProps) { function EventTriggerForm(props: EventTriggerFormProps) {
const { eventId, triggers } = props; const { eventId, triggers } = props;
const { data: automationSettings } = useAutomationSettings(); const { data: automationSettings } = useAutomationSettings();
const { updateEvent } = useEventAction(); const { updateEntry } = useEntryActions();
const [automationId, setAutomationId] = useState<string | undefined>(undefined); const [automationId, setAutomationId] = useState<string | undefined>(undefined);
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart); const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
@@ -45,7 +45,7 @@ function EventTriggerForm(props: EventTriggerFormProps) {
const newTriggers = triggers ?? new Array<Trigger>(); const newTriggers = triggers ?? new Array<Trigger>();
const id = generateId(); const id = generateId();
newTriggers.push({ id, title: '', trigger: triggerLifeCycle, automationId }); newTriggers.push({ id, title: '', trigger: triggerLifeCycle, automationId });
updateEvent({ id: eventId, triggers: newTriggers }); updateEntry({ id: eventId, triggers: newTriggers });
}; };
const getValidationError = (cycle: TimerLifeCycle, automationId?: string): string | undefined => { const getValidationError = (cycle: TimerLifeCycle, automationId?: string): string | undefined => {
@@ -72,7 +72,6 @@ function EventTriggerForm(props: EventTriggerFormProps) {
variant='ontime' variant='ontime'
value={cycleValue} value={cycleValue}
onChange={(e) => setCycleValue(e.target.value as TimerLifeCycle)} onChange={(e) => setCycleValue(e.target.value as TimerLifeCycle)}
defaultValue={TimerLifeCycle.onStart}
> >
<option disabled>Lifecycle Trigger</option> <option disabled>Lifecycle Trigger</option>
{eventTriggerOptions.map((cycle) => ( {eventTriggerOptions.map((cycle) => (
@@ -123,15 +122,15 @@ interface ExistingEventTriggersProps {
function ExistingEventTriggers(props: ExistingEventTriggersProps) { function ExistingEventTriggers(props: ExistingEventTriggersProps) {
const { eventId, triggers } = props; const { eventId, triggers } = props;
const { updateEvent } = useEventAction(); const { updateEntry } = useEntryActions();
const { data: automationSettings } = useAutomationSettings(); const { data: automationSettings } = useAutomationSettings();
const handleDelete = useCallback( const handleDelete = useCallback(
(triggerId: string) => { (triggerId: string) => {
const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId); const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId);
updateEvent({ id: eventId, triggers: newTriggers }); updateEntry({ id: eventId, triggers: newTriggers });
}, },
[eventId, triggers, updateEvent], [eventId, triggers, updateEntry],
); );
const filteredTriggers: Record<string, Trigger[]> = {}; const filteredTriggers: Record<string, Trigger[]> = {};
@@ -0,0 +1,22 @@
import { memo } from 'react';
import { useDisclosure } from '@chakra-ui/react';
import { useHotkeys } from '@mantine/hooks';
import Finder from '../../editors/finder/Finder';
export default memo(FinderPlacement);
function FinderPlacement() {
const { isOpen, onToggle, onClose } = useDisclosure();
useHotkeys([
['mod + f', onToggle],
['Escape', onClose],
]);
if (isOpen) {
return <Finder isOpen={isOpen} onClose={onClose} />;
}
return null;
}
@@ -3,9 +3,10 @@
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
margin: 0.25rem 0; padding-block: 0.5rem;
font-size: calc(1rem - 3px); font-size: calc(1rem - 3px);
padding-inline: calc(2em + 0.5rem) 0.75rem; padding-left: calc(2em + 0.5rem);
background-color: color-mix(in srgb, var(--user-bg, transparent) 10%, transparent 90%);
} }
.quickBtn { .quickBtn {
@@ -1,73 +1,76 @@
import { memo, useCallback, useRef } from 'react'; import { memo, useRef } from 'react';
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { SupportedEvent } from 'ontime-types'; import { MaybeString, SupportedEntry } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { useEmitLog } from '../../../common/stores/logger';
import style from './QuickAddBlock.module.scss'; import style from './QuickAddBlock.module.scss';
interface QuickAddBlockProps { interface QuickAddBlockProps {
previousEventId?: string; previousEventId: MaybeString;
parentBlock: MaybeString;
backgroundColor?: string;
} }
export default memo(QuickAddBlock); export default memo(QuickAddBlock);
function QuickAddBlock(props: QuickAddBlockProps) { function QuickAddBlock(props: QuickAddBlockProps) {
const { previousEventId } = props; const { previousEventId, parentBlock, backgroundColor } = props;
const { addEvent } = useEventAction(); const { addEntry } = useEntryActions();
const { emitError } = useEmitLog();
const doLinkPrevious = useRef<HTMLInputElement | null>(null); const doLinkPrevious = useRef<HTMLInputElement | null>(null);
const doPublic = useRef<HTMLInputElement | null>(null); const doPublic = useRef<HTMLInputElement | null>(null);
const handleCreateEvent = useCallback( const addEvent = () => {
(eventType: SupportedEvent) => { addEntry(
switch (eventType) { {
case 'event': { type: SupportedEntry.Event,
const defaultPublic = doPublic?.current?.checked; parent: parentBlock,
const linkPrevious = doLinkPrevious?.current?.checked; },
{
after: previousEventId,
defaultPublic: doPublic?.current?.checked,
lastEventId: previousEventId,
linkPrevious: doLinkPrevious?.current?.checked,
},
);
};
const newEvent = { type: SupportedEvent.Event }; const addDelay = () => {
const options = { addEntry(
after: previousEventId, { type: SupportedEntry.Delay, parent: parentBlock },
defaultPublic, {
lastEventId: previousEventId, lastEventId: previousEventId,
linkPrevious, after: previousEventId,
}; },
addEvent(newEvent, options); );
break; };
}
case 'delay': { const addBlock = () => {
const options = { if (parentBlock !== null) {
lastEventId: previousEventId, return;
after: previousEventId, }
}; addEntry(
addEvent({ type: SupportedEvent.Delay }, options); { type: SupportedEntry.Block },
break; {
} lastEventId: previousEventId,
case 'block': { after: previousEventId,
const options = { },
lastEventId: previousEventId, );
after: previousEventId, };
};
addEvent({ type: SupportedEvent.Block }, options); /**
break; * If the colour is empty string ''
} * ie: we are inside a block, but there is no defined colour
default: { * we default to $gray-1050 #303030
emitError(`Cannot create unknown event type: ${eventType}`); */
break; const blockColour = backgroundColor === '' ? '#303030' : backgroundColor;
}
}
},
[previousEventId, addEvent, emitError],
);
return ( return (
<div className={style.quickAdd}> <div className={style.quickAdd} style={blockColour ? { '--user-bg': blockColour } : {}}>
<Button <Button
onClick={() => handleCreateEvent(SupportedEvent.Event)} onClick={addEvent}
size='xs' size='xs'
variant='ontime-subtle-white' variant='ontime-subtle-white'
className={style.quickBtn} className={style.quickBtn}
@@ -77,7 +80,7 @@ function QuickAddBlock(props: QuickAddBlockProps) {
Event Event
</Button> </Button>
<Button <Button
onClick={() => handleCreateEvent(SupportedEvent.Delay)} onClick={addDelay}
size='xs' size='xs'
variant='ontime-subtle-white' variant='ontime-subtle-white'
className={style.quickBtn} className={style.quickBtn}
@@ -86,16 +89,18 @@ function QuickAddBlock(props: QuickAddBlockProps) {
> >
Delay Delay
</Button> </Button>
<Button {parentBlock === null && (
onClick={() => handleCreateEvent(SupportedEvent.Block)} <Button
size='xs' onClick={addBlock}
variant='ontime-subtle-white' size='xs'
className={style.quickBtn} variant='ontime-subtle-white'
leftIcon={<IoAdd />} className={style.quickBtn}
color='#b1b1b1' // $gray-400 leftIcon={<IoAdd />}
> color='#b1b1b1' // $gray-400
Block >
</Button> Block
</Button>
)}
</div> </div>
); );
} }
@@ -1,5 +1,5 @@
.header { .header {
padding-inline: calc(2rem - 6px + 0.5rem) calc(1rem + 2px); padding-inline: 1rem 2rem;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -11,23 +11,23 @@ import {
useDisclosure, useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { useAppMode } from '../../../common/stores/appModeStore'; import { useAppMode } from '../../../common/stores/appModeStore';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
export default function RundownMenu() { export default function RundownMenu() {
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents); const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const appMode = useAppMode((state) => state.mode); const appMode = useAppMode((state) => state.mode);
const { deleteAllEvents } = useEventAction(); const { deleteAllEntries } = useEntryActions();
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const cancelRef = useRef<HTMLButtonElement | null>(null); const cancelRef = useRef<HTMLButtonElement | null>(null);
const deleteAll = useCallback(() => { const deleteAll = useCallback(() => {
deleteAllEvents(); deleteAllEntries();
clearSelectedEvents(); clearSelectedEvents();
onClose(); onClose();
}, [clearSelectedEvents, deleteAllEvents, onClose]); }, [clearSelectedEvents, deleteAllEntries, onClose]);
return ( return (
<> <>
@@ -0,0 +1,265 @@
import {
EntryId,
isOntimeBlock,
isOntimeEvent,
isPlayableEvent,
MaybeString,
OntimeDelay,
OntimeEntry,
OntimeEvent,
PlayableEvent,
RundownEntries,
SupportedEntry,
} from 'ontime-types';
import { checkIsNextDay, isNewLatest } from 'ontime-utils';
type RundownMetadata = {
previousEvent: PlayableEvent | null; // The playableEvent from the previous iteration, used by indicators
latestEvent: PlayableEvent | null; // The playableEvent most forwards in time processed so far
previousEntryId: MaybeString; // previous entry is used to infer position in the rundown for new events
thisId: MaybeString;
eventIndex: number;
isPast: boolean;
isNextDay: boolean;
totalGap: number;
isLinkedToLoaded: boolean; // check if the event can link all the way back to the currently playing event
isLoaded: boolean;
groupId: MaybeString;
groupColour: string | undefined;
};
/**
* Creates a process function which aggregates the rundown metadata and event metadata
*/
export function makeRundownMetadata(selectedEventId: MaybeString) {
let rundownMeta: RundownMetadata = {
previousEvent: null,
latestEvent: null,
previousEntryId: null,
thisId: null,
eventIndex: 0,
isPast: Boolean(selectedEventId), // all events before the current selected are in the past
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
};
function process(entry: OntimeEntry): Readonly<RundownMetadata> {
const processedRundownMetadata = processEntry(rundownMeta, selectedEventId, entry);
rundownMeta = processedRundownMetadata;
return rundownMeta;
}
return { metadata: rundownMeta, process };
}
/**
* Receives a rundown entry and processes its place in the rundown
*/
function processEntry(
rundownMetadata: RundownMetadata,
selectedEventId: MaybeString,
entry: Readonly<OntimeEntry>,
): Readonly<RundownMetadata> {
const processedData = { ...rundownMetadata };
// initialise data to be overridden below
processedData.isNextDay = false;
processedData.isLoaded = false;
processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration
processedData.thisId = entry.id; // we reassign thisId
processedData.previousEvent = processedData.latestEvent;
if (entry.id === selectedEventId) {
processedData.isLoaded = true;
processedData.isPast = false;
}
if (isOntimeBlock(entry)) {
processedData.groupId = entry.id;
processedData.groupColour = entry.colour;
} else {
// for delays and blocks, we insert the group metadata
if ((entry as OntimeEvent | OntimeDelay).parent !== processedData.groupId) {
// if the parent is not the current group, we need to update the groupId
processedData.groupId = (entry as OntimeEvent | OntimeDelay).parent;
if ((entry as OntimeEvent | OntimeDelay).parent === null) {
// if the entry has no parent, it cannot have a group colour
processedData.groupColour = undefined;
}
}
if (isOntimeEvent(entry)) {
// event indexes are 1 based in UI
processedData.eventIndex += 1;
if (isPlayableEvent(entry)) {
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
processedData.totalGap += entry.gap;
if (!processedData.isPast && !processedData.isLoaded) {
/**
* isLinkToLoaded is a chain value that we maintain until we
* a) find an unlinked event
* b) find a countToEnd event
*/
processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd;
}
if (isNewLatest(entry, processedData.latestEvent)) {
// this event is the forward most event in rundown, for next iteration
processedData.latestEvent = entry;
}
}
}
}
return processedData;
}
/**
* Creates a sortable list of entries
* ------------------------------------
* Due to limitations in dnd-kit we need to flatten the list of entries
* This list should also be aware of any elements that are sortable (ie: block ends)
*/
export function makeSortableList(order: EntryId[], entries: RundownEntries): EntryId[] {
const flatIds: EntryId[] = [];
for (let i = 0; i < order.length; i++) {
const entry = entries[order[i]];
if (!entry) {
continue;
}
if (isOntimeBlock(entry)) {
// inside a block there are delays and events
// there is no need for special handling
flatIds.push(entry.id);
flatIds.push(...entry.events);
// close the block
flatIds.push(`end-${entry.id}`);
} else {
flatIds.push(entry.id);
}
}
return flatIds;
}
/**
* Checks whether a drop operation is valid
* Currently only used for validating dropping blocks
*/
export function canDrop(targetType?: SupportedEntry, targetParent?: EntryId | null): boolean {
if (targetType === 'event' || targetType === 'delay') {
return targetParent === null;
}
// remaining events will be block or end-block
// we can swap places with other blocks
return targetType == 'block';
}
/**
* Calculates destinations for an entry moving one position up in the rundown
* - Handles noops
* - Handles moving in and out of blocks
* TODO: handle moving blocks
*/
export function moveUp(entryId: EntryId, sortableData: EntryId[], entries: RundownEntries) {
const previousEntryId = getPreviousId(entryId, sortableData);
// the user is moving up at the top of the list
if (!previousEntryId) {
return { destinationId: null, order: 'before', isBlock: false };
}
if (previousEntryId.startsWith('end-')) {
const entry = entries[entryId];
if (isOntimeBlock(entry)) {
// if we are moving a block, we cannot insert it
return { destinationId: previousEntryId.replace('end-', ''), order: 'before', isBlock: false };
}
// insert in the block ID will add to the end of the block events
return { destinationId: previousEntryId.replace('end-', ''), order: 'insert', isBlock: true };
}
// @ts-expect-error -- we safeguard the entry not having a parent property
return { destinationId: previousEntryId, order: 'before', isBlock: Boolean(entries[previousEntryId]?.parent) };
}
/**
* Calculates destinations for an entry moving one position down in the rundown
* - Handles noops
* - Handles moving in and out of blocks
* TODO: handle moving blocks
*/
export function moveDown(entryId: EntryId, sortableData: EntryId[], entries: RundownEntries) {
const nextEntryId = getNextId(entryId, sortableData);
// the user is moving down at the end of the list
if (!nextEntryId) {
return { destinationId: null, order: 'after', isBlock: false };
}
if (nextEntryId.startsWith('end-')) {
// move outside the block
return { destinationId: nextEntryId.replace('end-', ''), order: 'after', isBlock: false };
}
/**
* If the next entry is a block
* - 1. blocks need to skip over it
* - 2. if the block has children, we insert before the first child
* - 3. if the block is empty, we insert into the block
*/
if (isOntimeBlock(entries[nextEntryId])) {
const entry = entries[entryId];
if (isOntimeBlock(entry)) {
// 1. if we are moving a block, we cannot insert it
return { destinationId: nextEntryId, order: 'after', isBlock: false };
}
const firstBlockChild = entries[nextEntryId].events.at(0);
if (firstBlockChild) {
// 2. add before the first child of the block
return { destinationId: firstBlockChild, order: 'before', isBlock: true };
} else {
// 3. or insert into an empty block
return { destinationId: nextEntryId, order: 'insert', isBlock: true };
}
}
return { destinationId: nextEntryId, order: 'after', isBlock: Boolean(entries[nextEntryId]?.parent) };
}
/**
* Utility function gets the ID if the next entry in the list
* returns null if none is found
*/
function getNextId(entryId: EntryId, sortableData: EntryId[]): EntryId | null {
const currentIndex = sortableData.indexOf(entryId);
if (currentIndex === -1 || currentIndex === sortableData.length - 1) {
// No next ID if not found or at the end
return null;
}
return sortableData[currentIndex + 1];
}
/**
* Utility function gets the ID if the previous entry in the list
* returns null if none is found
*/
function getPreviousId(entryId: EntryId, sortableData: EntryId[]): EntryId | null {
const currentIndex = sortableData.indexOf(entryId);
if (currentIndex < 1) {
// No previous ID found or at the beginning
return null;
}
return sortableData[currentIndex - 1];
}
@@ -1,11 +1,11 @@
import { memo } from 'react'; import { memo } from 'react';
import { IoAlertCircleOutline, IoLink, IoLockClosed, IoLockOpenOutline, IoUnlink } from 'react-icons/io5'; import { IoAlertCircleOutline, IoLink, IoLockClosed, IoLockOpenOutline, IoUnlink } from 'react-icons/io5';
import { InputRightElement, Tooltip } from '@chakra-ui/react'; import { InputRightElement, Tooltip } from '@chakra-ui/react';
import { MaybeString, TimeField, TimeStrategy } from 'ontime-types'; import { TimeField, TimeStrategy } from 'ontime-types';
import { dayInMs } from 'ontime-utils'; import { dayInMs } from 'ontime-utils';
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton'; import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayFast, tooltipDelayMid } from '../../../ontimeConfig'; import { tooltipDelayFast, tooltipDelayMid } from '../../../ontimeConfig';
import * as Editor from '../../editors/editor-utils/EditorUtils'; import * as Editor from '../../editors/editor-utils/EditorUtils';
@@ -19,14 +19,14 @@ interface EventBlockTimerProps {
timeEnd: number; timeEnd: number;
duration: number; duration: number;
timeStrategy: TimeStrategy; timeStrategy: TimeStrategy;
linkStart: MaybeString; linkStart: boolean;
delay: number; delay: number;
showLabels?: boolean; showLabels?: boolean;
} }
function TimeInputFlow(props: EventBlockTimerProps) { function TimeInputFlow(props: EventBlockTimerProps) {
const { eventId, countToEnd, timeStart, timeEnd, duration, timeStrategy, linkStart, delay, showLabels } = props; const { eventId, countToEnd, timeStart, timeEnd, duration, timeStrategy, linkStart, delay, showLabels } = props;
const { updateEvent, updateTimer } = useEventAction(); const { updateEntry, updateTimer } = useEntryActions();
// In sync with EventEditorTimes // In sync with EventEditorTimes
const handleSubmit = (field: TimeField, value: string) => { const handleSubmit = (field: TimeField, value: string) => {
@@ -34,11 +34,11 @@ function TimeInputFlow(props: EventBlockTimerProps) {
}; };
const handleChangeStrategy = (timeStrategy: TimeStrategy) => { const handleChangeStrategy = (timeStrategy: TimeStrategy) => {
updateEvent({ id: eventId, timeStrategy }); updateEntry({ id: eventId, timeStrategy });
}; };
const handleLink = (doLink: boolean) => { const handleLink = (doLink: boolean) => {
updateEvent({ id: eventId, linkStart: doLink ? 'true' : null }); updateEntry({ id: eventId, linkStart: doLink });
}; };
const warnings = []; const warnings = [];
@@ -55,9 +55,9 @@ function TimeInputFlow(props: EventBlockTimerProps) {
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd; const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration; const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
const activeStart = cx([style.timeAction, linkStart ? style.active : null]); const activeStart = cx([style.timeAction, linkStart && style.active]);
const activeEnd = cx([style.timeAction, isLockedEnd ? style.active : null]); const activeEnd = cx([style.timeAction, isLockedEnd && style.active]);
const activeDuration = cx([style.timeAction, isLockedDuration ? style.active : null]); const activeDuration = cx([style.timeAction, isLockedDuration && style.active]);
return ( return (
<> <>
@@ -69,7 +69,7 @@ function TimeInputFlow(props: EventBlockTimerProps) {
time={timeStart} time={timeStart}
hasDelay={hasDelay} hasDelay={hasDelay}
placeholder='Start' placeholder='Start'
disabled={Boolean(linkStart)} disabled={linkStart}
> >
<Tooltip label='Link start to previous end' openDelay={tooltipDelayMid}> <Tooltip label='Link start to previous end' openDelay={tooltipDelayMid}>
<InputRightElement className={activeStart} onClick={() => handleLink(!linkStart)}> <InputRightElement className={activeStart} onClick={() => handleLink(!linkStart)}>
@@ -1,21 +1,21 @@
import { MouseEvent } from 'react'; import { MouseEvent } from 'react';
import { isOntimeEvent, MaybeNumber, MaybeString, OntimeEvent, RundownCached } from 'ontime-types'; import { EntryId, isOntimeEvent, MaybeNumber, MaybeString, Rundown } from 'ontime-types';
import { create } from 'zustand'; import { create } from 'zustand';
import { RUNDOWN } from '../../common/api/constants'; import { RUNDOWN } from '../../common/api/constants';
import { ontimeQueryClient } from '../../common/queryClient'; import { ontimeQueryClient } from '../../common/queryClient';
import { isMacOS } from '../../common/utils/deviceUtils'; import { isMacOS } from '../../common/utils/deviceUtils';
export type SelectionMode = 'shift' | 'click' | 'ctrl'; type SelectionMode = 'shift' | 'click' | 'ctrl';
interface EventSelectionStore { interface EventSelectionStore {
selectedEvents: Set<string>; selectedEvents: Set<EntryId>;
anchoredIndex: MaybeNumber; anchoredIndex: MaybeNumber;
cursor: MaybeString; cursor: MaybeString;
setSelectedEvents: (selectionArgs: { id: string; index: number; selectMode: SelectionMode }) => void; setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void; clearSelectedEvents: () => void;
clearMultiSelect: () => void; clearMultiSelect: () => void;
unselect: (id: string) => void; unselect: (id: EntryId) => void;
} }
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
@@ -33,7 +33,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
// on ctrl + click, we toggle the selection of that event // on ctrl + click, we toggle the selection of that event
if (selectMode === 'ctrl') { if (selectMode === 'ctrl') {
const rundownData = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN); const rundownData = ontimeQueryClient.getQueryData<Rundown>(RUNDOWN);
if (!rundownData) return; if (!rundownData) return;
// if it doesnt exist, simply add to the list and set an anchor // if it doesnt exist, simply add to the list and set an anchor
@@ -50,7 +50,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents.delete(id); selectedEvents.delete(id);
const nextIndex = rundownData.order.findIndex( const nextIndex = rundownData.order.findIndex(
(eventId, i) => i > index && isOntimeEvent(rundownData.rundown[eventId]) && selectedEvents.has(eventId), (eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && selectedEvents.has(eventId),
); );
// if we didnt find anything after, set the anchor to the last event // if we didnt find anything after, set the anchor to the last event
@@ -62,15 +62,15 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
// on shift + click, we select a range of events up to the clicked event // on shift + click, we select a range of events up to the clicked event
if (selectMode === 'shift') { if (selectMode === 'shift') {
const rundownData = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN); const rundownData = ontimeQueryClient.getQueryData<Rundown>(RUNDOWN);
if (!rundownData) return; if (!rundownData) return;
// get list of rundown with only ontime events // get list of rundown with only ontime events
const events: OntimeEvent[] = []; const eventIds: EntryId[] = [];
rundownData.order.forEach((eventId) => { rundownData.flatOrder.forEach((eventId) => {
const event = rundownData.rundown[eventId]; const event = rundownData.entries[eventId];
if (isOntimeEvent(event)) { if (isOntimeEvent(event)) {
events.push(event); eventIds.push(event.id);
} }
}); });
@@ -78,7 +78,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1); const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1);
// create new set with range of ids from start to end // create new set with range of ids from start to end
const selectedEventIds = events.slice(start, end).map((event) => event.id); const selectedEventIds = eventIds.slice(start, end);
return set({ return set({
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]), selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
@@ -8,7 +8,7 @@ import {
Runtime, Runtime,
Settings, Settings,
SimpleTimerState, SimpleTimerState,
SupportedEvent, SupportedEntry,
TimerType, TimerType,
ViewSettings, ViewSettings,
} from 'ontime-types'; } from 'ontime-types';
@@ -63,7 +63,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
const publicEvents = useMemo(() => { const publicEvents = useMemo(() => {
if (Array.isArray(rundownData)) { if (Array.isArray(rundownData)) {
return rundownData.filter((e) => e.type === SupportedEvent.Event && e.title && e.isPublic); return rundownData.filter((e) => e.type === SupportedEntry.Event && e.title && e.isPublic);
} }
return []; return [];
}, [rundownData]); }, [rundownData]);
@@ -1,15 +0,0 @@
// used in both sm and public views
export const titleVariants = {
hidden: {
x: -1500,
},
visible: {
x: 0,
transition: {
duration: 1,
},
},
exit: {
x: -1500,
},
};
@@ -1,13 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { import {
OntimeEntry,
OntimeEvent, OntimeEvent,
OntimeRundownEntry,
Playback, Playback,
ProjectData, ProjectData,
Runtime, Runtime,
Settings, Settings,
SupportedEvent, SupportedEntry,
TimerPhase, TimerPhase,
} from 'ontime-types'; } from 'ontime-types';
@@ -63,7 +63,7 @@ export default function Countdown(props: CountdownProps) {
} }
let followThis: OntimeEvent | null = null; let followThis: OntimeEvent | null = null;
const events: OntimeEvent[] = [...backstageEvents].filter((event) => event.type === SupportedEvent.Event); const events: OntimeEvent[] = [...backstageEvents].filter((event) => event.type === SupportedEntry.Event);
if (eventId !== null) { if (eventId !== null) {
followThis = events.find((event) => event.id === eventId) || null; followThis = events.find((event) => event.id === eventId) || null;
@@ -72,7 +72,7 @@ export default function Countdown(props: CountdownProps) {
} }
if (followThis !== null) { if (followThis !== null) {
setFollow(followThis); setFollow(followThis);
const idx: number = backstageEvents.findIndex((event: OntimeRundownEntry) => event.id === followThis?.id); const idx: number = backstageEvents.findIndex((event: OntimeEntry) => event.id === followThis?.id);
const delayToEvent = backstageEvents[idx]?.delay ?? 0; const delayToEvent = backstageEvents[idx]?.delay ?? 0;
setDelay(delayToEvent); setDelay(delayToEvent);
} }
@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from 'ontime-types'; import { OntimeEntry, OntimeEvent, SupportedEntry } from 'ontime-types';
import Empty from '../../../common/components/state/Empty'; import Empty from '../../../common/components/state/Empty';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
@@ -10,7 +10,7 @@ import { sanitiseTitle } from './countdown.helpers';
import './Countdown.scss'; import './Countdown.scss';
interface CountdownSelectProps { interface CountdownSelectProps {
events: OntimeRundownEntry[]; events: OntimeEntry[];
} }
const scheduleFormat = { format12: 'hh:mm a', format24: 'HH:mm' }; const scheduleFormat = { format12: 'hh:mm a', format24: 'HH:mm' };
@@ -19,9 +19,7 @@ export default function CountdownSelect(props: CountdownSelectProps) {
const { events } = props; const { events } = props;
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const filteredEvents = events.filter( const filteredEvents = events.filter((event: OntimeEntry) => event.type === SupportedEntry.Event) as OntimeEvent[];
(event: OntimeRundownEntry) => event.type === SupportedEvent.Event,
) as OntimeEvent[];
return ( return (
<div className='event-select' data-testid='countdown__select'> <div className='event-select' data-testid='countdown__select'>
@@ -1,5 +1,5 @@
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import type { MaybeString, OntimeEvent, OntimeRundown, ProjectData, Settings } from 'ontime-types'; import type { MaybeString, OntimeEntry, OntimeEvent, ProjectData, Settings } from 'ontime-types';
import { Playback } from 'ontime-types'; import { Playback } from 'ontime-types';
import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils'; import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils';
@@ -17,7 +17,7 @@ import StudioClockSchedule from './StudioClockSchedule';
import './StudioClock.scss'; import './StudioClock.scss';
interface StudioClockProps { interface StudioClockProps {
backstageEvents: OntimeRundown; backstageEvents: OntimeEntry[];
eventNext: OntimeEvent | null; eventNext: OntimeEvent | null;
general: ProjectData; general: ProjectData;
isMirrored: boolean; isMirrored: boolean;
@@ -1,4 +1,4 @@
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundown } from 'ontime-types'; import { isOntimeEvent, MaybeString, OntimeEntry, OntimeEvent } from 'ontime-types';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
@@ -8,7 +8,7 @@ import { trimRundown } from './studioClock.utils';
import './StudioClock.scss'; import './StudioClock.scss';
interface StudioClockScheduleProps { interface StudioClockScheduleProps {
rundown: OntimeRundown; rundown: OntimeEntry[];
selectedId: MaybeString; selectedId: MaybeString;
nextId: MaybeString; nextId: MaybeString;
onAir: boolean; onAir: boolean;
@@ -32,7 +32,7 @@ interface TranslationContextValue {
getLocalizedString: (key: keyof typeof langEn, lang?: string) => string; getLocalizedString: (key: keyof typeof langEn, lang?: string) => string;
} }
export const TranslationContext = createContext<TranslationContextValue>({ const TranslationContext = createContext<TranslationContextValue>({
getLocalizedString: () => '', getLocalizedString: () => '',
}); });
@@ -8,7 +8,7 @@ import {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types'; import { isOntimeEvent, OntimeEntry, OntimeEvent } from 'ontime-types';
import { usePartialRundown } from '../../../common/hooks-query/useRundown'; import { usePartialRundown } from '../../../common/hooks-query/useRundown';
@@ -36,7 +36,7 @@ export const ScheduleProvider = ({
isBackstage = false, isBackstage = false,
}: PropsWithChildren<ScheduleProviderProps>) => { }: PropsWithChildren<ScheduleProviderProps>) => {
const { cycleInterval, stopCycle } = useScheduleOptions(); const { cycleInterval, stopCycle } = useScheduleOptions();
const { data: events } = usePartialRundown((event: OntimeRundownEntry) => { const { data: events } = usePartialRundown((event: OntimeEntry) => {
if (isBackstage) { if (isBackstage) {
return isOntimeEvent(event); return isOntimeEvent(event);
} }
@@ -9,12 +9,12 @@ import {
useSensors, useSensors,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types'; import { OntimeEntry } from 'ontime-types';
import useColumnManager from '../cuesheet-table/useColumnManager'; import useColumnManager from '../cuesheet-table/useColumnManager';
interface CuesheetDndProps { interface CuesheetDndProps {
columns: ColumnDef<OntimeRundownEntry>[]; columns: ColumnDef<OntimeEntry>[];
} }
export default function CuesheetDnd(props: PropsWithChildren<CuesheetDndProps>) { export default function CuesheetDnd(props: PropsWithChildren<CuesheetDndProps>) {
@@ -1,9 +1,9 @@
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { useTableNav } from '@table-nav/react'; import { useTableNav } from '@table-nav/react';
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundown, OntimeRundownEntry, TimeField } from 'ontime-types'; import { isOntimeEvent, MaybeString, OntimeEntry, OntimeEvent, TimeField } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import useFollowComponent from '../../../common/hooks/useFollowComponent'; import useFollowComponent from '../../../common/hooks/useFollowComponent';
import { useCuesheetOptions } from '../cuesheet.options'; import { useCuesheetOptions } from '../cuesheet.options';
@@ -16,15 +16,15 @@ import useColumnManager from './useColumnManager';
import style from './CuesheetTable.module.scss'; import style from './CuesheetTable.module.scss';
interface CuesheetTableProps { interface CuesheetTableProps {
data: OntimeRundown; data: OntimeEntry[];
columns: ColumnDef<OntimeRundownEntry>[]; columns: ColumnDef<OntimeEntry>[];
showModal: (eventId: MaybeString) => void; showModal: (eventId: MaybeString) => void;
} }
export default function CuesheetTable(props: CuesheetTableProps) { export default function CuesheetTable(props: CuesheetTableProps) {
const { data, columns, showModal } = props; const { data, columns, showModal } = props;
const { updateEvent, updateTimer } = useEventAction(); const { updateEntry, updateTimer } = useEntryActions();
const { followSelected, showDelayedTimes, hideTableSeconds } = useCuesheetOptions(); const { followSelected, showDelayedTimes, hideTableSeconds } = useCuesheetOptions();
const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } = const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } =
useColumnManager(columns); useColumnManager(columns);
@@ -64,11 +64,11 @@ export default function CuesheetTable(props: CuesheetTableProps) {
} }
if (isCustom) { if (isCustom) {
updateEvent({ id: event.id, custom: { [accessor]: payload } }); updateEntry({ id: event.id, custom: { [accessor]: payload } });
return; return;
} }
updateEvent({ id: event.id, [accessor]: payload }); updateEntry({ id: event.id, [accessor]: payload });
}, },
handleUpdateTimer: (eventId: string, field: TimeField, payload) => { handleUpdateTimer: (eventId: string, field: TimeField, payload) => {
// the timer element already contains logic to avoid submitting a unchanged value // the timer element already contains logic to avoid submitting a unchanged value
@@ -1,7 +1,7 @@
import { MutableRefObject } from 'react'; import { MutableRefObject } from 'react';
import { RowModel, Table } from '@tanstack/react-table'; import { RowModel, Table } from '@tanstack/react-table';
import Color from 'color'; import Color from 'color';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundownEntry } from 'ontime-types'; import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeEntry } from 'ontime-types';
import { useSelectedEventId } from '../../../../common/hooks/useSocket'; import { useSelectedEventId } from '../../../../common/hooks/useSocket';
import { lazyEvaluate } from '../../../../common/utils/lazyEvaluate'; import { lazyEvaluate } from '../../../../common/utils/lazyEvaluate';
@@ -13,9 +13,9 @@ import DelayRow from './DelayRow';
import EventRow from './EventRow'; import EventRow from './EventRow';
interface CuesheetBodyProps { interface CuesheetBodyProps {
rowModel: RowModel<OntimeRundownEntry>; rowModel: RowModel<OntimeEntry>;
selectedRef: MutableRefObject<HTMLTableRowElement | null>; selectedRef: MutableRefObject<HTMLTableRowElement | null>;
table: Table<OntimeRundownEntry>; table: Table<OntimeEntry>;
} }
export default function CuesheetBody(props: CuesheetBodyProps) { export default function CuesheetBody(props: CuesheetBodyProps) {

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