mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-06 16:03:52 +00:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a95bc43a3 | |||
| 1604762f2b | |||
| 2f8f4ad129 | |||
| 0c7c90cae4 | |||
| 1148ac1924 | |||
| 100fbabfc6 | |||
| 42810a97ca | |||
| 05a9b2af1a | |||
| 1bac3a7fd0 | |||
| 8281314ede | |||
| 3e0b3f100e | |||
| fb93063b47 | |||
| 24d3bf05ca | |||
| a4b15970de | |||
| 2a5b053d97 | |||
| 02c30c9681 | |||
| 2de7221653 | |||
| 2498e59156 | |||
| f3b4ea0155 | |||
| aebf949883 | |||
| 33ac05ebee | |||
| 421183fe55 | |||
| b66c19769d | |||
| 7b8445597f | |||
| 0b719c485f | |||
| 17deeddb87 | |||
| 2546159a94 | |||
| 2f26f20db5 | |||
| 11e0215530 | |||
| ea54953298 | |||
| d21f132674 | |||
| c7b2f0e89c | |||
| a1505606cf | |||
| 9edff55bd5 | |||
| e287215179 | |||
| 776649c997 | |||
| 9de5e0ac48 | |||
| c7d99073f6 | |||
| 5c1694d802 | |||
| 7cd319a6b3 | |||
| 2c9b6918e6 | |||
| a50cfad7ef | |||
| 66cd1decde | |||
| c9bf6d9812 | |||
| 8557f64382 | |||
| 8e994fb0df | |||
| 5c47a834b5 | |||
| f356afef43 | |||
| eba5252e40 | |||
| 72c4ad789a | |||
| 3118c20719 | |||
| 3754c374c3 | |||
| dd6d74121c | |||
| 6fb42b989e | |||
| 82dc1bc56b | |||
| d2c7b34142 | |||
| 918f8d1b08 | |||
| fcbcf70e01 | |||
| 5c6b06a0d4 | |||
| fc4c1bf22e | |||
| 84fac739f0 | |||
| ef73c87e2f | |||
| 26ee5768a5 | |||
| 27dcf06c73 | |||
| d48b2ae506 | |||
| 14a7c04d3b | |||
| abd9b127db | |||
| 0a80d6db31 | |||
| 977c99e587 |
Generated
-5
@@ -1,5 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# Editor-based HTTP Client requests
|
|
||||||
/httpRequests/
|
|
||||||
-51
@@ -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>
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -25,11 +25,10 @@ const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
|
|||||||
const TimerView = React.lazy(() => import('./views/timer/Timer'));
|
const TimerView = React.lazy(() => import('./views/timer/Timer'));
|
||||||
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
||||||
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
||||||
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
|
const Countdown = React.lazy(() => import('./views/countdown/Countdown'));
|
||||||
|
|
||||||
const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
|
const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
|
||||||
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
||||||
const Public = React.lazy(() => import('./views/public/Public'));
|
|
||||||
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||||
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||||
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
|
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
|
||||||
@@ -40,7 +39,6 @@ const SClock = withPreset(withData(ClockView));
|
|||||||
const SCountdown = withPreset(withData(Countdown));
|
const SCountdown = withPreset(withData(Countdown));
|
||||||
const SBackstage = withPreset(withData(Backstage));
|
const SBackstage = withPreset(withData(Backstage));
|
||||||
const SProjectInfo = withPreset(withData(ProjectInfo));
|
const SProjectInfo = withPreset(withData(ProjectInfo));
|
||||||
const SPublic = withPreset(withData(Public));
|
|
||||||
const SLowerThird = withPreset(withData(Lower));
|
const SLowerThird = withPreset(withData(Lower));
|
||||||
const SStudio = withPreset(withData(StudioClock));
|
const SStudio = withPreset(withData(StudioClock));
|
||||||
const STimeline = withPreset(withData(Timeline));
|
const STimeline = withPreset(withData(Timeline));
|
||||||
@@ -88,14 +86,6 @@ export default function AppRouter() {
|
|||||||
</ViewLoader>
|
</ViewLoader>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path='/public'
|
|
||||||
element={
|
|
||||||
<ViewLoader>
|
|
||||||
<SPublic />
|
|
||||||
</ViewLoader>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path='/minimal'
|
path='/minimal'
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { CustomField, CustomFieldLabel, CustomFields } from 'ontime-types';
|
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
|
||||||
@@ -24,15 +24,15 @@ export async function postCustomField(newField: CustomField): Promise<CustomFiel
|
|||||||
/**
|
/**
|
||||||
* Edits single custom field
|
* Edits single custom field
|
||||||
*/
|
*/
|
||||||
export async function editCustomField(label: CustomFieldLabel, newField: CustomField): Promise<CustomFields> {
|
export async function editCustomField(key: CustomFieldKey, newField: CustomField): Promise<CustomFields> {
|
||||||
const res = await axios.put(`${customFieldsPath}/${label}`, { ...newField });
|
const res = await axios.put(`${customFieldsPath}/${key}`, { ...newField });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes single custom field
|
* Deletes single custom field
|
||||||
*/
|
*/
|
||||||
export async function deleteCustomField(label: CustomFieldLabel): Promise<CustomFields> {
|
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> {
|
||||||
const res = await axios.delete(`${customFieldsPath}/${label}`);
|
const res = await axios.delete(`${customFieldsPath}/${key}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;');
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<Rundown>> {
|
||||||
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<Rundown>> {
|
||||||
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 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
@use '../../../theme/viewerDefs' as *;
|
||||||
|
|
||||||
|
.baseButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
|
||||||
|
width: fit-content;
|
||||||
|
padding-inline: 1em;
|
||||||
|
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtle {
|
||||||
|
background: $gray-1050;
|
||||||
|
color: $blue-400;
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(:active) {
|
||||||
|
background: $gray-1000;
|
||||||
|
color: $blue-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background: $gray-1100;
|
||||||
|
border-color: $gray-1250;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background: $blue-700;
|
||||||
|
color: $ui-white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(:active) {
|
||||||
|
background: $blue-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background: $blue-800;
|
||||||
|
border-color: $blue-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: $viewer-opacity-disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.medium {
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large {
|
||||||
|
height: 3.5rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { ButtonHTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
|
import style from './Button.module.scss';
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'subtle' | 'primary';
|
||||||
|
size?: 'medium' | 'large';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Button(props: ButtonProps) {
|
||||||
|
const { className, children, variant = 'subtle', size = 'medium', ...buttonProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={cx([style.baseButton, style[variant], style[size], className])} type='button' {...buttonProps}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,28 @@
|
|||||||
.subtle {
|
@use '../../../theme/viewerDefs' as *;
|
||||||
|
|
||||||
|
.baseIconButton {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
height: 2rem;
|
width: 2rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-content: center;
|
place-content: center;
|
||||||
|
|
||||||
background: $gray-1050;
|
|
||||||
color: $blue-400;
|
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtle {
|
||||||
|
background: $gray-1050;
|
||||||
|
color: $blue-400;
|
||||||
|
|
||||||
&:hover:not(:disabled):not(:active) {
|
&:hover:not(:disabled):not(:active) {
|
||||||
background: $gray-1000;
|
background: $gray-1000;
|
||||||
color: $blue-500;
|
color: $blue-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active:not(:disabled){
|
||||||
background: $gray-1100;
|
background: $gray-1100;
|
||||||
border-color: $gray-1250;
|
border-color: $gray-1250;
|
||||||
}
|
}
|
||||||
@@ -24,3 +31,37 @@
|
|||||||
background: $gray-1050;
|
background: $gray-1050;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subtle-white {
|
||||||
|
background: $gray-1050;
|
||||||
|
color: $ui-white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(:active) {
|
||||||
|
background: $gray-1000;
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background: $gray-1100;
|
||||||
|
border-color: $gray-1250;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: $gray-1050;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.destructive {
|
||||||
|
background: $red-700;
|
||||||
|
color: $ui-white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(:active) {
|
||||||
|
background: $red-600;
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background: $red-800;
|
||||||
|
border-color: $red-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ import { cx } from '../../utils/styleUtils';
|
|||||||
|
|
||||||
import style from './IconButton.module.scss';
|
import style from './IconButton.module.scss';
|
||||||
|
|
||||||
export default function IconButton(props: ButtonHTMLAttributes<HTMLButtonElement>) {
|
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
const { className, children, ...buttonProps } = props;
|
variant?: 'subtle' | 'subtle-white' | 'destructive';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IconButton(props: IconButtonProps) {
|
||||||
|
const { className, children, variant = 'subtle', ...buttonProps } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className={cx([style.subtle, className])} {...buttonProps}>
|
<button className={cx([style.baseIconButton, style[variant], className])} type='button' {...buttonProps}>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@ import { IoSettingsOutline } from 'react-icons/io5';
|
|||||||
|
|
||||||
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
|
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
import IconButton from '../buttons/IconButton';
|
||||||
|
|
||||||
import style from './NavigationMenu.module.scss';
|
import style from './NavigationMenu.module.scss';
|
||||||
|
|
||||||
@@ -17,22 +18,24 @@ export default function FloatingNavigation(props: FloatingNavigationProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}>
|
<div className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}>
|
||||||
<button
|
<IconButton
|
||||||
|
variant='subtle-white'
|
||||||
|
className={style.navButton}
|
||||||
onClick={toggleMenu}
|
onClick={toggleMenu}
|
||||||
aria-label='toggle menu'
|
aria-label='toggle menu'
|
||||||
className={style.navButton}
|
|
||||||
data-testid='navigation__toggle-menu'
|
data-testid='navigation__toggle-menu'
|
||||||
>
|
>
|
||||||
<IoApps />
|
<IoApps />
|
||||||
</button>
|
</IconButton>
|
||||||
<button
|
<IconButton
|
||||||
className={style.button}
|
variant='subtle-white'
|
||||||
|
className={style.navButton}
|
||||||
onClick={toggleSettings}
|
onClick={toggleSettings}
|
||||||
aria-label='toggle settings'
|
aria-label='toggle settings'
|
||||||
data-testid='navigation__toggle-settings'
|
data-testid='navigation__toggle-settings'
|
||||||
>
|
>
|
||||||
<IoSettingsOutline />
|
<IoSettingsOutline />
|
||||||
</button>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
$menu-hover-bg: $gray-1350;
|
$menu-hover-bg: $gray-1350;
|
||||||
$menu-focus-bg: $gray-1300;
|
$menu-focus-bg: $gray-1300;
|
||||||
|
|
||||||
$icon-color: $ui-white;
|
|
||||||
$button-bg: $gray-1050;
|
|
||||||
$button-size: 3rem;
|
$button-size: 3rem;
|
||||||
|
|
||||||
.fadeable {
|
.fadeable {
|
||||||
@@ -15,6 +13,7 @@ $button-size: 3rem;
|
|||||||
|
|
||||||
&.hidden {
|
&.hidden {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,20 +40,10 @@ $button-size: 3rem;
|
|||||||
z-index: 12;
|
z-index: 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
color: $icon-color;
|
|
||||||
background-color: $button-bg;
|
|
||||||
width: $button-size;
|
|
||||||
height: $button-size;
|
|
||||||
display: grid;
|
|
||||||
place-content: center;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navButton {
|
.navButton {
|
||||||
@extend .button;
|
font-size: 1.5rem;
|
||||||
z-index: 3;
|
height: $button-size;
|
||||||
|
width: $button-size;;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
|
|||||||
@@ -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.flatOrder.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.flatOrder, 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);
|
||||||
|
|||||||
@@ -0,0 +1,806 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
EntryId,
|
||||||
|
isOntimeBlock,
|
||||||
|
isOntimeEvent,
|
||||||
|
MaybeString,
|
||||||
|
OntimeBlock,
|
||||||
|
OntimeEntry,
|
||||||
|
OntimeEvent,
|
||||||
|
Rundown,
|
||||||
|
TimeField,
|
||||||
|
TimeStrategy,
|
||||||
|
TransientEventPayload,
|
||||||
|
} from 'ontime-types';
|
||||||
|
import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, swapEventData } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { moveDown, moveUp } from '../../features/rundown/rundown.utils';
|
||||||
|
import { RUNDOWN } from '../api/constants';
|
||||||
|
import {
|
||||||
|
deleteEntries,
|
||||||
|
patchReorderEntry,
|
||||||
|
postAddEntry,
|
||||||
|
postCloneEntry,
|
||||||
|
putBatchEditEvents,
|
||||||
|
putEditEntry,
|
||||||
|
ReorderEntry,
|
||||||
|
requestApplyDelay,
|
||||||
|
requestDeleteAll,
|
||||||
|
requestEventSwap,
|
||||||
|
requestGroupEntries,
|
||||||
|
requestUngroup,
|
||||||
|
SwapEntry,
|
||||||
|
} from '../api/rundown';
|
||||||
|
import { logAxiosError } from '../api/utils';
|
||||||
|
import { useEditorSettings } from '../stores/editorSettings';
|
||||||
|
|
||||||
|
export type EventOptions = Partial<{
|
||||||
|
// options to any new block (event / delay / block)
|
||||||
|
after: MaybeString;
|
||||||
|
before: MaybeString;
|
||||||
|
// options to blocks of type OntimeEvent
|
||||||
|
defaultPublic: boolean;
|
||||||
|
linkPrevious: boolean;
|
||||||
|
lastEventId: MaybeString;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gather utilities for actions on entries
|
||||||
|
*/
|
||||||
|
export const useEntryActions = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const {
|
||||||
|
defaultPublic,
|
||||||
|
linkPrevious,
|
||||||
|
defaultTimeStrategy,
|
||||||
|
defaultDuration,
|
||||||
|
defaultWarnTime,
|
||||||
|
defaultDangerTime,
|
||||||
|
defaultTimerType,
|
||||||
|
defaultEndAction,
|
||||||
|
} = useEditorSettings();
|
||||||
|
|
||||||
|
const getEntryById = useCallback(
|
||||||
|
(eventId: string): OntimeEntry | undefined => {
|
||||||
|
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||||
|
if (!cachedRundown?.entries) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return cachedRundown.entries[eventId];
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls mutation to add new entry
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
const _addEntryMutation = useMutation({
|
||||||
|
// TODO(v4): optimistic create entry
|
||||||
|
mutationFn: postAddEntry,
|
||||||
|
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an entry to rundown
|
||||||
|
*/
|
||||||
|
const addEntry = useCallback(
|
||||||
|
async (entry: Partial<OntimeEntry>, options?: EventOptions) => {
|
||||||
|
const newEntry: TransientEventPayload = { ...entry, id: generateId() };
|
||||||
|
|
||||||
|
// ************* CHECK OPTIONS specific to events
|
||||||
|
if (isOntimeEvent(newEntry)) {
|
||||||
|
// merge creation time options with event settings
|
||||||
|
const applicationOptions = {
|
||||||
|
after: options?.after,
|
||||||
|
before: options?.before,
|
||||||
|
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
||||||
|
lastEventId: options?.lastEventId,
|
||||||
|
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (applicationOptions?.lastEventId) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
|
||||||
|
const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
|
||||||
|
const previousEvent = rundownData.entries[applicationOptions.lastEventId];
|
||||||
|
if (isOntimeEvent(previousEvent)) {
|
||||||
|
newEntry.timeStart = previousEvent.timeEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override event with options from editor settings
|
||||||
|
newEntry.linkStart = applicationOptions.linkPrevious;
|
||||||
|
newEntry.isPublic = applicationOptions.defaultPublic;
|
||||||
|
|
||||||
|
if (newEntry.duration === undefined && newEntry.timeEnd === undefined) {
|
||||||
|
newEntry.duration = parseUserTime(defaultDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newEntry.timeDanger === undefined) {
|
||||||
|
newEntry.timeDanger = parseUserTime(defaultDangerTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newEntry.timeWarning === undefined) {
|
||||||
|
newEntry.timeWarning = parseUserTime(defaultWarnTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newEntry.timerType === undefined) {
|
||||||
|
newEntry.timerType = defaultTimerType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newEntry.endAction === undefined) {
|
||||||
|
newEntry.endAction = defaultEndAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newEntry.timeStrategy === undefined) {
|
||||||
|
newEntry.timeStrategy = defaultTimeStrategy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle adding options that concern all event type
|
||||||
|
if (options?.after) {
|
||||||
|
(newEntry as TransientEventPayload).after = options.after;
|
||||||
|
}
|
||||||
|
if (options?.before) {
|
||||||
|
(newEntry as TransientEventPayload).before = options.before;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _addEntryMutation.mutateAsync(newEntry);
|
||||||
|
} catch (error) {
|
||||||
|
logAxiosError('Failed adding event', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
_addEntryMutation,
|
||||||
|
defaultDangerTime,
|
||||||
|
defaultDuration,
|
||||||
|
defaultEndAction,
|
||||||
|
defaultPublic,
|
||||||
|
defaultTimerType,
|
||||||
|
defaultTimeStrategy,
|
||||||
|
defaultWarnTime,
|
||||||
|
linkPrevious,
|
||||||
|
queryClient,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls mutation to clone a selection
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
const _cloneMutation = useMutation({
|
||||||
|
mutationFn: postCloneEntry,
|
||||||
|
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone a selection
|
||||||
|
*/
|
||||||
|
const clone = useCallback(
|
||||||
|
async (entryId: EntryId) => {
|
||||||
|
try {
|
||||||
|
await _cloneMutation.mutateAsync(entryId);
|
||||||
|
} catch (error) {
|
||||||
|
logAxiosError('Error cloning entry', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[_cloneMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls mutation to update existing entry
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
const _updateEntryMutation = useMutation({
|
||||||
|
mutationFn: putEditEntry,
|
||||||
|
// we optimistically update here
|
||||||
|
onMutate: async (newEvent) => {
|
||||||
|
// cancel ongoing queries
|
||||||
|
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||||
|
|
||||||
|
// Snapshot the previous value
|
||||||
|
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||||
|
const eventId = newEvent.id;
|
||||||
|
|
||||||
|
if (previousData && eventId) {
|
||||||
|
// optimistically update object
|
||||||
|
const newRundown = { ...previousData.entries };
|
||||||
|
// @ts-expect-error -- we expect the events to be of same type
|
||||||
|
newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
|
||||||
|
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 { previousData, newEvent };
|
||||||
|
},
|
||||||
|
// Mutation fails, rollback undoes optimist update
|
||||||
|
onError: (_error, _newEvent, context) => {
|
||||||
|
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
|
||||||
|
},
|
||||||
|
// Mutation finished, failed or successful
|
||||||
|
// Fetch anyway, just to be sure
|
||||||
|
onSettled: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates existing entry
|
||||||
|
*/
|
||||||
|
const updateEntry = useCallback(
|
||||||
|
async (event: Partial<OntimeEntry>) => {
|
||||||
|
try {
|
||||||
|
await _updateEntryMutation.mutateAsync(event);
|
||||||
|
} catch (error) {
|
||||||
|
logAxiosError('Error updating event', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[_updateEntryMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCustomField = useCallback(
|
||||||
|
async (entryId: EntryId, field: string, value: string) => {
|
||||||
|
updateEntry({ id: entryId, custom: { [field]: value } });
|
||||||
|
},
|
||||||
|
[updateEntry],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates time of existing event
|
||||||
|
* @param eventId {EntryId} - id of the event
|
||||||
|
* @param field {TimeField} - field to update
|
||||||
|
* @param value {string} - new value string to be parsed
|
||||||
|
* @param lockOnUpdate {boolean} - whether we will apply the lock / release on update
|
||||||
|
*/
|
||||||
|
const updateTimer = useCallback(
|
||||||
|
async (eventId: EntryId, field: TimeField, value: string, lockOnUpdate?: boolean) => {
|
||||||
|
// an empty value with no lock has no domain validity
|
||||||
|
if (!lockOnUpdate && value === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEvent: Partial<OntimeEvent> = {
|
||||||
|
id: eventId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// check if we should lock the field
|
||||||
|
if (lockOnUpdate) {
|
||||||
|
if (field === 'timeEnd') {
|
||||||
|
// an empty value indicates that we should unlock the field
|
||||||
|
newEvent.timeStrategy = value === '' ? TimeStrategy.LockDuration : TimeStrategy.LockEnd;
|
||||||
|
newEvent.timeEnd = value === '' ? undefined : calculateNewValue();
|
||||||
|
} else if (field === 'duration') {
|
||||||
|
// an empty value indicates that we should unlock the field
|
||||||
|
newEvent.timeStrategy = value === '' ? TimeStrategy.LockEnd : TimeStrategy.LockDuration;
|
||||||
|
newEvent.duration = value === '' ? undefined : calculateNewValue();
|
||||||
|
} else if (field === 'timeStart') {
|
||||||
|
// an empty values means we should link to the previous
|
||||||
|
newEvent.linkStart = value === '';
|
||||||
|
newEvent.timeStart = value === '' ? undefined : calculateNewValue();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newEvent[field] = calculateNewValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _updateEntryMutation.mutateAsync(newEvent);
|
||||||
|
} catch (error) {
|
||||||
|
logAxiosError('Error updating event', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to calculate the new time value
|
||||||
|
*/
|
||||||
|
function calculateNewValue(): number {
|
||||||
|
let newValMillis = 0;
|
||||||
|
|
||||||
|
// check for previous keyword
|
||||||
|
if (value === 'p' || value === 'prev' || value === 'previous') {
|
||||||
|
newValMillis = getPreviousEnd();
|
||||||
|
|
||||||
|
// check for adding time keyword
|
||||||
|
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
|
||||||
|
// TODO: is this logic solid?
|
||||||
|
const remainingString = value.substring(1);
|
||||||
|
newValMillis = getPreviousEnd() + parseUserTime(remainingString);
|
||||||
|
} else {
|
||||||
|
newValMillis = parseUserTime(value);
|
||||||
|
}
|
||||||
|
// dont allow timer values over 23:59:59
|
||||||
|
return Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to get the previous event end time
|
||||||
|
*/
|
||||||
|
function getPreviousEnd(): number {
|
||||||
|
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||||
|
|
||||||
|
if (!cachedRundown?.order || !cachedRundown?.entries) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = cachedRundown.order.indexOf(eventId);
|
||||||
|
if (index === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let previousEnd = 0;
|
||||||
|
for (let i = index - 1; i >= 0; i--) {
|
||||||
|
const event = cachedRundown.entries[cachedRundown.order[i]];
|
||||||
|
if (isOntimeEvent(event)) {
|
||||||
|
previousEnd = event.timeEnd;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return previousEnd;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[_updateEntryMutation, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls mutation to edit multiple events
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
const _batchUpdateEventsMutation = useMutation({
|
||||||
|
mutationFn: putBatchEditEvents,
|
||||||
|
onMutate: async ({ ids, data }) => {
|
||||||
|
// cancel ongoing queries
|
||||||
|
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||||
|
|
||||||
|
// Snapshot the previous value
|
||||||
|
const previousRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||||
|
|
||||||
|
if (previousRundown) {
|
||||||
|
const eventIds = new Set(ids);
|
||||||
|
const newRundown = { ...previousRundown.entries };
|
||||||
|
|
||||||
|
eventIds.forEach((eventId) => {
|
||||||
|
if (Object.hasOwn(newRundown, eventId)) {
|
||||||
|
const event = newRundown[eventId];
|
||||||
|
if (isOntimeEvent(event)) {
|
||||||
|
newRundown[eventId] = {
|
||||||
|
...event,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 rundown
|
||||||
|
return { previousRundown };
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
if (!response.data) return;
|
||||||
|
|
||||||
|
const { id, title, order, flatOrder, entries, revision } = response.data;
|
||||||
|
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
order,
|
||||||
|
flatOrder,
|
||||||
|
entries,
|
||||||
|
revision,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (_error, _newEvent, context) => {
|
||||||
|
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousRundown);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const batchUpdateEvents = useCallback(
|
||||||
|
async (data: Partial<OntimeEvent>, eventIds: string[]) => {
|
||||||
|
try {
|
||||||
|
await _batchUpdateEventsMutation.mutateAsync({ ids: eventIds, data });
|
||||||
|
} catch (error) {
|
||||||
|
logAxiosError('Error updating events', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[_batchUpdateEventsMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls mutation to delete an entry
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
const _deleteEntryMutation = useMutation({
|
||||||
|
mutationFn: deleteEntries,
|
||||||
|
// we optimistically update here
|
||||||
|
onMutate: async (entryIds: EntryId[]) => {
|
||||||
|
// cancel ongoing queries
|
||||||
|
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||||
|
|
||||||
|
// Snapshot the previous value
|
||||||
|
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||||
|
|
||||||
|
if (previousData) {
|
||||||
|
// optimistically update object
|
||||||
|
const { entries, order, flatOrder } = optimisticDeleteEntries(entryIds, previousData);
|
||||||
|
|
||||||
|
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
||||||
|
id: previousData.id,
|
||||||
|
title: previousData.title,
|
||||||
|
order,
|
||||||
|
flatOrder,
|
||||||
|
entries,
|
||||||
|
revision: -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a context with the previous and new events
|
||||||
|
return { previousData };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mutation fails, rollback undoes optimist update
|
||||||
|
onError: (_error, _entryIds, context) => {
|
||||||
|
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
|
||||||
|
},
|
||||||
|
// Mutation finished, failed or successful
|
||||||
|
// Fetch anyway, just to be sure
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an event entry from the rundown
|
||||||
|
*/
|
||||||
|
const deleteEntry = useCallback(
|
||||||
|
async (entryIds: EntryId[]) => {
|
||||||
|
try {
|
||||||
|
await _deleteEntryMutation.mutateAsync(entryIds);
|
||||||
|
} catch (error) {
|
||||||
|
logAxiosError('Error deleting event', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[_deleteEntryMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls mutation to delete all events
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
const _deleteAllEntriesMutation = useMutation({
|
||||||
|
mutationFn: requestDeleteAll,
|
||||||
|
// we optimistically update here
|
||||||
|
onMutate: async () => {
|
||||||
|
// cancel ongoing queries
|
||||||
|
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||||
|
|
||||||
|
// Snapshot the previous value
|
||||||
|
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||||
|
|
||||||
|
// optimistically update object
|
||||||
|
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 { previousData };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mutation fails, rollback optimist update
|
||||||
|
onError: (_error, _, context) => {
|
||||||
|
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
|
||||||
|
},
|
||||||
|
// Mutation finished, failed or successful
|
||||||
|
// Fetch anyway, just to be sure
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all entries in the rundown
|
||||||
|
*/
|
||||||
|
const deleteAllEntries = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await _deleteAllEntriesMutation.mutateAsync();
|
||||||
|
} catch (error) {
|
||||||
|
logAxiosError('Error deleting events', error);
|
||||||
|
}
|
||||||
|
}, [_deleteAllEntriesMutation]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls mutation to apply a delay
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
const _applyDelayMutation = useMutation({
|
||||||
|
mutationFn: requestApplyDelay,
|
||||||
|
onSuccess: (response) => {
|
||||||
|
if (!response.data) return;
|
||||||
|
|
||||||
|
const { id, title, order, flatOrder, entries, revision } = response.data;
|
||||||
|
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
order,
|
||||||
|
flatOrder,
|
||||||
|
entries,
|
||||||
|
revision,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// Mutation finished, failed or successful
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a given delay
|
||||||
|
*/
|
||||||
|
const applyDelay = useCallback(
|
||||||
|
async (delayEventId: EntryId) => {
|
||||||
|
try {
|
||||||
|
await _applyDelayMutation.mutateAsync(delayEventId);
|
||||||
|
} catch (error) {
|
||||||
|
logAxiosError('Error applying delay', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[_applyDelayMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls mutation to dissolve a block
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
const _ungroupMutation = useMutation({
|
||||||
|
mutationFn: requestUngroup,
|
||||||
|
onSuccess: (response) => {
|
||||||
|
if (!response.data) return;
|
||||||
|
|
||||||
|
const { id, title, order, flatOrder, entries, revision } = response.data;
|
||||||
|
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
order,
|
||||||
|
flatOrder,
|
||||||
|
entries,
|
||||||
|
revision,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a block and moves its events to the top level
|
||||||
|
*/
|
||||||
|
const ungroup = useCallback(
|
||||||
|
async (blockId: EntryId) => {
|
||||||
|
try {
|
||||||
|
await _ungroupMutation.mutateAsync(blockId);
|
||||||
|
} catch (error) {
|
||||||
|
logAxiosError('Error dissolving block', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[_ungroupMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls mutation to create a block with a selection
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
const _groupEntriesMutation = useMutation({
|
||||||
|
mutationFn: requestGroupEntries,
|
||||||
|
onSuccess: (response) => {
|
||||||
|
if (!response.data) return;
|
||||||
|
|
||||||
|
const { id, title, order, flatOrder, entries, revision } = response.data;
|
||||||
|
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
order,
|
||||||
|
flatOrder,
|
||||||
|
entries,
|
||||||
|
revision,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a block with a selection
|
||||||
|
*/
|
||||||
|
const groupEntries = useCallback(
|
||||||
|
async (entryIds: EntryId[]) => {
|
||||||
|
try {
|
||||||
|
await _groupEntriesMutation.mutateAsync(entryIds);
|
||||||
|
} catch (error) {
|
||||||
|
logAxiosError('Error grouping entries', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[_groupEntriesMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls mutation to reorder an entry
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
const _reorderEntryMutation = useMutation({
|
||||||
|
mutationFn: patchReorderEntry,
|
||||||
|
// Mutation finished, failed or successful
|
||||||
|
// Fetch anyway, just to be sure
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorders a given entry
|
||||||
|
*/
|
||||||
|
const reorderEntry = useCallback(
|
||||||
|
async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => {
|
||||||
|
try {
|
||||||
|
const reorderObject: ReorderEntry = {
|
||||||
|
entryId,
|
||||||
|
destinationId,
|
||||||
|
order,
|
||||||
|
};
|
||||||
|
await _reorderEntryMutation.mutateAsync(reorderObject);
|
||||||
|
} catch (error) {
|
||||||
|
logAxiosError('Error re-ordering event', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[_reorderEntryMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const move = useCallback(async (entryId: EntryId, direction: 'up' | 'down') => {
|
||||||
|
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||||
|
if (!cachedRundown?.order) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { destinationId, order } =
|
||||||
|
direction === 'up'
|
||||||
|
? moveUp(entryId, cachedRundown.order, cachedRundown.entries)
|
||||||
|
: moveDown(entryId, cachedRundown.order, cachedRundown.entries);
|
||||||
|
|
||||||
|
if (destinationId) {
|
||||||
|
try {
|
||||||
|
const reorderObject: ReorderEntry = {
|
||||||
|
entryId,
|
||||||
|
destinationId,
|
||||||
|
order: order as 'before' | 'after' | 'insert',
|
||||||
|
};
|
||||||
|
await _reorderEntryMutation.mutateAsync(reorderObject);
|
||||||
|
} catch (error) {
|
||||||
|
logAxiosError('Error re-ordering event', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls mutation to swap events
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
const _swapEvents = useMutation({
|
||||||
|
mutationFn: requestEventSwap,
|
||||||
|
// we optimistically update here
|
||||||
|
onMutate: async ({ from, to }) => {
|
||||||
|
// cancel ongoing queries
|
||||||
|
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||||
|
|
||||||
|
// Snapshot the previous value
|
||||||
|
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||||
|
if (previousData) {
|
||||||
|
// optimistically update object
|
||||||
|
const newRundown = { ...previousData.entries };
|
||||||
|
const eventA = previousData.entries[from];
|
||||||
|
const eventB = previousData.entries[to];
|
||||||
|
|
||||||
|
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newA, newB] = swapEventData(eventA, eventB);
|
||||||
|
newRundown[from] = newA;
|
||||||
|
newRundown[to] = newB;
|
||||||
|
|
||||||
|
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 { previousData };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mutation fails, rollback undoes optimist update
|
||||||
|
onError: (_error, _eventId, context) => {
|
||||||
|
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
|
||||||
|
},
|
||||||
|
// Mutation finished, failed or successful
|
||||||
|
// Fetch anyway, just to be sure
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swaps the schedule of two events
|
||||||
|
*/
|
||||||
|
const swapEvents = useCallback(
|
||||||
|
async ({ from, to }: SwapEntry) => {
|
||||||
|
try {
|
||||||
|
await _swapEvents.mutateAsync({ from, to });
|
||||||
|
} catch (error) {
|
||||||
|
logAxiosError('Error re-ordering event', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[_swapEvents],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
addEntry,
|
||||||
|
applyDelay,
|
||||||
|
batchUpdateEvents,
|
||||||
|
clone,
|
||||||
|
deleteEntry,
|
||||||
|
deleteAllEntries,
|
||||||
|
ungroup,
|
||||||
|
getEntryById,
|
||||||
|
groupEntries,
|
||||||
|
move,
|
||||||
|
reorderEntry,
|
||||||
|
swapEvents,
|
||||||
|
updateEntry,
|
||||||
|
updateTimer,
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -1,636 +0,0 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import {
|
|
||||||
isOntimeEvent,
|
|
||||||
OntimeBlock,
|
|
||||||
OntimeDelay,
|
|
||||||
OntimeEvent,
|
|
||||||
OntimeRundownEntry,
|
|
||||||
RundownCached,
|
|
||||||
TimeField,
|
|
||||||
TimeStrategy,
|
|
||||||
TransientEventPayload,
|
|
||||||
} from 'ontime-types';
|
|
||||||
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
|
|
||||||
|
|
||||||
import { RUNDOWN } from '../api/constants';
|
|
||||||
import {
|
|
||||||
ReorderEntry,
|
|
||||||
requestApplyDelay,
|
|
||||||
requestBatchPutEvents,
|
|
||||||
requestDelete,
|
|
||||||
requestDeleteAll,
|
|
||||||
requestEventSwap,
|
|
||||||
requestPostEvent,
|
|
||||||
requestPutEvent,
|
|
||||||
requestReorderEvent,
|
|
||||||
SwapEntry,
|
|
||||||
} from '../api/rundown';
|
|
||||||
import { logAxiosError } from '../api/utils';
|
|
||||||
import { useEditorSettings } from '../stores/editorSettings';
|
|
||||||
|
|
||||||
export type EventOptions = Partial<{
|
|
||||||
// options to any new block (event / delay / block)
|
|
||||||
after: string;
|
|
||||||
before: string;
|
|
||||||
// options to blocks of type OntimeEvent
|
|
||||||
defaultPublic: boolean;
|
|
||||||
linkPrevious: boolean;
|
|
||||||
lastEventId: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on
|
|
||||||
*/
|
|
||||||
export const useEventAction = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const {
|
|
||||||
defaultPublic,
|
|
||||||
linkPrevious,
|
|
||||||
defaultTimeStrategy,
|
|
||||||
defaultDuration,
|
|
||||||
defaultWarnTime,
|
|
||||||
defaultDangerTime,
|
|
||||||
defaultTimerType,
|
|
||||||
defaultEndAction,
|
|
||||||
} = useEditorSettings();
|
|
||||||
|
|
||||||
const getEventById = useCallback(
|
|
||||||
(eventId: string) => {
|
|
||||||
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
|
||||||
if (!cachedRundown?.rundown) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return cachedRundown.rundown[eventId];
|
|
||||||
},
|
|
||||||
[queryClient],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calls mutation to add new event
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
const _addEventMutation = useMutation({
|
|
||||||
mutationFn: requestPostEvent,
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
|
||||||
},
|
|
||||||
networkMode: 'always',
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds an event to rundown
|
|
||||||
*/
|
|
||||||
const addEvent = useCallback(
|
|
||||||
async (event: Partial<OntimeEvent | OntimeDelay | OntimeBlock>, options?: EventOptions) => {
|
|
||||||
const newEvent: TransientEventPayload = { ...event };
|
|
||||||
|
|
||||||
// ************* CHECK OPTIONS specific to events
|
|
||||||
if (isOntimeEvent(newEvent)) {
|
|
||||||
// merge creation time options with event settings
|
|
||||||
const applicationOptions = {
|
|
||||||
after: options?.after,
|
|
||||||
before: options?.before,
|
|
||||||
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
|
||||||
lastEventId: options?.lastEventId,
|
|
||||||
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
|
|
||||||
newEvent.linkStart = applicationOptions.lastEventId;
|
|
||||||
} else if (applicationOptions?.lastEventId) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
|
|
||||||
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
|
|
||||||
const { rundown } = rundownData;
|
|
||||||
const previousEvent = rundown[applicationOptions.lastEventId];
|
|
||||||
if (isOntimeEvent(previousEvent)) {
|
|
||||||
newEvent.timeStart = previousEvent.timeEnd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override event with options from editor settings
|
|
||||||
if (applicationOptions.defaultPublic) {
|
|
||||||
newEvent.isPublic = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newEvent.duration === undefined && newEvent.timeEnd === undefined) {
|
|
||||||
newEvent.duration = parseUserTime(defaultDuration);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newEvent.timeDanger === undefined) {
|
|
||||||
newEvent.timeDanger = parseUserTime(defaultDangerTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newEvent.timeWarning === undefined) {
|
|
||||||
newEvent.timeWarning = parseUserTime(defaultWarnTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newEvent.timerType === undefined) {
|
|
||||||
newEvent.timerType = defaultTimerType;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newEvent.endAction === undefined) {
|
|
||||||
newEvent.endAction = defaultEndAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newEvent.timeStrategy === undefined) {
|
|
||||||
newEvent.timeStrategy = defaultTimeStrategy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handle adding options that concern all event type
|
|
||||||
if (options?.after) {
|
|
||||||
// @ts-expect-error -- not sure how to type this, <after> is a transient property
|
|
||||||
newEvent.after = options.after;
|
|
||||||
}
|
|
||||||
if (options?.before) {
|
|
||||||
// @ts-expect-error -- not sure how to type this, <before> is a transient property
|
|
||||||
newEvent.before = options.before;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _addEventMutation.mutateAsync(newEvent as TransientEventPayload);
|
|
||||||
} catch (error) {
|
|
||||||
logAxiosError('Failed adding event', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
_addEventMutation,
|
|
||||||
defaultDangerTime,
|
|
||||||
defaultDuration,
|
|
||||||
defaultEndAction,
|
|
||||||
defaultPublic,
|
|
||||||
defaultTimerType,
|
|
||||||
defaultTimeStrategy,
|
|
||||||
defaultWarnTime,
|
|
||||||
linkPrevious,
|
|
||||||
queryClient,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calls mutation to update existing event
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
const _updateEventMutation = useMutation({
|
|
||||||
mutationFn: requestPutEvent,
|
|
||||||
// we optimistically update here
|
|
||||||
onMutate: async (newEvent) => {
|
|
||||||
// cancel ongoing queries
|
|
||||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
|
||||||
|
|
||||||
// Snapshot the previous value
|
|
||||||
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
|
||||||
const eventId = newEvent.id;
|
|
||||||
|
|
||||||
if (previousData && eventId) {
|
|
||||||
// optimistically update object
|
|
||||||
const newRundown = { ...previousData.rundown };
|
|
||||||
// @ts-expect-error -- we expect the events to be of same type
|
|
||||||
newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
|
|
||||||
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return a context with the previous and new events
|
|
||||||
return { previousData, newEvent };
|
|
||||||
},
|
|
||||||
// Mutation fails, rollback undoes optimist update
|
|
||||||
onError: (_error, _newEvent, context) => {
|
|
||||||
queryClient.setQueryData(RUNDOWN, context?.previousData);
|
|
||||||
},
|
|
||||||
// Mutation finished, failed or successful
|
|
||||||
// Fetch anyway, just to be sure
|
|
||||||
onSettled: async () => {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
|
||||||
},
|
|
||||||
networkMode: 'always',
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates existing event
|
|
||||||
*/
|
|
||||||
const updateEvent = useCallback(
|
|
||||||
async (event: Partial<OntimeRundownEntry>) => {
|
|
||||||
try {
|
|
||||||
await _updateEventMutation.mutateAsync(event);
|
|
||||||
} catch (error) {
|
|
||||||
logAxiosError('Error updating event', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[_updateEventMutation],
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateCustomField = useCallback(
|
|
||||||
async (eventId: string, field: string, value: string) => {
|
|
||||||
updateEvent({ id: eventId, custom: { [field]: value } });
|
|
||||||
},
|
|
||||||
[updateEvent],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates time of existing event
|
|
||||||
* @param eventId {string} - id of the event
|
|
||||||
* @param field {TimeField} - field to update
|
|
||||||
* @param value {string} - new value string to be parsed
|
|
||||||
* @param lockOnUpdate {boolean} - whether we will apply the lock / release on update
|
|
||||||
*/
|
|
||||||
const updateTimer = useCallback(
|
|
||||||
async (eventId: string, field: TimeField, value: string, lockOnUpdate?: boolean) => {
|
|
||||||
// an empty value with no lock has no domain validity
|
|
||||||
if (!lockOnUpdate && value === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newEvent: Partial<OntimeEvent> = {
|
|
||||||
id: eventId,
|
|
||||||
};
|
|
||||||
|
|
||||||
// check if we should lock the field
|
|
||||||
if (lockOnUpdate) {
|
|
||||||
if (field === 'timeEnd') {
|
|
||||||
// an empty value indicates that we should unlock the field
|
|
||||||
newEvent.timeStrategy = value === '' ? TimeStrategy.LockDuration : TimeStrategy.LockEnd;
|
|
||||||
newEvent.timeEnd = value === '' ? undefined : calculateNewValue();
|
|
||||||
} else if (field === 'duration') {
|
|
||||||
// an empty value indicates that we should unlock the field
|
|
||||||
newEvent.timeStrategy = value === '' ? TimeStrategy.LockEnd : TimeStrategy.LockDuration;
|
|
||||||
newEvent.duration = value === '' ? undefined : calculateNewValue();
|
|
||||||
} else if (field === 'timeStart') {
|
|
||||||
// an empty values means we should link to the previous
|
|
||||||
newEvent.linkStart = value === '' ? 'true' : null;
|
|
||||||
newEvent.timeStart = value === '' ? undefined : calculateNewValue();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
newEvent[field] = calculateNewValue();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _updateEventMutation.mutateAsync(newEvent);
|
|
||||||
} catch (error) {
|
|
||||||
logAxiosError('Error updating event', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility function to calculate the new time value
|
|
||||||
*/
|
|
||||||
function calculateNewValue(): number {
|
|
||||||
let newValMillis = 0;
|
|
||||||
|
|
||||||
// check for previous keyword
|
|
||||||
if (value === 'p' || value === 'prev' || value === 'previous') {
|
|
||||||
newValMillis = getPreviousEnd();
|
|
||||||
|
|
||||||
// check for adding time keyword
|
|
||||||
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
|
|
||||||
// TODO: is this logic solid?
|
|
||||||
const remainingString = value.substring(1);
|
|
||||||
newValMillis = getPreviousEnd() + parseUserTime(remainingString);
|
|
||||||
} else {
|
|
||||||
newValMillis = parseUserTime(value);
|
|
||||||
}
|
|
||||||
// dont allow timer values over 23:59:59
|
|
||||||
return Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility function to get the previous event end time
|
|
||||||
*/
|
|
||||||
function getPreviousEnd(): number {
|
|
||||||
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
|
||||||
|
|
||||||
if (!cachedRundown?.order || !cachedRundown?.rundown) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = cachedRundown.order.indexOf(eventId);
|
|
||||||
if (index === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let previousEnd = 0;
|
|
||||||
for (let i = index - 1; i >= 0; i--) {
|
|
||||||
const event = cachedRundown.rundown[cachedRundown.order[i]];
|
|
||||||
if (isOntimeEvent(event)) {
|
|
||||||
previousEnd = event.timeEnd;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return previousEnd;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[_updateEventMutation, queryClient],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calls mutation to edit multiple events
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
const _batchUpdateEventsMutation = useMutation({
|
|
||||||
mutationFn: requestBatchPutEvents,
|
|
||||||
onMutate: async ({ ids, data }) => {
|
|
||||||
// cancel ongoing queries
|
|
||||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
|
||||||
|
|
||||||
// Snapshot the previous value
|
|
||||||
const previousEvents = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
|
||||||
|
|
||||||
if (previousEvents) {
|
|
||||||
const eventIds = new Set(ids);
|
|
||||||
const newRundown = { ...previousEvents.rundown };
|
|
||||||
|
|
||||||
eventIds.forEach((eventId) => {
|
|
||||||
if (Object.hasOwn(newRundown, eventId)) {
|
|
||||||
const event = newRundown[eventId];
|
|
||||||
if (isOntimeEvent(event)) {
|
|
||||||
newRundown[eventId] = {
|
|
||||||
...event,
|
|
||||||
...data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
queryClient.setQueryData(RUNDOWN, { order: previousEvents.order, rundown: newRundown, revision: -1 });
|
|
||||||
}
|
|
||||||
// Return a context with the previous and new events
|
|
||||||
return { previousEvents };
|
|
||||||
},
|
|
||||||
onSettled: async () => {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
|
||||||
},
|
|
||||||
onError: (_error, _newEvent, context) => {
|
|
||||||
queryClient.setQueryData(RUNDOWN, context?.previousEvents);
|
|
||||||
},
|
|
||||||
networkMode: 'always',
|
|
||||||
});
|
|
||||||
|
|
||||||
const batchUpdateEvents = useCallback(
|
|
||||||
async (data: Partial<OntimeEvent>, eventIds: string[]) => {
|
|
||||||
try {
|
|
||||||
await _batchUpdateEventsMutation.mutateAsync({ ids: eventIds, data });
|
|
||||||
} catch (error) {
|
|
||||||
logAxiosError('Error updating events', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[_batchUpdateEventsMutation],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calls mutation to delete an event
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
const _deleteEventMutation = useMutation({
|
|
||||||
mutationFn: requestDelete,
|
|
||||||
// we optimistically update here
|
|
||||||
onMutate: async (eventIds: string[]) => {
|
|
||||||
// cancel ongoing queries
|
|
||||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
|
||||||
|
|
||||||
// Snapshot the previous value
|
|
||||||
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
|
||||||
|
|
||||||
if (previousData) {
|
|
||||||
// optimistically update object
|
|
||||||
const newOrder = previousData.order.filter((id) => !eventIds.includes(id));
|
|
||||||
const newRundown = { ...previousData.rundown };
|
|
||||||
for (const eventId of eventIds) {
|
|
||||||
delete newRundown[eventId];
|
|
||||||
}
|
|
||||||
|
|
||||||
queryClient.setQueryData(RUNDOWN, {
|
|
||||||
order: newOrder,
|
|
||||||
rundown: newRundown,
|
|
||||||
revision: -1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return a context with the previous and new events
|
|
||||||
return { previousData };
|
|
||||||
},
|
|
||||||
|
|
||||||
// Mutation fails, rollback undoes optimist update
|
|
||||||
onError: (_error, _eventId, context) => {
|
|
||||||
queryClient.setQueryData(RUNDOWN, context?.previousData);
|
|
||||||
},
|
|
||||||
// Mutation finished, failed or successful
|
|
||||||
// Fetch anyway, just to be sure
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
|
||||||
},
|
|
||||||
networkMode: 'always',
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes an event form the list
|
|
||||||
*/
|
|
||||||
const deleteEvent = useCallback(
|
|
||||||
async (eventIds: string[]) => {
|
|
||||||
try {
|
|
||||||
await _deleteEventMutation.mutateAsync(eventIds);
|
|
||||||
} catch (error) {
|
|
||||||
logAxiosError('Error deleting event', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[_deleteEventMutation],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calls mutation to delete all events
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
const _deleteAllEventsMutation = useMutation({
|
|
||||||
mutationFn: requestDeleteAll,
|
|
||||||
// we optimistically update here
|
|
||||||
onMutate: async () => {
|
|
||||||
// cancel ongoing queries
|
|
||||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
|
||||||
|
|
||||||
// Snapshot the previous value
|
|
||||||
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
|
||||||
|
|
||||||
// optimistically update object
|
|
||||||
queryClient.setQueryData(RUNDOWN, { rundown: {}, order: [], revision: -1 });
|
|
||||||
|
|
||||||
// Return a context with the previous and new events
|
|
||||||
return { previousData };
|
|
||||||
},
|
|
||||||
|
|
||||||
// Mutation fails, rollback undos optimist update
|
|
||||||
onError: (_error, _eventId, context) => {
|
|
||||||
queryClient.setQueryData(RUNDOWN, context?.previousData);
|
|
||||||
},
|
|
||||||
// Mutation finished, failed or successful
|
|
||||||
// Fetch anyway, just to be sure
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
|
||||||
},
|
|
||||||
networkMode: 'always',
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes all events from list
|
|
||||||
*/
|
|
||||||
const deleteAllEvents = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await _deleteAllEventsMutation.mutateAsync();
|
|
||||||
} catch (error) {
|
|
||||||
logAxiosError('Error deleting events', error);
|
|
||||||
}
|
|
||||||
}, [_deleteAllEventsMutation]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calls mutation to apply a delay
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
const _applyDelayMutation = useMutation({
|
|
||||||
mutationFn: requestApplyDelay,
|
|
||||||
// Mutation finished, failed or successful
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
|
||||||
},
|
|
||||||
networkMode: 'always',
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies a given delay block
|
|
||||||
*/
|
|
||||||
const applyDelay = useCallback(
|
|
||||||
async (delayEventId: string) => {
|
|
||||||
try {
|
|
||||||
await _applyDelayMutation.mutateAsync(delayEventId);
|
|
||||||
} catch (error) {
|
|
||||||
logAxiosError('Error applying delay', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[_applyDelayMutation],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calls mutation to reorder an event
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
const _reorderEventMutation = useMutation({
|
|
||||||
mutationFn: requestReorderEvent,
|
|
||||||
// we optimistically update here
|
|
||||||
onMutate: async (data) => {
|
|
||||||
// cancel ongoing queries
|
|
||||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
|
||||||
|
|
||||||
// Snapshot the previous value
|
|
||||||
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
|
||||||
|
|
||||||
if (previousData) {
|
|
||||||
// optimistically update object
|
|
||||||
const newOrder = reorderArray(previousData.order, data.from, data.to);
|
|
||||||
|
|
||||||
queryClient.setQueryData(RUNDOWN, { order: newOrder, rundown: previousData.rundown, revision: -1 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return a context with the previous and new events
|
|
||||||
return { previousData };
|
|
||||||
},
|
|
||||||
|
|
||||||
// Mutation fails, rollback undoes optimist update
|
|
||||||
onError: (_error, _eventId, context) => {
|
|
||||||
queryClient.setQueryData(RUNDOWN, context?.previousData);
|
|
||||||
},
|
|
||||||
// Mutation finished, failed or successful
|
|
||||||
// Fetch anyway, just to be sure
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
|
||||||
},
|
|
||||||
networkMode: 'always',
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reorders a given event
|
|
||||||
*/
|
|
||||||
const reorderEvent = useCallback(
|
|
||||||
async (eventId: string, from: number, to: number) => {
|
|
||||||
try {
|
|
||||||
const reorderObject: ReorderEntry = {
|
|
||||||
eventId,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
};
|
|
||||||
await _reorderEventMutation.mutateAsync(reorderObject);
|
|
||||||
} catch (error) {
|
|
||||||
logAxiosError('Error re-ordering event', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[_reorderEventMutation],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calls mutation to swap events
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
const _swapEvents = useMutation({
|
|
||||||
mutationFn: requestEventSwap,
|
|
||||||
// we optimistically update here
|
|
||||||
onMutate: async ({ from, to }) => {
|
|
||||||
// cancel ongoing queries
|
|
||||||
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
|
||||||
|
|
||||||
// Snapshot the previous value
|
|
||||||
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
|
||||||
if (previousData) {
|
|
||||||
// optimistically update object
|
|
||||||
const newRundown = { ...previousData.rundown };
|
|
||||||
const eventA = previousData.rundown[from];
|
|
||||||
const eventB = previousData.rundown[to];
|
|
||||||
|
|
||||||
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { newA, newB } = swapEventData(eventA, eventB);
|
|
||||||
newRundown[from] = newA;
|
|
||||||
newRundown[to] = newB;
|
|
||||||
|
|
||||||
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return a context with the previous events
|
|
||||||
return { previousData };
|
|
||||||
},
|
|
||||||
|
|
||||||
// Mutation fails, rollback undoes optimist update
|
|
||||||
onError: (_error, _eventId, context) => {
|
|
||||||
queryClient.setQueryData(RUNDOWN, context?.previousData);
|
|
||||||
},
|
|
||||||
// Mutation finished, failed or successful
|
|
||||||
// Fetch anyway, just to be sure
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
|
||||||
},
|
|
||||||
networkMode: 'always',
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Swaps the schedule of two events
|
|
||||||
*/
|
|
||||||
const swapEvents = useCallback(
|
|
||||||
async ({ from, to }: SwapEntry) => {
|
|
||||||
try {
|
|
||||||
await _swapEvents.mutateAsync({ from, to });
|
|
||||||
} catch (error) {
|
|
||||||
logAxiosError('Error re-ordering event', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[_swapEvents],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
addEvent,
|
|
||||||
applyDelay,
|
|
||||||
batchUpdateEvents,
|
|
||||||
deleteEvent,
|
|
||||||
deleteAllEvents,
|
|
||||||
getEventById,
|
|
||||||
reorderEvent,
|
|
||||||
swapEvents,
|
|
||||||
updateEvent,
|
|
||||||
updateTimer,
|
|
||||||
updateCustomField,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -2,31 +2,38 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { throttle } from '../utils/throttle';
|
import { throttle } from '../utils/throttle';
|
||||||
|
|
||||||
|
const inactiveTime = 3000; // 3 seconds
|
||||||
|
|
||||||
export const useFadeOutOnInactivity = () => {
|
/**
|
||||||
const [isMouseMoved, setIsMouseMoved] = useState(false);
|
* Provides whether there has been mouse movement in the page in the last <inactiveTime>
|
||||||
|
*/
|
||||||
|
export const useFadeOutOnInactivity = (initialState = false) => {
|
||||||
|
const [isUserActive, setIsUserActive] = useState(initialState);
|
||||||
|
|
||||||
// show on mouse move
|
// show on mouse move
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let fadeOut: NodeJS.Timeout | null = null;
|
let fadeOut: NodeJS.Timeout | null = null;
|
||||||
const setShowMenuTrue = () => {
|
const setShowMenuTrue = () => {
|
||||||
setIsMouseMoved(true);
|
setIsUserActive(true);
|
||||||
if (fadeOut) {
|
if (fadeOut) {
|
||||||
clearTimeout(fadeOut);
|
clearTimeout(fadeOut);
|
||||||
}
|
}
|
||||||
fadeOut = setTimeout(() => setIsMouseMoved(false), 3000);
|
fadeOut = setTimeout(() => setIsUserActive(false), inactiveTime);
|
||||||
};
|
};
|
||||||
|
|
||||||
const throttledShowMenu = throttle(setShowMenuTrue, 1000);
|
const throttledShowMenu = throttle(setShowMenuTrue, 1000);
|
||||||
|
|
||||||
document.addEventListener('mousemove', throttledShowMenu);
|
document.addEventListener('mousemove', throttledShowMenu);
|
||||||
|
document.addEventListener('keydown', throttledShowMenu);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousemove', throttledShowMenu);
|
document.removeEventListener('mousemove', throttledShowMenu);
|
||||||
|
document.removeEventListener('keydown', throttledShowMenu);
|
||||||
if (fadeOut) {
|
if (fadeOut) {
|
||||||
clearTimeout(fadeOut);
|
clearTimeout(fadeOut);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return isMouseMoved;
|
return isUserActive;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -174,11 +164,15 @@ export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
|
|||||||
clock: state.clock,
|
clock: state.clock,
|
||||||
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
||||||
offsetMode: state.runtime.offsetMode,
|
offsetMode: state.runtime.offsetMode,
|
||||||
currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event
|
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||||
actualStart: state.runtime.actualStart,
|
actualStart: state.runtime.actualStart,
|
||||||
plannedStart: state.runtime.plannedStart,
|
plannedStart: state.runtime.plannedStart,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const useCurrentDay = createSelector((state: RuntimeStore) => ({
|
||||||
|
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||||
|
}));
|
||||||
|
|
||||||
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
|
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
|
||||||
offset: state.runtime.offset,
|
offset: state.runtime.offset,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type LogStore = {
|
|||||||
logs: Log[];
|
logs: Log[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const logger = createStore<LogStore>(() => ({
|
const logger = createStore<LogStore>(() => ({
|
||||||
logs: [],
|
logs: [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
+12
-6
@@ -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,
|
||||||
@@ -27,17 +28,19 @@ describe('cloneEvent()', () => {
|
|||||||
delay: 0,
|
delay: 0,
|
||||||
dayOffset: 0,
|
dayOffset: 0,
|
||||||
gap: 0,
|
gap: 0,
|
||||||
|
triggers: [],
|
||||||
custom: {
|
custom: {
|
||||||
lighting: '3',
|
lighting: '3',
|
||||||
} as EventCustomFields,
|
} as EntryCustomFields,
|
||||||
};
|
};
|
||||||
|
|
||||||
const cloned = cloneEvent(original);
|
const cloned = cloneEvent(original);
|
||||||
expect(cloned).not.toBe(original);
|
expect(cloned).not.toBe(original);
|
||||||
expect(cloned.custom).not.toBe(original.custom);
|
expect(cloned.custom).not.toBe(original.custom);
|
||||||
|
expect(cloned.triggers).not.toBe(original.triggers);
|
||||||
|
|
||||||
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 +48,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,
|
||||||
@@ -57,6 +61,8 @@ describe('cloneEvent()', () => {
|
|||||||
gap: 0,
|
gap: 0,
|
||||||
timeWarning: original.timeWarning,
|
timeWarning: original.timeWarning,
|
||||||
timeDanger: original.timeDanger,
|
timeDanger: original.timeDanger,
|
||||||
|
triggers: original.triggers,
|
||||||
|
custom: original.custom,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
})
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -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,12 +23,14 @@ 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,
|
||||||
gap: 0,
|
gap: 0,
|
||||||
timeWarning: event.timeWarning,
|
timeWarning: event.timeWarning,
|
||||||
timeDanger: event.timeDanger,
|
timeDanger: event.timeDanger,
|
||||||
custom: { ...event.custom },
|
triggers: structuredClone(event.triggers),
|
||||||
|
custom: structuredClone(event.custom),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1,10 +1,31 @@
|
|||||||
import { stringify } from 'csv-stringify/browser/esm/sync';
|
import { stringify } from 'csv-stringify/browser/esm/sync';
|
||||||
|
import { OntimeEntry, ProjectRundowns } from 'ontime-types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Converts an array of arrays to a CSV file
|
* Converts an array of arrays to a CSV file
|
||||||
* @param {string[][]} arrayOfArrays
|
|
||||||
* @return {string}
|
|
||||||
*/
|
*/
|
||||||
export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string {
|
export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string {
|
||||||
return stringify(arrayOfArrays);
|
return stringify(arrayOfArrays);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives an object of rundowns, and flattens them into a single, linear rundown
|
||||||
|
* Used for CSV export
|
||||||
|
*/
|
||||||
|
export function aggregateRundowns(rundowns: ProjectRundowns): OntimeEntry[] {
|
||||||
|
const rundownKeys = Object.keys(rundowns);
|
||||||
|
if (rundownKeys.length === 0) return [];
|
||||||
|
const flatRundown: OntimeEntry[] = [];
|
||||||
|
|
||||||
|
for (const key of rundownKeys) {
|
||||||
|
const { order, entries } = rundowns[key];
|
||||||
|
|
||||||
|
for (let i = 0; i < order.length; i++) {
|
||||||
|
const entryId = order[i];
|
||||||
|
const entry = entries[entryId];
|
||||||
|
|
||||||
|
flatRundown.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flatRundown;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { 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);
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,7 @@ export function formatDuration(duration: number, hideSeconds = true): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hideSeconds) {
|
if (!hideSeconds) {
|
||||||
const seconds = Math.floor((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
|
const seconds = Math.ceil((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
|
||||||
if (seconds > 0) {
|
if (seconds > 0) {
|
||||||
result += `${seconds}s`;
|
result += `${seconds}s`;
|
||||||
}
|
}
|
||||||
|
|||||||
-11
@@ -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>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -49,10 +49,7 @@ function resolveUrl(protocol: 'http' | 'ws', path: string) {
|
|||||||
url.pathname = baseURI ? `${baseURI}/${path}` : path;
|
url.pathname = baseURI ? `${baseURI}/${path}` : path;
|
||||||
|
|
||||||
// in development mode, we use the React port for UI, but need the requests to target the server
|
// in development mode, we use the React port for UI, but need the requests to target the server
|
||||||
if (isDev) {
|
// this is done with a proxy in the vite config to avoid CORS issues in the dev environment
|
||||||
// this is used as a fallback port for development
|
|
||||||
url.port = '4001';
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = url.toString();
|
const result = url.toString();
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -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>
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { IoPencil, IoTrash } from 'react-icons/io5';
|
import { IoPencil, IoTrash } from 'react-icons/io5';
|
||||||
import { IconButton } from '@chakra-ui/react';
|
import { IconButton } from '@chakra-ui/react';
|
||||||
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
import { CustomField, CustomFieldKey } from 'ontime-types';
|
||||||
|
|
||||||
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
||||||
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
||||||
@@ -17,8 +17,8 @@ interface CustomFieldEntryProps {
|
|||||||
label: string;
|
label: string;
|
||||||
fieldKey: string;
|
fieldKey: string;
|
||||||
type: 'string' | 'image';
|
type: 'string' | 'image';
|
||||||
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
|
onEdit: (key: CustomFieldKey, patch: CustomField) => Promise<void>;
|
||||||
onDelete: (label: CustomFieldLabel) => Promise<void>;
|
onDelete: (key: CustomFieldKey) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||||
|
|||||||
+5
-5
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } 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 { CustomField, CustomFieldLabel } from 'ontime-types';
|
import { CustomField, CustomFieldKey } from 'ontime-types';
|
||||||
|
|
||||||
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
|
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
|
||||||
import Info from '../../../../../common/components/info/Info';
|
import Info from '../../../../../common/components/info/Info';
|
||||||
@@ -31,14 +31,14 @@ export default function CustomFields() {
|
|||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditField = async (label: CustomFieldLabel, customField: CustomField) => {
|
const handleEditField = async (key: CustomFieldKey, customField: CustomField) => {
|
||||||
await editCustomField(label, customField);
|
await editCustomField(key, customField);
|
||||||
refetch();
|
refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (label: string) => {
|
const handleDelete = async (key: CustomFieldKey) => {
|
||||||
try {
|
try {
|
||||||
await deleteCustomField(label);
|
await deleteCustomField(key);
|
||||||
refetch();
|
refetch();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
/** we do not handle errors here */
|
/** we do not handle errors here */
|
||||||
|
|||||||
+2
-2
@@ -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 {
|
||||||
|
|||||||
@@ -8,7 +8,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.column {
|
.column {
|
||||||
align-items: start;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export default function ViewSettingsForm() {
|
|||||||
onClick={onCodeEditorOpen}
|
onClick={onCodeEditorOpen}
|
||||||
variant='ontime-subtle'
|
variant='ontime-subtle'
|
||||||
size='sm'
|
size='sm'
|
||||||
isDisabled={!data.overrideStyles}
|
isDisabled={isSubmitting}
|
||||||
width='fit-content'
|
width='fit-content'
|
||||||
>
|
>
|
||||||
Edit CSS override
|
Edit CSS override
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -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',
|
||||||
|
|||||||
+32
-31
@@ -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'
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -118,7 +118,8 @@
|
|||||||
padding-inline: 0.25rem;
|
padding-inline: 0.25rem;
|
||||||
background-color: $gray-1250;
|
background-color: $gray-1250;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
white-space: pre;
|
// allow multi-line text but trim before
|
||||||
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ function OperatorEvent(props: OperatorEventProps) {
|
|||||||
<span className={style.mainField}>{main}</span>
|
<span className={style.mainField}>{main}</span>
|
||||||
<span className={style.schedule}>
|
<span className={style.schedule}>
|
||||||
<ClockTime value={timeStart} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
<ClockTime value={timeStart} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
||||||
-
|
→
|
||||||
<ClockTime value={timeEnd} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
<ClockTime value={timeEnd} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'subscribe',
|
id: 'subscribe',
|
||||||
title: 'Highlight Field',
|
title: 'Highlight Fields',
|
||||||
description: 'Choose a custom field to highlight',
|
description: 'Choose custom fields to highlight',
|
||||||
type: 'multi-option',
|
type: 'multi-option',
|
||||||
values: customFieldSelect,
|
values: customFieldSelect,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
|
import { OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
import AppLink from '../../../common/components/link/app-link/AppLink';
|
import 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,9 +14,8 @@ import EventEditorEmpty from './EventEditorEmpty';
|
|||||||
|
|
||||||
import style from './EventEditor.module.scss';
|
import style from './EventEditor.module.scss';
|
||||||
|
|
||||||
export type EventEditorSubmitActions = keyof OntimeEvent;
|
// any of the titles + custom field labels
|
||||||
|
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | string;
|
||||||
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel;
|
|
||||||
|
|
||||||
interface EventEditorProps {
|
interface EventEditorProps {
|
||||||
event: OntimeEvent;
|
event: OntimeEvent;
|
||||||
@@ -25,7 +24,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 +32,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';
|
||||||
@@ -14,12 +14,12 @@ import style from './EventEditorTriggers.module.scss';
|
|||||||
|
|
||||||
interface EventEditorTriggersProps {
|
interface EventEditorTriggersProps {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
triggers?: Trigger[];
|
triggers: Trigger[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EventEditorTriggers(props: EventEditorTriggersProps) {
|
export default function EventEditorTriggers(props: EventEditorTriggersProps) {
|
||||||
const { triggers, eventId } = props;
|
const { triggers, eventId } = props;
|
||||||
const showTriggers = triggers !== undefined && triggers.length > 0;
|
const showTriggers = triggers.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user