Compare commits

...

8 Commits

Author SHA1 Message Date
Carlos Valente ffde3739d1 refactor: remove unnecessary abstractions 2025-03-29 21:23:47 +01:00
Carlos Valente 9db8d996e9 refactor: simplify logic 2025-03-28 22:04:57 +01:00
Carlos Valente 59c7360ea2 refactor: clearer relationship on rundown elements 2025-03-28 19:55:20 +01:00
Carlos Valente f8bf867576 refactor: use strict typing 2025-03-28 19:55:20 +01:00
Carlos Valente dda165bfd7 refactor: remove stop as a possible end action 2025-03-28 19:55:20 +01:00
Carlos Valente dbf8eeb7fb refactor: restructure model to contain an object of rundowns 2025-03-28 19:55:20 +01:00
Carlos Valente 433e9f6a4f chore: remove IDE files 2025-03-28 19:54:25 +01:00
Carlos Valente 7958d49153 refactor: remove unused and legacy code
- remove legacy migrations
- remove unused server code
- remove unused UI code
2025-03-28 19:54:25 +01:00
185 changed files with 4765 additions and 6015 deletions
-5
View File
@@ -1,5 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
-51
View File
@@ -1,51 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="CssUnknownProperty" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myCustomPropertiesEnabled" value="true" />
<option name="myIgnoreVendorSpecificProperties" value="false" />
<option name="myCustomPropertiesList">
<value>
<list size="3">
<item index="0" class="java.lang.String" itemvalue="scrollbar-color" />
<item index="1" class="java.lang.String" itemvalue="scrollbar-width" />
<item index="2" class="java.lang.String" itemvalue="app-region" />
</list>
</value>
</option>
</inspection_tool>
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredUrls">
<list>
<option value="http://localhost" />
<option value="http://127.0.0.1" />
<option value="http://0.0.0.0" />
<option value="http://www.w3.org/" />
<option value="http://json-schema.org/draft" />
<option value="http://java.sun.com/" />
<option value="http://xmlns.jcp.org/" />
<option value="http://javafx.com/javafx/" />
<option value="http://javafx.com/fxml" />
<option value="http://maven.apache.org/xsd/" />
<option value="http://maven.apache.org/POM/" />
<option value="http://www.springframework.org/schema/" />
<option value="http://www.springframework.org/tags" />
<option value="http://www.springframework.org/security/tags" />
<option value="http://www.thymeleaf.org" />
<option value="http://www.jboss.org/j2ee/schema/" />
<option value="http://www.jboss.com/xml/ns/" />
<option value="http://www.ibm.com/webservices/xsd" />
<option value="http://activemq.apache.org/schema/" />
<option value="http://schema.cloudfoundry.org/spring/" />
<option value="http://schemas.xmlsoap.org/" />
<option value="http://cxf.apache.org/schemas/" />
<option value="http://primefaces.org/ui" />
<option value="http://tiles.apache.org/" />
<option value="http://__IP__" />
</list>
</option>
</inspection_tool>
<inspection_tool class="Stylelint" enabled="true" level="ERROR" enabled_by_default="true" />
</profile>
</component>
+2 -6
View File
@@ -10,7 +10,6 @@ export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList']; export const PROJECT_LIST = ['projectList'];
export const RUNDOWN = ['rundown']; export const RUNDOWN = ['rundown'];
export const RUNTIME = ['runtimeStore']; export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState'];
export const URL_PRESETS = ['urlpresets']; export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings']; export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList']; export const CLIENT_LIST = ['clientList'];
@@ -19,11 +18,8 @@ export const REPORT = ['report'];
// API URLs // API URLs
export const apiEntryUrl = `${serverURL}/data`; export const apiEntryUrl = `${serverURL}/data`;
export const projectDataURL = `${serverURL}/project`; const userAssetsPath = 'user';
export const rundownURL = `${serverURL}/events`; const cssOverridePath = 'styles/override.css';
export const ontimeURL = `${serverURL}/ontime`;
export const userAssetsPath = 'user';
export const cssOverridePath = 'styles/override.css';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`; export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`; export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
+5 -3
View File
@@ -2,7 +2,7 @@ import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeTable } from '../../views/cuesheet/cuesheet.utils'; import { makeTable } from '../../views/cuesheet/cuesheet.utils';
import { makeCSVFromArrayOfArrays } from '../utils/csv'; import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils'; import { createBlob, downloadBlob } from './utils';
@@ -40,9 +40,11 @@ export async function downloadProject(fileName: string) {
export async function downloadCSV(fileName: string = 'rundown') { export async function downloadCSV(fileName: string = 'rundown') {
try { try {
const { data, name } = await fileDownload(fileName); const { data, name } = await fileDownload(fileName);
const { project, rundown, customFields } = data; const { project, rundowns, customFields } = data;
const flatRundowns = aggregateRundowns(rundowns);
const sheetData = makeTable(project, flatRundowns, customFields);
const sheetData = makeTable(project, rundown, customFields);
const fileContent = makeCSVFromArrayOfArrays(sheetData); const fileContent = makeCSVFromArrayOfArrays(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;'); const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
+5 -6
View File
@@ -1,16 +1,11 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { CustomFields, OntimeRundown } from 'ontime-types'; import { CustomFields, Rundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
const excelPath = `${apiEntryUrl}/excel`; const excelPath = `${apiEntryUrl}/excel`;
type PreviewSpreadsheetResponse = {
rundown: OntimeRundown;
customFields: CustomFields;
};
/** /**
* upload Excel file to server * upload Excel file to server
* @return string - file ID op the uploaded file * @return string - file ID op the uploaded file
@@ -34,6 +29,10 @@ export async function getWorksheetNames(): Promise<string[]> {
return response.data; return response.data;
} }
type PreviewSpreadsheetResponse = {
rundown: Rundown;
customFields: CustomFields;
};
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> { export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, { const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
options, options,
+21 -6
View File
@@ -1,29 +1,44 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types'; import {
MessageResponse,
OntimeEntry,
OntimeEvent,
ProjectRundownsList,
Rundown,
TransientEventPayload,
} from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
const rundownPath = `${apiEntryUrl}/rundown`; const rundownPath = `${apiEntryUrl}/rundown`;
/**
* HTTP request to fetch a list of existing rundowns
*/
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
const res = await axios.get(`${rundownPath}/`);
return res.data;
}
/** /**
* HTTP request to fetch all events * HTTP request to fetch all events
*/ */
export async function fetchNormalisedRundown(): Promise<RundownCached> { export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/normalised`); const res = await axios.get(`${rundownPath}/current`);
return res.data; return res.data;
} }
/** /**
* HTTP request to post new event * HTTP request to post new event
*/ */
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeRundownEntry>> { export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(rundownPath, data); return axios.post(rundownPath, data);
} }
/** /**
* HTTP request to put new event * HTTP request to put new event
*/ */
export async function requestPutEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> { export async function requestPutEvent(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(rundownPath, data); return axios.put(rundownPath, data);
} }
@@ -48,7 +63,7 @@ export type ReorderEntry = {
/** /**
* HTTP request to reorder events * HTTP request to reorder events
*/ */
export async function requestReorderEvent(data: ReorderEntry): Promise<AxiosResponse<OntimeRundownEntry>> { export async function requestReorderEvent(data: ReorderEntry): Promise<AxiosResponse<OntimeEntry>> {
return axios.patch(`${rundownPath}/reorder`, data); return axios.patch(`${rundownPath}/reorder`, data);
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types'; import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -54,7 +54,7 @@ export const previewRundown = async (
sheetId: string, sheetId: string,
options: ImportMap, options: ImportMap,
): Promise<{ ): Promise<{
rundown: OntimeRundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
}> => { }> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options }); const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
@@ -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'>[];
}; };
@@ -4,13 +4,12 @@ import { IoCopy } from 'react-icons/io5';
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react'; import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
import { tooltipDelayFast } from '../../../ontimeConfig'; import { tooltipDelayFast } from '../../../ontimeConfig';
import { Size } from '../../models/Util.type';
import copyToClipboard from '../../utils/copyToClipboard'; import copyToClipboard from '../../utils/copyToClipboard';
interface CopyTagProps { interface CopyTagProps {
copyValue: string; copyValue: string;
label: string; label: string;
size?: Size; size?: 'xs' | 'sm' | 'md' | 'lg';
disabled?: boolean; disabled?: boolean;
onClick?: () => void; onClick?: () => void;
} }
@@ -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 });
@@ -1,45 +0,0 @@
$loader-size: 4rem;
.overlay {
position: absolute;
z-index: 10;
width: 100%;
height: 100%;
display: grid;
place-content: center;
background-color: $black-10;
backdrop-filter: blur(5px);
}
.loader {
width: $loader-size;
height: $loader-size;
background: $blue-500;
display: inline-block;
border-radius: 50%;
box-sizing: border-box;
animation: animloader 1s ease-in infinite;
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@@ -1,9 +0,0 @@
import style from './LoaderOverlay.module.scss';
export default function LoaderOverlay() {
return (
<div className={style.overlay}>
<span className={style.loader} />
</div>
);
}
@@ -1,16 +0,0 @@
import { memo } from 'react';
import NavigationMenu from './NavigationMenu';
interface ProductionNavigationMenuProps {
isMenuOpen: boolean;
onMenuClose: () => void;
}
function ProductionNavigationMenu(props: ProductionNavigationMenuProps) {
const { isMenuOpen, onMenuClose } = props;
return <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />;
}
export default memo(ProductionNavigationMenu);
@@ -12,7 +12,7 @@ type OptionsField = {
defaultValue?: string; defaultValue?: string;
}; };
export type MultiselectOption = { value: string; label: string; colour: string }; type MultiselectOption = { value: string; label: string; colour: string };
export type MultiselectOptions = Record<string, MultiselectOption>; export type MultiselectOptions = Record<string, MultiselectOption>;
type MultiOptionsField = { type MultiOptionsField = {
type: 'multi-option'; type: 'multi-option';
@@ -1,32 +0,0 @@
// roughly from https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useMediaQuery/useMediaQuery.ts
import { useCallback, useEffect, useState } from 'react';
function getMatches(query: string): boolean {
return window.matchMedia(query).matches;
}
// TODO: debounce handleChange
export default function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(getMatches(query));
const handleChange = useCallback(() => {
setMatches(getMatches(query));
}, [query]);
useEffect(() => {
const matchMedia = window.matchMedia(query);
// Triggered at the first client-side load and if query changes
handleChange();
// Listen matchMedia
matchMedia.addEventListener('change', handleChange);
return () => {
matchMedia.removeEventListener('change', handleChange);
};
}, [handleChange, query]);
return matches;
}
@@ -1,11 +1,9 @@
import { useMutation, useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { editAutomationSettings, getAutomationSettings } from '../api/automation'; import { getAutomationSettings } from '../api/automation';
import { AUTOMATION } from '../api/constants'; import { AUTOMATION } from '../api/constants';
import { logAxiosError } from '../api/utils';
import { automationPlaceholderSettings } from '../models/AutomationSettings'; import { automationPlaceholderSettings } from '../models/AutomationSettings';
import { ontimeQueryClient } from '../queryClient';
export default function useAutomationSettings() { export default function useAutomationSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
@@ -20,15 +18,3 @@ export default function useAutomationSettings() {
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch }; return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
} }
export function useAutomationSettingsMutation() {
const { isPending, mutateAsync } = useMutation({
mutationFn: editAutomationSettings,
onError: (error) => logAxiosError('Error saving Automation settings', error),
onSuccess: (data) => {
ontimeQueryClient.setQueryData(AUTOMATION, data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: AUTOMATION }),
});
return { isPending, mutateAsync };
}
@@ -11,7 +11,7 @@ const placeholderProjectList: ProjectFileListResponse = {
lastLoadedProject: '', lastLoadedProject: '',
}; };
export function useProjectList() { function useProjectList() {
const { data, status, refetch } = useQuery({ const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST, queryKey: PROJECT_LIST,
queryFn: getProjects, queryFn: getProjects,
@@ -1,23 +1,29 @@
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: [],
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 +43,16 @@ export function useFlatRundown() {
const loadedProject = useRef<string>(''); const loadedProject = useRef<string>('');
const [prevRevision, setPrevRevision] = useState<number>(-1); const [prevRevision, setPrevRevision] = useState<number>(-1);
const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]); const [flatRundown, setFlatRundown] = useState<OntimeEntry[]>([]);
// update data whenever the revision changes // update data whenever the revision changes
useEffect(() => { useEffect(() => {
if (data.revision !== -1 && data.revision !== prevRevision) { if (data.revision !== -1 && data.revision !== prevRevision) {
const flatRundown = data.order.map((id) => data.rundown[id]); const flatRundown = data.order.map((id) => data.entries[id]);
setFlatRunDown(flatRundown); setFlatRundown(flatRundown);
setPrevRevision(data.revision); setPrevRevision(data.revision);
} }
}, [data.order, data.revision, data.rundown, prevRevision]); }, [data.entries, data.order, data.revision, prevRevision]);
// TODO: should we have a project id field? // TODO: should we have a project id field?
// invalidate current version if project changes // invalidate current version if project changes
@@ -57,13 +63,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);
+76 -45
View File
@@ -2,11 +2,12 @@ import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
isOntimeEvent, isOntimeEvent,
MaybeString,
OntimeBlock, OntimeBlock,
OntimeDelay, OntimeDelay,
OntimeEntry,
OntimeEvent, OntimeEvent,
OntimeRundownEntry, Rundown,
RundownCached,
TimeField, TimeField,
TimeStrategy, TimeStrategy,
TransientEventPayload, TransientEventPayload,
@@ -31,12 +32,12 @@ import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{ export type EventOptions = Partial<{
// options to any new block (event / delay / block) // options to any new block (event / delay / block)
after: string; after: MaybeString;
before: string; before: MaybeString;
// options to blocks of type OntimeEvent // options to blocks of type OntimeEvent
defaultPublic: boolean; defaultPublic: boolean;
linkPrevious: boolean; linkPrevious: boolean;
lastEventId: string; lastEventId: MaybeString;
}>; }>;
/** /**
@@ -57,11 +58,11 @@ export const useEventAction = () => {
const getEventById = useCallback( const getEventById = useCallback(
(eventId: string) => { (eventId: string) => {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN); const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.rundown) { if (!cachedRundown?.entries) {
return; return;
} }
return cachedRundown.rundown[eventId]; return cachedRundown.entries[eventId];
}, },
[queryClient], [queryClient],
); );
@@ -100,9 +101,8 @@ export const useEventAction = () => {
newEvent.linkStart = applicationOptions.lastEventId; newEvent.linkStart = applicationOptions.lastEventId;
} else if (applicationOptions?.lastEventId) { } else if (applicationOptions?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!; const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
const { rundown } = rundownData; const previousEvent = rundownData.entries[applicationOptions.lastEventId];
const previousEvent = rundown[applicationOptions.lastEventId];
if (isOntimeEvent(previousEvent)) { if (isOntimeEvent(previousEvent)) {
newEvent.timeStart = previousEvent.timeEnd; newEvent.timeStart = previousEvent.timeEnd;
} }
@@ -180,15 +180,21 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
const eventId = newEvent.id; const eventId = newEvent.id;
if (previousData && eventId) { if (previousData && eventId) {
// optimistically update object // optimistically update object
const newRundown = { ...previousData.rundown }; const newRundown = { ...previousData.entries };
// @ts-expect-error -- we expect the events to be of same type // @ts-expect-error -- we expect the events to be of same type
newRundown[eventId] = { ...newRundown[eventId], ...newEvent }; newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 }); queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
order: previousData.order,
entries: newRundown,
revision: -1,
});
} }
// Return a context with the previous and new events // Return a context with the previous and new events
@@ -196,7 +202,7 @@ export const useEventAction = () => {
}, },
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => { onError: (_error, _newEvent, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
@@ -210,7 +216,7 @@ export const useEventAction = () => {
* Updates existing event * Updates existing event
*/ */
const updateEvent = useCallback( const updateEvent = useCallback(
async (event: Partial<OntimeRundownEntry>) => { async (event: Partial<OntimeEntry>) => {
try { try {
await _updateEventMutation.mutateAsync(event); await _updateEventMutation.mutateAsync(event);
} catch (error) { } catch (error) {
@@ -296,9 +302,9 @@ export const useEventAction = () => {
* Utility function to get the previous event end time * Utility function to get the previous event end time
*/ */
function getPreviousEnd(): number { function getPreviousEnd(): number {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN); const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.order || !cachedRundown?.rundown) { if (!cachedRundown?.order || !cachedRundown?.entries) {
return 0; return 0;
} }
@@ -308,7 +314,7 @@ export const useEventAction = () => {
} }
let previousEnd = 0; let previousEnd = 0;
for (let i = index - 1; i >= 0; i--) { for (let i = index - 1; i >= 0; i--) {
const event = cachedRundown.rundown[cachedRundown.order[i]]; const event = cachedRundown.entries[cachedRundown.order[i]];
if (isOntimeEvent(event)) { if (isOntimeEvent(event)) {
previousEnd = event.timeEnd; previousEnd = event.timeEnd;
break; break;
@@ -331,11 +337,11 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousEvents = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousEvents) { if (previousRundown) {
const eventIds = new Set(ids); const eventIds = new Set(ids);
const newRundown = { ...previousEvents.rundown }; const newRundown = { ...previousRundown.entries };
eventIds.forEach((eventId) => { eventIds.forEach((eventId) => {
if (Object.hasOwn(newRundown, eventId)) { if (Object.hasOwn(newRundown, eventId)) {
@@ -349,16 +355,22 @@ export const useEventAction = () => {
} }
}); });
queryClient.setQueryData(RUNDOWN, { order: previousEvents.order, rundown: newRundown, revision: -1 }); queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousRundown.id,
title: previousRundown.title,
order: previousRundown.order,
entries: newRundown,
revision: -1,
});
} }
// Return a context with the previous and new events // Return a context with the previous rundown
return { previousEvents }; return { previousRundown };
}, },
onSettled: async () => { onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN }); await queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
onError: (_error, _newEvent, context) => { onError: (_error, _newEvent, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousEvents); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousRundown);
}, },
networkMode: 'always', networkMode: 'always',
}); });
@@ -386,19 +398,21 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) { if (previousData) {
// optimistically update object // optimistically update object
const newOrder = previousData.order.filter((id) => !eventIds.includes(id)); const newOrder = previousData.order.filter((id) => !eventIds.includes(id));
const newRundown = { ...previousData.rundown }; const newRundown = { ...previousData.entries };
for (const eventId of eventIds) { for (const eventId of eventIds) {
delete newRundown[eventId]; delete newRundown[eventId];
} }
queryClient.setQueryData(RUNDOWN, { queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
order: newOrder, order: newOrder,
rundown: newRundown, entries: newRundown,
revision: -1, revision: -1,
}); });
} }
@@ -409,7 +423,7 @@ export const useEventAction = () => {
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => { onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
@@ -445,10 +459,16 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
// optimistically update object // optimistically update object
queryClient.setQueryData(RUNDOWN, { rundown: {}, order: [], revision: -1 }); queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData?.id ?? 'default',
title: previousData?.title ?? '',
entries: {},
order: [],
revision: -1,
});
// Return a context with the previous and new events // Return a context with the previous and new events
return { previousData }; return { previousData };
@@ -456,7 +476,7 @@ export const useEventAction = () => {
// Mutation fails, rollback undos optimist update // Mutation fails, rollback undos optimist update
onError: (_error, _eventId, context) => { onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
@@ -516,13 +536,18 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) { if (previousData) {
// optimistically update object // optimistically update object
const newOrder = reorderArray(previousData.order, data.from, data.to); const newOrder = reorderArray(previousData.order, data.from, data.to);
queryClient.setQueryData<Rundown>(RUNDOWN, {
queryClient.setQueryData(RUNDOWN, { order: newOrder, rundown: previousData.rundown, revision: -1 }); id: previousData.id,
title: previousData.title,
order: newOrder,
entries: previousData.entries,
revision: -1,
});
} }
// Return a context with the previous and new events // Return a context with the previous and new events
@@ -531,7 +556,7 @@ export const useEventAction = () => {
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => { onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
@@ -572,22 +597,28 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) { if (previousData) {
// optimistically update object // optimistically update object
const newRundown = { ...previousData.rundown }; const newRundown = { ...previousData.entries };
const eventA = previousData.rundown[from]; const eventA = previousData.entries[from];
const eventB = previousData.rundown[to]; const eventB = previousData.entries[to];
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) { if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
return; return;
} }
const { newA, newB } = swapEventData(eventA, eventB); const [newA, newB] = swapEventData(eventA, eventB);
newRundown[from] = newA; newRundown[from] = newA;
newRundown[to] = newB; newRundown[to] = newB;
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 }); queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
order: previousData.order,
entries: newRundown,
revision: -1,
});
} }
// Return a context with the previous events // Return a context with the previous events
@@ -596,7 +627,7 @@ export const useEventAction = () => {
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => { onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
@@ -12,7 +12,7 @@ type noop = (this: any, ...args: any[]) => any;
type PickFunction<T extends noop> = (this: ThisParameterType<T>, ...args: Parameters<T>) => ReturnType<T>; type PickFunction<T extends noop> = (this: ThisParameterType<T>, ...args: Parameters<T>) => ReturnType<T>;
export const isFunction = (value: unknown): value is (...args: any) => any => typeof value === 'function'; const isFunction = (value: unknown): value is (...args: any) => any => typeof value === 'function';
export default function useMemoisedFn<T extends noop>(fn: T) { export default function useMemoisedFn<T extends noop>(fn: T) {
if (isDev) { if (isDev) {
-10
View File
@@ -91,14 +91,6 @@ export const setPlayback = {
}, },
}; };
export const useInfoPanel = createSelector((state: RuntimeStore) => ({
eventNow: state.eventNow,
eventNext: state.eventNext,
playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
}));
export const useAuxTimerTime = createSelector((state: RuntimeStore) => state.auxtimer1.current); export const useAuxTimerTime = createSelector((state: RuntimeStore) => state.auxtimer1.current);
export const useAuxTimerControl = createSelector((state: RuntimeStore) => ({ export const useAuxTimerControl = createSelector((state: RuntimeStore) => ({
@@ -145,8 +137,6 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({
timeDanger: state.eventNow?.timeDanger ?? null, timeDanger: state.eventNow?.timeDanger ?? null,
})); }));
export const setClientName = (newName: string) => socketSendJson('set-client-name', newName);
export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({ export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.runtime.plannedStart, plannedStart: state.runtime.plannedStart,
actualStart: state.runtime.actualStart, actualStart: state.runtime.actualStart,
@@ -1 +0,0 @@
export type Size = 'xs' | 'sm' | 'md' | 'lg';
+1 -1
View File
@@ -11,7 +11,7 @@ type LogStore = {
logs: Log[]; logs: Log[];
}; };
export const logger = createStore<LogStore>(() => ({ const logger = createStore<LogStore>(() => ({
logs: [], logs: [],
})); }));
@@ -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,32 @@ 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'],
entries: {
'1': { id: '1' } as OntimeEntry,
'2': { id: '2' } as OntimeEntry,
},
},
second: {
id: '',
title: '',
revision: 0,
order: ['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' }]);
});
});
@@ -1,4 +1,4 @@
import { EndAction, EventCustomFields, OntimeEvent, SupportedEvent, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, EntryCustomFields, OntimeEvent, SupportedEvent, TimerType, TimeStrategy } from 'ontime-types';
import { cloneEvent } from '../eventsManager'; import { cloneEvent } from '../eventsManager';
@@ -29,7 +29,7 @@ describe('cloneEvent()', () => {
gap: 0, gap: 0,
custom: { custom: {
lighting: '3', lighting: '3',
} as EventCustomFields, } as EntryCustomFields,
}; };
const cloned = cloneEvent(original); const cloned = cloneEvent(original);
@@ -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();
}) });
}); });
+24 -3
View File
@@ -1,10 +1,31 @@
import { stringify } from 'csv-stringify/browser/esm/sync'; import { stringify } from 'csv-stringify/browser/esm/sync';
import { OntimeEntry, ProjectRundowns } from 'ontime-types';
/** /**
* @description Converts an array of arrays to a CSV file * Converts an array of arrays to a CSV file
* @param {string[][]} arrayOfArrays
* @return {string}
*/ */
export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string { export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string {
return stringify(arrayOfArrays); return stringify(arrayOfArrays);
} }
/**
* Receives an object of rundowns, and flattens them into a single, linear rundown
* Used for CSV export
*/
export function aggregateRundowns(rundowns: ProjectRundowns): OntimeEntry[] {
const rundownKeys = Object.keys(rundowns);
if (rundownKeys.length === 0) return [];
const flatRundown: OntimeEntry[] = [];
for (const key of rundownKeys) {
const { order, entries } = rundowns[key];
for (let i = 0; i < order.length; i++) {
const entryId = order[i];
const entry = entries[entryId];
flatRundown.push(entry);
}
}
return flatRundown;
}
@@ -23,6 +23,7 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
isPublic: event.isPublic, isPublic: event.isPublic,
skip: event.skip, skip: event.skip,
colour: event.colour, colour: event.colour,
currentBlock: event.currentBlock,
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,
+11 -18
View File
@@ -1,4 +1,4 @@
import { Log, RundownCached, RuntimeStore } from 'ontime-types'; import { Log, Rundown, RuntimeStore } from 'ontime-types';
import { isProduction, websocketUrl } from '../../externals'; import { isProduction, websocketUrl } from '../../externals';
import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants'; import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants';
@@ -16,11 +16,10 @@ import { addDialog } from '../stores/dialogStore';
import { addLog } from '../stores/logger'; import { addLog } from '../stores/logger';
import { addToBatchUpdates, flushBatchUpdates, patchRuntime, patchRuntimeProperty } from '../stores/runtime'; import { addToBatchUpdates, flushBatchUpdates, 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;
@@ -50,15 +49,14 @@ export const connectSocket = () => {
console.warn('WebSocket disconnected'); console.warn('WebSocket disconnected');
setOnlineStatus(false); setOnlineStatus(false);
if (shouldReconnect) { // we decide to allows reconnect
reconnectTimeout = setTimeout(() => { reconnectTimeout = setTimeout(() => {
console.warn('WebSocket: attempting reconnect'); console.warn('WebSocket: attempting reconnect');
if (websocket && websocket.readyState === WebSocket.CLOSED) { if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1; reconnectAttempts += 1;
connectSocket(); connectSocket();
} }
}, reconnectInterval); }, reconnectInterval);
}
}; };
websocket.onerror = (error) => { websocket.onerror = (error) => {
@@ -203,7 +201,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 });
@@ -224,11 +222,6 @@ export const connectSocket = () => {
}; };
}; };
export const disconnectSocket = () => {
shouldReconnect = false;
websocket?.close();
};
export const socketSend = (message: any) => { export const socketSend = (message: any) => {
if (websocket && websocket.readyState === WebSocket.OPEN) { if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(message); websocket.send(message);
+1 -1
View File
@@ -35,7 +35,7 @@ function getFormatFromParams() {
* Gets the format options from the applicaton settings * Gets the format options from the applicaton settings
* @returns a string equivalent to the format, ie: hh:mm:ss a or HH:mm:ss * @returns a string equivalent to the format, ie: hh:mm:ss a or HH:mm:ss
*/ */
export function getFormatFromSettings(): TimeFormat { function getFormatFromSettings(): TimeFormat {
const settings: Settings | undefined = ontimeQueryClient.getQueryData(APP_SETTINGS); const settings: Settings | undefined = ontimeQueryClient.getQueryData(APP_SETTINGS);
return settings?.timeFormat ?? '24'; return settings?.timeFormat ?? '24';
} }
-11
View File
@@ -1,11 +0,0 @@
import { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers';
import 'vitest';
// ugly hack because vite and pnpm are not playing ball with jest
// https://github.com/testing-library/jest-dom/issues/123
declare global {
namespace Vi {
type Assertion<T = any> = TestingLibraryMatchers<T, void>;
}
}
@@ -29,8 +29,8 @@ export default function ReportSettings() {
}; };
const combinedReport = useMemo(() => { const combinedReport = useMemo(() => {
return getCombinedReport(reportData, data.rundown, data.order); return getCombinedReport(reportData, data.entries, data.order);
}, [reportData, data.rundown, data.order]); }, [reportData, data.entries, data.order]);
return ( return (
<Panel.Section> <Panel.Section>
@@ -1,4 +1,4 @@
import { isOntimeEvent, MaybeNumber, NormalisedRundown, OntimeReport } from 'ontime-types'; import { EntryId, isOntimeEvent, MaybeNumber, OntimeReport, RundownEntries } from 'ontime-types';
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv'; import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
import { formatTime } from '../../../../common/utils/time'; import { formatTime } from '../../../../common/utils/time';
@@ -16,7 +16,7 @@ export type CombinedReport = {
/** /**
* Creates a combined report with the rundown data * Creates a combined report with the rundown data
*/ */
export function getCombinedReport(report: OntimeReport, rundown: NormalisedRundown, order: string[]): CombinedReport[] { export function getCombinedReport(report: OntimeReport, rundown: RundownEntries, order: EntryId[]): CombinedReport[] {
if (Object.keys(report).length === 0) return []; if (Object.keys(report).length === 0) return [];
if (order.length === 0) return []; if (order.length === 0) return [];
@@ -13,10 +13,6 @@ import * as Panel from '../../panel-utils/PanelUtils';
import GeneralPinInput from './GeneralPinInput'; import GeneralPinInput from './GeneralPinInput';
export type GeneralPanelFormValues = {
filename: string;
};
export default function GeneralPanelForm() { export default function GeneralPanelForm() {
const { data, status, refetch } = useSettings(); const { data, status, refetch } = useSettings();
const { const {
@@ -102,7 +102,6 @@ export default function EditorSettingsForm() {
onChange={(event) => setDefaultEndAction(event.target.value as EndAction)} onChange={(event) => setDefaultEndAction(event.target.value as EndAction)}
> >
<option value={EndAction.None}>None</option> <option value={EndAction.None}>None</option>
<option value={EndAction.Stop}>Stop</option>
<option value={EndAction.LoadNext}>Load next</option> <option value={EndAction.LoadNext}>Load next</option>
<option value={EndAction.PlayNext}>Play next</option> <option value={EndAction.PlayNext}>Play next</option>
</Select> </Select>
@@ -6,7 +6,7 @@ export async function makeProjectPatch(data: DatabaseModel, mergeKeys: Record<st
for (const key in mergeKeys) { for (const key in mergeKeys) {
if (isKeyOfType(key, data) && mergeKeys[key]) { if (isKeyOfType(key, data) && mergeKeys[key]) {
// if the rundown is merged we also need the custom fields // if the rundown is merged we also need the custom fields
if (key === 'rundown') { if (key === 'rundowns') {
patchObject.customFields = data['customFields']; patchObject.customFields = data['customFields'];
} }
Object.assign(patchObject, { [key]: data[key] }); Object.assign(patchObject, { [key]: data[key] });
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { CustomFields, OntimeRundown } from 'ontime-types'; import { CustomFields, Rundown } from 'ontime-types';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -9,7 +9,7 @@ import useGoogleSheet from './useGoogleSheet';
import { useSheetStore } from './useSheetStore'; import { useSheetStore } from './useSheetStore';
interface ImportReviewProps { interface ImportReviewProps {
rundown: OntimeRundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
onFinished: () => void; onFinished: () => void;
onCancel: () => void; onCancel: () => void;
@@ -29,7 +29,12 @@ export default function ImportReview(props: ImportReviewProps) {
const applyImport = async () => { const applyImport = async () => {
setLoading(true); setLoading(true);
await importRundown(rundown, customFields); await importRundown(
{
[rundown.id]: rundown,
},
customFields,
);
setLoading(false); setLoading(false);
onFinished(); onFinished();
}; };
@@ -3,7 +3,7 @@ import { ImportCustom, ImportMap } from 'ontime-utils';
export type NamedImportMap = typeof namedImportMap; export type NamedImportMap = typeof namedImportMap;
// Record of label and import name // Record of label and import name
export const namedImportMap = { const namedImportMap = {
Worksheet: 'event schedule', Worksheet: 'event schedule',
Start: 'time start', Start: 'time start',
'Link start': 'link start', 'Link start': 'link start',
@@ -1,6 +1,6 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { IoLink } from 'react-icons/io5'; import { IoLink } from 'react-icons/io5';
import { CustomFields, isOntimeBlock, isOntimeEvent, OntimeRundown } from 'ontime-types'; import { CustomFields, isOntimeBlock, isOntimeEvent, Rundown } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import Tag from '../../../../../common/components/tag/Tag'; import Tag from '../../../../../common/components/tag/Tag';
@@ -10,7 +10,7 @@ import * as Panel from '../../../panel-utils/PanelUtils';
import style from './PreviewRundown.module.scss'; import style from './PreviewRundown.module.scss';
interface PreviewRundownProps { interface PreviewRundownProps {
rundown: OntimeRundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
} }
@@ -53,75 +53,76 @@ export default function PreviewRundown(props: PreviewRundownProps) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rundown.map((event) => { {rundown.order.map((entryId) => {
if (isOntimeBlock(event)) { const entry = rundown.entries[entryId];
if (isOntimeBlock(entry)) {
return ( return (
<tr key={event.id}> <tr key={entry.id}>
<td className={style.center}> <td className={style.center}>
<Tag>-</Tag> <Tag>-</Tag>
</td> </td>
<td className={style.center}> <td className={style.center}>
<Tag>{event.type}</Tag> <Tag>{entry.type}</Tag>
</td> </td>
<td /> <td />
<td colSpan={99}>{event.title}</td> <td colSpan={99}>{entry.title}</td>
</tr> </tr>
); );
} }
if (!isOntimeEvent(event)) { if (!isOntimeEvent(entry)) {
return null; return null;
} }
eventIndex += 1; eventIndex += 1;
const colour = event.colour ? getAccessibleColour(event.colour) : {}; const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
const countToEnd = booleanToText(event.countToEnd); const countToEnd = booleanToText(entry.countToEnd);
const isPublic = booleanToText(event.isPublic); const isPublic = booleanToText(entry.isPublic);
const skip = booleanToText(event.skip); const skip = booleanToText(entry.skip);
return ( return (
<Fragment key={event.id}> <Fragment key={entry.id}>
<tr> <tr>
<td className={style.center}> <td className={style.center}>
<Tag>{eventIndex}</Tag> <Tag>{eventIndex}</Tag>
</td> </td>
<td className={style.center}> <td className={style.center}>
<Tag>{event.type}</Tag> <Tag>{entry.type}</Tag>
</td> </td>
<td className={style.nowrap}>{event.cue}</td> <td className={style.nowrap}>{entry.cue}</td>
<td>{event.title}</td> <td>{entry.title}</td>
<td className={style.flex}> <td className={style.flex}>
<span className={event.linkStart ? style.subdued : undefined}>{millisToString(event.timeStart)}</span> <span className={entry.linkStart ? style.subdued : undefined}>{millisToString(entry.timeStart)}</span>
{event.linkStart && <IoLink className={style.linkStartActive} />} {entry.linkStart && <IoLink className={style.linkStartActive} />}
</td> </td>
<td>{millisToString(event.timeEnd)}</td> <td>{millisToString(entry.timeEnd)}</td>
<td>{millisToString(event.duration)}</td> <td>{millisToString(entry.duration)}</td>
<td>{millisToString(event.timeWarning)}</td> <td>{millisToString(entry.timeWarning)}</td>
<td>{millisToString(event.timeDanger)}</td> <td>{millisToString(entry.timeDanger)}</td>
<td className={style.center}>{countToEnd && <Tag>{countToEnd}</Tag>}</td> <td className={style.center}>{countToEnd && <Tag>{countToEnd}</Tag>}</td>
<td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td> <td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td>
<td>{skip && <Tag>{skip}</Tag>}</td> <td>{skip && <Tag>{skip}</Tag>}</td>
<td style={{ ...colour }}>{event.colour}</td> <td style={{ ...colour }}>{entry.colour}</td>
<td className={style.center}> <td className={style.center}>
<Tag>{event.timerType}</Tag> <Tag>{entry.timerType}</Tag>
</td> </td>
<td className={style.center}> <td className={style.center}>
<Tag>{event.endAction}</Tag> <Tag>{entry.endAction}</Tag>
</td> </td>
{isOntimeEvent(event) && {isOntimeEvent(entry) &&
fieldKeys.map((field) => { fieldKeys.map((field) => {
let value = ''; let value = '';
if (field in event.custom) { if (field in entry.custom) {
value = event.custom[field]; value = entry.custom[field];
} }
return <td key={field}>{value}</td>; return <td key={field}>{value}</td>;
})} })}
<td className={style.center}> <td className={style.center}>
<Tag>{event.id}</Tag> <Tag>{entry.id}</Tag>
</td> </td>
</tr> </tr>
{event.note && ( {entry.note && (
<tr> <tr>
<td colSpan={99} className={style.secondaryRow}> <td colSpan={99} className={style.secondaryRow}>
Note: {event.note} Note: {entry.note}
</td> </td>
</tr> </tr>
)} )}
@@ -1,5 +1,5 @@
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types'; import { AuthenticationStatus, CustomFields, ProjectRundowns } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../common/api/constants'; import { CUSTOM_FIELDS, RUNDOWN } from '../../../../common/api/constants';
@@ -75,9 +75,9 @@ export default function useGoogleSheet() {
}; };
/** applies rundown and customFields to current project */ /** applies rundown and customFields to current project */
const importRundown = async (rundown: OntimeRundown, customFields: CustomFields) => { const importRundown = async (rundowns: ProjectRundowns, customFields: CustomFields) => {
try { try {
await patchData({ rundown, customFields }); await patchData({ rundowns, customFields });
// we are unable to optimistically set the rundown since we need // we are unable to optimistically set the rundown since we need
// it to be normalised // it to be normalised
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
@@ -1,4 +1,4 @@
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types'; import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types';
import { defaultImportMap, ImportMap } from 'ontime-utils'; import { defaultImportMap, ImportMap } from 'ontime-utils';
import { create } from 'zustand'; import { create } from 'zustand';
@@ -15,8 +15,8 @@ type SheetStore = {
setAuthenticationStatus: (status: AuthenticationStatus) => void; setAuthenticationStatus: (status: AuthenticationStatus) => void;
// we get this from a preview response // we get this from a preview response
rundown: OntimeRundown | null; rundown: Rundown | null;
setRundown: (rundown: OntimeRundown | null) => void; setRundown: (rundown: Rundown | null) => void;
// we get this from a preview response // we get this from a preview response
customFields: CustomFields | null; customFields: CustomFields | null;
@@ -60,7 +60,7 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
setAuthenticationStatus: (status: AuthenticationStatus) => set({ authenticationStatus: status }), setAuthenticationStatus: (status: AuthenticationStatus) => set({ authenticationStatus: status }),
setRundown: (rundown: OntimeRundown | null) => set({ rundown }), setRundown: (rundown: Rundown | null) => set({ rundown }),
setCustomFields: (customFields: CustomFields | null) => set({ customFields }), setCustomFields: (customFields: CustomFields | null) => set({ customFields }),
@@ -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) {
@@ -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 {
+35 -28
View File
@@ -1,15 +1,16 @@
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 { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useHotkeys } from '@mantine/hooks'; import { useHotkeys } from '@mantine/hooks';
import { import {
type EntryId,
type MaybeString,
type PlayableEvent,
type Rundown,
isOntimeBlock, isOntimeBlock,
isOntimeEvent, isOntimeEvent,
isPlayableEvent, isPlayableEvent,
MaybeString,
PlayableEvent,
Playback, Playback,
RundownCached,
SupportedEvent, SupportedEvent,
} from 'ontime-types'; } from 'ontime-types';
import { import {
@@ -21,6 +22,7 @@ import {
getPreviousBlockNormal, getPreviousBlockNormal,
getPreviousNormal, getPreviousNormal,
isNewLatest, isNewLatest,
reorderArray,
} from 'ontime-utils'; } from 'ontime-utils';
import { type EventOptions, useEventAction } from '../../common/hooks/useEventAction'; import { type EventOptions, useEventAction } from '../../common/hooks/useEventAction';
@@ -39,12 +41,12 @@ 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 } = data;
const [statefulEntries, setStatefulEntries] = useState(order); const [statefulEntries, setStatefulEntries] = useState<EntryId[]>(order);
const featureData = useRundownEditor(); const featureData = useRundownEditor();
const { addEvent, reorderEvent, deleteEvent } = useEventAction(); const { addEvent, reorderEvent, deleteEvent } = useEventAction();
@@ -65,30 +67,30 @@ export default function Rundown({ data }: RundownProps) {
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]); deleteEvent([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, deleteEvent, 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 === SupportedEvent.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 }); addEvent(newEvent, { after: adjustedCursor ?? undefined });
} }
}, },
[addEvent, order, rundown], [addEvent, order, entries],
); );
const insertAtId = useCallback( const insertAtId = useCallback(
@@ -124,7 +126,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 +142,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 +160,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,13 +169,13 @@ 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(
@@ -182,14 +184,14 @@ export default function Rundown({ data }: RundownProps) {
return; return;
} }
const { index } = const { index } =
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor); direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor);
if (index !== null) { if (index !== null) {
const offsetIndex = direction === 'up' ? index + 1 : index - 1; const offsetIndex = direction === 'up' ? index + 1 : index - 1;
reorderEvent(cursor, offsetIndex, index); reorderEvent(cursor, offsetIndex, index);
} }
}, },
[order, reorderEvent, rundown], [order, reorderEvent, entries],
); );
// shortcuts // shortcuts
@@ -238,6 +240,9 @@ 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]);
/**
* On drag end, we reorder the events
*/
const handleOnDragEnd = (event: DragEndEvent) => { const handleOnDragEnd = (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
@@ -245,9 +250,10 @@ export default function Rundown({ data }: RundownProps) {
if (active.id !== over?.id) { if (active.id !== over?.id) {
const fromIndex = active.data.current?.sortable.index; const fromIndex = active.data.current?.sortable.index;
const toIndex = over.data.current?.sortable.index; const toIndex = over.data.current?.sortable.index;
// ugly hack to handle inconsistencies between dnd-kit and async store updates
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
setStatefulEntries((currentEntries) => { setStatefulEntries((currentEntries) => {
return arrayMove(currentEntries, fromIndex, toIndex); return reorderArray(currentEntries, fromIndex, toIndex);
}); });
reorderEvent(String(active.id), fromIndex, toIndex); reorderEvent(String(active.id), fromIndex, toIndex);
} }
@@ -259,11 +265,11 @@ export default function Rundown({ data }: RundownProps) {
} }
// last event is used to calculate relative timings // last event is used to calculate relative timings
let lastEvent: PlayableEvent | undefined; // used by indicators let lastEvent: PlayableEvent | null = null; // used by indicators
let thisEvent: PlayableEvent | undefined; let thisEvent: PlayableEvent | null = null;
// previous entry is used to infer position in the rundown for new events // previous entry is used to infer position in the rundown for new events
let previousEntryId: string | undefined; let previousEntryId: MaybeString = null;
let thisId = previousEntryId; let thisId: MaybeString = null;
let eventIndex = 0; let eventIndex = 0;
// all events before the current selected are in the past // all events before the current selected are in the past
@@ -272,6 +278,7 @@ export default function Rundown({ data }: RundownProps) {
let totalGap = 0; 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 let isLinkedToLoaded = true; //check if the event can link all the way back to the currently playing event
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 onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
@@ -281,7 +288,7 @@ export default function Rundown({ data }: RundownProps) {
// we iterate through a stateful copy of order to make the operations smoother // we iterate through a stateful copy of order to make the operations smoother
// this means that this can be out of sync with order until the useEffect runs // 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 // instead of writing all the logic guards, we simply short circuit rendering here
const entry = rundown[entryId]; const entry = entries[entryId];
if (!entry) { if (!entry) {
return null; return null;
} }
@@ -1,5 +1,14 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types'; import {
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
MaybeString,
OntimeEntry,
OntimeEvent,
Playback,
SupportedEvent,
} from 'ontime-types';
import { useEventAction } from '../../common/hooks/useEventAction'; import { useEventAction } from '../../common/hooks/useEventAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn'; import useMemoisedFn from '../../common/hooks/useMemoisedFn';
@@ -28,13 +37,13 @@ export type EventItemActions =
interface RundownEntryProps { interface RundownEntryProps {
type: SupportedEvent; type: SupportedEvent;
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
@@ -150,7 +159,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,7 +176,7 @@ 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}
@@ -184,9 +193,15 @@ export default function RundownEntry(props: RundownEntryProps) {
actionHandler={actionHandler} actionHandler={actionHandler}
/> />
); );
} else if (data.type === SupportedEvent.Block) { } else if (isOntimeBlock(data)) {
return <BlockBlock data={data} hasCursor={hasCursor} onDelete={() => actionHandler('delete')} />; return (
} else if (data.type === SupportedEvent.Delay) { <BlockBlock data={data} hasCursor={hasCursor}>
{data.events.map((eventId) => {
return <div key={eventId}>{eventId}</div>;
})}
</BlockBlock>
);
} else if (isOntimeDelay(data)) {
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 */
@@ -21,18 +21,12 @@ $block-cursor-color: $orange-400;
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-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;
@@ -1,9 +1,7 @@
@use '../blockMixins' as *; @use '../blockMixins' as *;
.block { .block {
@include block-spacing;
@include block-styling; @include block-styling;
margin-left: 0.5rem;
background-color: $block-bg2; background-color: $block-bg2;
@@ -22,7 +20,3 @@
.drag { .drag {
@include drag-style; @include drag-style;
} }
.actionMenu {
justify-self: flex-end;
}
@@ -1,4 +1,4 @@
import { useRef } from 'react'; import { PropsWithChildren, useRef } from 'react';
import { IoReorderTwo } from 'react-icons/io5'; import { IoReorderTwo } 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';
@@ -7,18 +7,15 @@ import { OntimeBlock } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import EditableBlockTitle from '../common/EditableBlockTitle'; import EditableBlockTitle from '../common/EditableBlockTitle';
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;
} }
export default function BlockBlock(props: BlockBlockProps) { export default function BlockBlock(props: PropsWithChildren<BlockBlockProps>) {
const { data, hasCursor, onDelete } = props; const { data, hasCursor, children } = props;
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
@@ -46,7 +43,8 @@ export default function BlockBlock(props: BlockBlockProps) {
<IoReorderTwo /> <IoReorderTwo />
</span> </span>
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' /> <EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
<BlockDelete onDelete={onDelete} /> <button>+++</button>
<div>{children}</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}
/>
);
}
@@ -1,11 +1,10 @@
@use '../blockMixins' as *; @use '../blockMixins' as *;
.delay { .delay {
@include block-spacing;
@include block-styling; @include block-styling;
margin-left: 0.5rem;
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;
@@ -8,7 +8,6 @@ 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';
@@ -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} />;
} }
@@ -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;
@@ -16,8 +16,6 @@ import EventEditorEmpty from './EventEditorEmpty';
import style from './EventEditor.module.scss'; import style from './EventEditor.module.scss';
export type EventEditorSubmitActions = keyof OntimeEvent;
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel; export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel;
interface EventEditorProps { interface EventEditorProps {
@@ -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 = data.order.find((entryId) => selectedEvents.has(entryId));
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 />;
@@ -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,6 @@
margin: 0.25rem 0; margin: 0.25rem 0;
font-size: calc(1rem - 3px); font-size: calc(1rem - 3px);
padding-inline: calc(2em + 0.5rem) 0.75rem;
} }
.quickBtn { .quickBtn {
@@ -1,7 +1,7 @@
import { memo, useCallback, useRef } from 'react'; import { memo, useCallback, useRef } from 'react';
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { SupportedEvent } from 'ontime-types'; import { MaybeString, SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
import { useEmitLog } from '../../../common/stores/logger'; import { useEmitLog } from '../../../common/stores/logger';
@@ -9,7 +9,7 @@ import { useEmitLog } from '../../../common/stores/logger';
import style from './QuickAddBlock.module.scss'; import style from './QuickAddBlock.module.scss';
interface QuickAddBlockProps { interface QuickAddBlockProps {
previousEventId?: string; previousEventId: MaybeString;
} }
export default memo(QuickAddBlock); export default memo(QuickAddBlock);
@@ -1,5 +1,5 @@
.header { .header {
padding-inline: calc(2rem - 6px + 0.5rem) calc(1rem + 2px); padding-inline: 1rem 2rem;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1,12 +1,12 @@
import { MouseEvent } from 'react'; import { MouseEvent } from 'react';
import { isOntimeEvent, MaybeNumber, MaybeString, OntimeEvent, RundownCached } from 'ontime-types'; import { isOntimeEvent, MaybeNumber, MaybeString, OntimeEvent, Rundown } from 'ontime-types';
import { create } from 'zustand'; import { create } from 'zustand';
import { RUNDOWN } from '../../common/api/constants'; import { RUNDOWN } from '../../common/api/constants';
import { ontimeQueryClient } from '../../common/queryClient'; import { ontimeQueryClient } from '../../common/queryClient';
import { isMacOS } from '../../common/utils/deviceUtils'; import { isMacOS } from '../../common/utils/deviceUtils';
export type SelectionMode = 'shift' | 'click' | 'ctrl'; type SelectionMode = 'shift' | 'click' | 'ctrl';
interface EventSelectionStore { interface EventSelectionStore {
selectedEvents: Set<string>; selectedEvents: Set<string>;
@@ -33,7 +33,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
// on ctrl + click, we toggle the selection of that event // on ctrl + click, we toggle the selection of that event
if (selectMode === 'ctrl') { if (selectMode === 'ctrl') {
const rundownData = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN); const rundownData = ontimeQueryClient.getQueryData<Rundown>(RUNDOWN);
if (!rundownData) return; if (!rundownData) return;
// if it doesnt exist, simply add to the list and set an anchor // if it doesnt exist, simply add to the list and set an anchor
@@ -50,7 +50,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents.delete(id); selectedEvents.delete(id);
const nextIndex = rundownData.order.findIndex( const nextIndex = rundownData.order.findIndex(
(eventId, i) => i > index && isOntimeEvent(rundownData.rundown[eventId]) && selectedEvents.has(eventId), (eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && selectedEvents.has(eventId),
); );
// if we didnt find anything after, set the anchor to the last event // if we didnt find anything after, set the anchor to the last event
@@ -62,13 +62,13 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
// on shift + click, we select a range of events up to the clicked event // on shift + click, we select a range of events up to the clicked event
if (selectMode === 'shift') { if (selectMode === 'shift') {
const rundownData = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN); const rundownData = ontimeQueryClient.getQueryData<Rundown>(RUNDOWN);
if (!rundownData) return; if (!rundownData) return;
// get list of rundown with only ontime events // get list of rundown with only ontime events
const events: OntimeEvent[] = []; const events: OntimeEvent[] = [];
rundownData.order.forEach((eventId) => { rundownData.order.forEach((eventId) => {
const event = rundownData.rundown[eventId]; const event = rundownData.entries[eventId];
if (isOntimeEvent(event)) { if (isOntimeEvent(event)) {
events.push(event); events.push(event);
} }
@@ -1,15 +0,0 @@
// used in both sm and public views
export const titleVariants = {
hidden: {
x: -1500,
},
visible: {
x: 0,
transition: {
duration: 1,
},
},
exit: {
x: -1500,
},
};
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { import {
OntimeEntry,
OntimeEvent, OntimeEvent,
OntimeRundownEntry,
Playback, Playback,
ProjectData, ProjectData,
Runtime, Runtime,
@@ -72,7 +72,7 @@ export default function Countdown(props: CountdownProps) {
} }
if (followThis !== null) { if (followThis !== null) {
setFollow(followThis); setFollow(followThis);
const idx: number = backstageEvents.findIndex((event: OntimeRundownEntry) => event.id === followThis?.id); const idx: number = backstageEvents.findIndex((event: OntimeEntry) => event.id === followThis?.id);
const delayToEvent = backstageEvents[idx]?.delay ?? 0; const delayToEvent = backstageEvents[idx]?.delay ?? 0;
setDelay(delayToEvent); setDelay(delayToEvent);
} }
@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from 'ontime-types'; import { OntimeEntry, OntimeEvent, SupportedEvent } from 'ontime-types';
import Empty from '../../../common/components/state/Empty'; import Empty from '../../../common/components/state/Empty';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
@@ -10,7 +10,7 @@ import { sanitiseTitle } from './countdown.helpers';
import './Countdown.scss'; import './Countdown.scss';
interface CountdownSelectProps { interface CountdownSelectProps {
events: OntimeRundownEntry[]; events: OntimeEntry[];
} }
const scheduleFormat = { format12: 'hh:mm a', format24: 'HH:mm' }; const scheduleFormat = { format12: 'hh:mm a', format24: 'HH:mm' };
@@ -19,9 +19,7 @@ export default function CountdownSelect(props: CountdownSelectProps) {
const { events } = props; const { events } = props;
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const filteredEvents = events.filter( const filteredEvents = events.filter((event: OntimeEntry) => event.type === SupportedEvent.Event) as OntimeEvent[];
(event: OntimeRundownEntry) => event.type === SupportedEvent.Event,
) as OntimeEvent[];
return ( return (
<div className='event-select' data-testid='countdown__select'> <div className='event-select' data-testid='countdown__select'>
@@ -1,5 +1,5 @@
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import type { MaybeString, OntimeEvent, OntimeRundown, ProjectData, Settings } from 'ontime-types'; import type { MaybeString, OntimeEntry, OntimeEvent, ProjectData, Settings } from 'ontime-types';
import { Playback } from 'ontime-types'; import { Playback } from 'ontime-types';
import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils'; import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils';
@@ -17,7 +17,7 @@ import StudioClockSchedule from './StudioClockSchedule';
import './StudioClock.scss'; import './StudioClock.scss';
interface StudioClockProps { interface StudioClockProps {
backstageEvents: OntimeRundown; backstageEvents: OntimeEntry[];
eventNext: OntimeEvent | null; eventNext: OntimeEvent | null;
general: ProjectData; general: ProjectData;
isMirrored: boolean; isMirrored: boolean;
@@ -1,4 +1,4 @@
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundown } from 'ontime-types'; import { isOntimeEvent, MaybeString, OntimeEntry, OntimeEvent } from 'ontime-types';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
@@ -8,7 +8,7 @@ import { trimRundown } from './studioClock.utils';
import './StudioClock.scss'; import './StudioClock.scss';
interface StudioClockScheduleProps { interface StudioClockScheduleProps {
rundown: OntimeRundown; rundown: OntimeEntry[];
selectedId: MaybeString; selectedId: MaybeString;
nextId: MaybeString; nextId: MaybeString;
onAir: boolean; onAir: boolean;
@@ -32,7 +32,7 @@ interface TranslationContextValue {
getLocalizedString: (key: keyof typeof langEn, lang?: string) => string; getLocalizedString: (key: keyof typeof langEn, lang?: string) => string;
} }
export const TranslationContext = createContext<TranslationContextValue>({ const TranslationContext = createContext<TranslationContextValue>({
getLocalizedString: () => '', getLocalizedString: () => '',
}); });
@@ -8,7 +8,7 @@ import {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types'; import { isOntimeEvent, OntimeEntry, OntimeEvent } from 'ontime-types';
import { usePartialRundown } from '../../../common/hooks-query/useRundown'; import { usePartialRundown } from '../../../common/hooks-query/useRundown';
@@ -36,7 +36,7 @@ export const ScheduleProvider = ({
isBackstage = false, isBackstage = false,
}: PropsWithChildren<ScheduleProviderProps>) => { }: PropsWithChildren<ScheduleProviderProps>) => {
const { cycleInterval, stopCycle } = useScheduleOptions(); const { cycleInterval, stopCycle } = useScheduleOptions();
const { data: events } = usePartialRundown((event: OntimeRundownEntry) => { const { data: events } = usePartialRundown((event: OntimeEntry) => {
if (isBackstage) { if (isBackstage) {
return isOntimeEvent(event); return isOntimeEvent(event);
} }
@@ -9,12 +9,12 @@ import {
useSensors, useSensors,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types'; import { OntimeEntry } from 'ontime-types';
import useColumnManager from '../cuesheet-table/useColumnManager'; import useColumnManager from '../cuesheet-table/useColumnManager';
interface CuesheetDndProps { interface CuesheetDndProps {
columns: ColumnDef<OntimeRundownEntry>[]; columns: ColumnDef<OntimeEntry>[];
} }
export default function CuesheetDnd(props: PropsWithChildren<CuesheetDndProps>) { export default function CuesheetDnd(props: PropsWithChildren<CuesheetDndProps>) {
@@ -1,7 +1,7 @@
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { useTableNav } from '@table-nav/react'; import { useTableNav } from '@table-nav/react';
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundown, OntimeRundownEntry, TimeField } from 'ontime-types'; import { isOntimeEvent, MaybeString, OntimeEntry, OntimeEvent, TimeField } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
import useFollowComponent from '../../../common/hooks/useFollowComponent'; import useFollowComponent from '../../../common/hooks/useFollowComponent';
@@ -16,8 +16,8 @@ import useColumnManager from './useColumnManager';
import style from './CuesheetTable.module.scss'; import style from './CuesheetTable.module.scss';
interface CuesheetTableProps { interface CuesheetTableProps {
data: OntimeRundown; data: OntimeEntry[];
columns: ColumnDef<OntimeRundownEntry>[]; columns: ColumnDef<OntimeEntry>[];
showModal: (eventId: MaybeString) => void; showModal: (eventId: MaybeString) => void;
} }
@@ -1,7 +1,7 @@
import { MutableRefObject } from 'react'; import { MutableRefObject } from 'react';
import { RowModel, Table } from '@tanstack/react-table'; import { RowModel, Table } from '@tanstack/react-table';
import Color from 'color'; import Color from 'color';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundownEntry } from 'ontime-types'; import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeEntry } from 'ontime-types';
import { useSelectedEventId } from '../../../../common/hooks/useSocket'; import { useSelectedEventId } from '../../../../common/hooks/useSocket';
import { lazyEvaluate } from '../../../../common/utils/lazyEvaluate'; import { lazyEvaluate } from '../../../../common/utils/lazyEvaluate';
@@ -13,9 +13,9 @@ import DelayRow from './DelayRow';
import EventRow from './EventRow'; import EventRow from './EventRow';
interface CuesheetBodyProps { interface CuesheetBodyProps {
rowModel: RowModel<OntimeRundownEntry>; rowModel: RowModel<OntimeEntry>;
selectedRef: MutableRefObject<HTMLTableRowElement | null>; selectedRef: MutableRefObject<HTMLTableRowElement | null>;
table: Table<OntimeRundownEntry>; table: Table<OntimeEntry>;
columnSizing: Record<string, number>; columnSizing: Record<string, number>;
} }
@@ -1,6 +1,6 @@
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable'; import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
import { flexRender, HeaderGroup } from '@tanstack/react-table'; import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types'; import { OntimeEntry } from 'ontime-types';
import { getAccessibleColour } from '../../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { useCuesheetOptions } from '../../cuesheet.options'; import { useCuesheetOptions } from '../../cuesheet.options';
@@ -10,7 +10,7 @@ import { SortableCell } from './SortableCell';
import style from '../CuesheetTable.module.scss'; import style from '../CuesheetTable.module.scss';
interface CuesheetHeaderProps { interface CuesheetHeaderProps {
headerGroups: HeaderGroup<OntimeRundownEntry>[]; headerGroups: HeaderGroup<OntimeEntry>[];
} }
export default function CuesheetHeader(props: CuesheetHeaderProps) { export default function CuesheetHeader(props: CuesheetHeaderProps) {
@@ -2,7 +2,7 @@ import { memo, MutableRefObject, useLayoutEffect, useRef, useState } from 'react
import { IoEllipsisHorizontal } from 'react-icons/io5'; import { IoEllipsisHorizontal } from 'react-icons/io5';
import { flexRender, Table } from '@tanstack/react-table'; import { flexRender, Table } from '@tanstack/react-table';
import Color from 'color'; import Color from 'color';
import { OntimeEvent, OntimeRundownEntry } from 'ontime-types'; import { OntimeEntry, OntimeEvent } from 'ontime-types';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
@@ -21,7 +21,7 @@ interface EventRowProps {
skip?: boolean; skip?: boolean;
colour?: string; colour?: string;
rowBgColour?: string; rowBgColour?: string;
table: Table<OntimeRundownEntry>; table: Table<OntimeEntry>;
/** hack to force re-rendering of the row when the column sizes change */ /** hack to force re-rendering of the row when the column sizes change */
columnSizing: Record<string, number>; columnSizing: Record<string, number>;
} }
@@ -2,12 +2,12 @@ import { CSSProperties, ReactNode } from '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 { Header } from '@tanstack/react-table'; import { Header } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types'; import { OntimeEntry } from 'ontime-types';
import styles from '../CuesheetTable.module.scss'; import styles from '../CuesheetTable.module.scss';
interface SortableCellProps { interface SortableCellProps {
header: Header<OntimeRundownEntry, unknown>; header: Header<OntimeEntry, unknown>;
style: CSSProperties; style: CSSProperties;
children: ReactNode; children: ReactNode;
} }
@@ -1,6 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { CellContext, ColumnDef } from '@tanstack/react-table'; import { CellContext, ColumnDef } from '@tanstack/react-table';
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry, TimeStrategy } from 'ontime-types'; import { CustomFields, isOntimeEvent, OntimeEntry, OntimeEvent, TimeStrategy } from 'ontime-types';
import { millisToString, removeSeconds } from 'ontime-utils'; import { millisToString, removeSeconds } from 'ontime-utils';
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator'; import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
@@ -11,7 +11,7 @@ import MultiLineCell from './MultiLineCell';
import SingleLineCell from './SingleLineCell'; import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput'; import TimeInput from './TimeInput';
function MakeStart({ getValue, row, table }: CellContext<OntimeRundownEntry, unknown>) { function MakeStart({ getValue, row, table }: CellContext<OntimeEntry, unknown>) {
if (!table.options.meta) { if (!table.options.meta) {
return null; return null;
} }
@@ -39,7 +39,7 @@ function MakeStart({ getValue, row, table }: CellContext<OntimeRundownEntry, unk
); );
} }
function MakeEnd({ getValue, row, table }: CellContext<OntimeRundownEntry, unknown>) { function MakeEnd({ getValue, row, table }: CellContext<OntimeEntry, unknown>) {
if (!table.options.meta) { if (!table.options.meta) {
return null; return null;
} }
@@ -67,7 +67,7 @@ function MakeEnd({ getValue, row, table }: CellContext<OntimeRundownEntry, unkno
); );
} }
function MakeDuration({ getValue, row, table }: CellContext<OntimeRundownEntry, unknown>) { function MakeDuration({ getValue, row, table }: CellContext<OntimeEntry, unknown>) {
if (!table.options.meta) { if (!table.options.meta) {
return null; return null;
} }
@@ -87,7 +87,7 @@ function MakeDuration({ getValue, row, table }: CellContext<OntimeRundownEntry,
); );
} }
function MakeMultiLineField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) { function MakeMultiLineField({ row, column, table }: CellContext<OntimeEntry, unknown>) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false); table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
@@ -101,12 +101,12 @@ function MakeMultiLineField({ row, column, table }: CellContext<OntimeRundownEnt
return null; return null;
} }
const initialValue = event[column.id as keyof OntimeRundownEntry] ?? ''; const initialValue = event[column.id as keyof OntimeEntry] ?? '';
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />; return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />;
} }
function LazyImage({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) { function LazyImage({ row, column, table }: CellContext<OntimeEntry, unknown>) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true); table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
@@ -124,7 +124,7 @@ function LazyImage({ row, column, table }: CellContext<OntimeRundownEntry, unkno
return <EditableImage initialValue={initialValue} updateValue={update} />; return <EditableImage initialValue={initialValue} updateValue={update} />;
} }
function MakeSingleLineField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) { function MakeSingleLineField({ row, column, table }: CellContext<OntimeEntry, unknown>) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false); table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
@@ -138,12 +138,12 @@ function MakeSingleLineField({ row, column, table }: CellContext<OntimeRundownEn
return null; return null;
} }
const initialValue = event[column.id as keyof OntimeRundownEntry] ?? ''; const initialValue = event[column.id as keyof OntimeEntry] ?? '';
return <SingleLineCell initialValue={initialValue} handleUpdate={update} />; return <SingleLineCell initialValue={initialValue as string} handleUpdate={update} />;
} }
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) { function MakeCustomField({ row, column, table }: CellContext<OntimeEntry, unknown>) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true); table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
@@ -161,7 +161,7 @@ function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry,
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />; return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
} }
export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeRundownEntry>[] { export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeEntry>[] {
const dynamicCustomFields = Object.keys(customFields).map((key) => ({ const dynamicCustomFields = Object.keys(customFields).map((key) => ({
accessorKey: key, accessorKey: key,
id: key, id: key,
@@ -1,7 +1,7 @@
import { memo, ReactNode } from 'react'; import { memo, ReactNode } from 'react';
import { Button, Checkbox } from '@chakra-ui/react'; import { Button, Checkbox } from '@chakra-ui/react';
import { Column } from '@tanstack/react-table'; import { Column } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types'; import { OntimeEntry } from 'ontime-types';
import * as Editor from '../../../../features/editors/editor-utils/EditorUtils'; import * as Editor from '../../../../features/editors/editor-utils/EditorUtils';
@@ -14,7 +14,7 @@ const buttonProps = {
}; };
interface CuesheetTableSettingsProps { interface CuesheetTableSettingsProps {
columns: Column<OntimeRundownEntry, unknown>[]; columns: Column<OntimeEntry, unknown>[];
handleResetResizing: () => void; handleResetResizing: () => void;
handleResetReordering: () => void; handleResetReordering: () => void;
handleClearToggles: () => void; handleClearToggles: () => void;
@@ -1,9 +1,9 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types'; import { OntimeEntry } from 'ontime-types';
export default function useColumnManager(columns: ColumnDef<OntimeRundownEntry>[]) { export default function useColumnManager(columns: ColumnDef<OntimeEntry>[]) {
const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} }); const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} });
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({ const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
key: 'table-order', key: 'table-order',
@@ -3,8 +3,8 @@ import {
isOntimeDelay, isOntimeDelay,
isOntimeEvent, isOntimeEvent,
MaybeNumber, MaybeNumber,
OntimeEntry,
OntimeEntryCommonKeys, OntimeEntryCommonKeys,
OntimeRundown,
ProjectData, ProjectData,
} from 'ontime-types'; } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
@@ -32,12 +32,8 @@ export const parseField = (field: CsvHeaderKey, data: unknown): string => {
/** /**
* @description Creates an array of arrays usable by xlsx for export * @description Creates an array of arrays usable by xlsx for export
* @param {ProjectData} headerData
* @param {OntimeRundown} rundown
* @param {CustomFields} customFields
* @return {(string[])[]}
*/ */
export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, customFields: CustomFields): string[][] => { export const makeTable = (headerData: ProjectData, rundown: OntimeEntry[], customFields: CustomFields): string[][] => {
// create metadata header row // create metadata header row
const data = [['Ontime · Rundown export']]; const data = [['Ontime · Rundown export']];
if (headerData.title) data.push([`Project title: ${headerData.title}`]); if (headerData.title) data.push([`Project title: ${headerData.title}`]);
+2 -2
View File
@@ -1,6 +1,6 @@
import { memo } from 'react'; import { memo } from 'react';
import { useViewportSize } from '@mantine/hooks'; import { useViewportSize } from '@mantine/hooks';
import { isOntimeEvent, isPlayableEvent, OntimeRundown } from 'ontime-types'; import { isOntimeEvent, isPlayableEvent, OntimeEntry } from 'ontime-types';
import { dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils'; import { dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
import TimelineMarkers from './timeline-markers/TimelineMarkers'; import TimelineMarkers from './timeline-markers/TimelineMarkers';
@@ -11,7 +11,7 @@ import style from './Timeline.module.scss';
interface TimelineProps { interface TimelineProps {
firstStart: number; firstStart: number;
rundown: OntimeRundown; rundown: OntimeEntry[];
selectedEventId: string | null; selectedEventId: string | null;
totalDuration: number; totalDuration: number;
} }
@@ -12,7 +12,7 @@ interface SectionProps {
export default memo(Section); export default memo(Section);
export function Section(props: SectionProps) { function Section(props: SectionProps) {
const { category, content, title, status } = props; const { category, content, title, status } = props;
const sectionClasses = cx(['section', category === 'now' && 'section--now']); const sectionClasses = cx(['section', category === 'now' && 'section--now']);
@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { isOntimeEvent, isPlayableEvent, MaybeString, OntimeEvent, OntimeRundown, PlayableEvent } from 'ontime-types'; import { isOntimeEvent, isPlayableEvent, MaybeString, OntimeEntry, OntimeEvent, PlayableEvent } from 'ontime-types';
import { import {
dayInMs, dayInMs,
getEventWithId, getEventWithId,
@@ -11,7 +11,6 @@ import {
MILLIS_PER_HOUR, MILLIS_PER_HOUR,
} from 'ontime-utils'; } from 'ontime-utils';
import { clamp } from '../../common/utils/math';
import { formatDuration } from '../../common/utils/time'; import { formatDuration } from '../../common/utils/time';
import { isStringBoolean } from '../../features/viewers/common/viewUtils'; import { isStringBoolean } from '../../features/viewers/common/viewUtils';
@@ -22,13 +21,6 @@ type CSSPosition = {
width: number; width: number;
}; };
/**
* Calculates the position (in %) of an element relative to a schedule
*/
export function getRelativePositionX(scheduleStart: number, scheduleEnd: number, now: number): number {
return clamp(((now - scheduleStart) / (scheduleEnd - scheduleStart)) * 100, 0, 100);
}
/** /**
* Calculates an absolute position of an element based on a schedule * Calculates an absolute position of an element based on a schedule
*/ */
@@ -95,7 +87,7 @@ interface ScopedRundownData {
totalDuration: number; totalDuration: number;
} }
export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeString): ScopedRundownData { export function useScopedRundown(rundown: OntimeEntry[], selectedEventId: MaybeString): ScopedRundownData {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const data = useMemo(() => { const data = useMemo(() => {
@@ -110,7 +102,7 @@ export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeS
let selectedIndex = selectedEventId ? Infinity : -1; let selectedIndex = selectedEventId ? Infinity : -1;
let firstStart = null; let firstStart = null;
let totalDuration = 0; let totalDuration = 0;
let lastEntry: PlayableEvent | undefined; let lastEntry: PlayableEvent | null = null;
for (let i = 0; i < rundown.length; i++) { for (let i = 0; i < rundown.length; i++) {
const currentEntry = rundown[i]; const currentEntry = rundown[i];
@@ -172,7 +164,7 @@ type UpcomingEvents = {
/** /**
* Returns upcoming events from current: now, next and followedBy * Returns upcoming events from current: now, next and followedBy
*/ */
export function getUpcomingEvents(events: OntimeRundown, selectedId: MaybeString): UpcomingEvents { export function getUpcomingEvents(events: PlayableEvent[], selectedId: MaybeString): UpcomingEvents {
if (events.length === 0) { if (events.length === 0) {
return { now: null, next: null, followedBy: null }; return { now: null, next: null, followedBy: null };
} }
+1
View File
@@ -26,6 +26,7 @@
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/multer": "^1.4.11", "@types/multer": "^1.4.11",
+20 -7
View File
@@ -29,7 +29,7 @@ import { authenticateSocket } from '../middleware/authenticate.js';
let instance: SocketServer | null = null; let instance: SocketServer | null = null;
export class SocketServer implements IAdapter { class SocketServer implements IAdapter {
private readonly MAX_PAYLOAD = 1024 * 256; // 256Kb private readonly MAX_PAYLOAD = 1024 * 256; // 256Kb
private wss: WebSocketServer | null; private wss: WebSocketServer | null;
@@ -102,7 +102,7 @@ export class SocketServer implements IAdapter {
ws.on('message', (data) => { ws.on('message', (data) => {
try { try {
// @ts-expect-error -- ?? // @ts-expect-error -- this works fine
const message = JSON.parse(data); const message = JSON.parse(data);
const { type, payload } = message; const { type, payload } = message;
@@ -120,7 +120,7 @@ export class SocketServer implements IAdapter {
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: 'client-name', type: 'client-name',
payload: this.clients.get(clientId).name, payload: this.getOrCreateClient(clientId),
}), }),
); );
return; return;
@@ -136,7 +136,7 @@ export class SocketServer implements IAdapter {
if (type === 'set-client-type') { if (type === 'set-client-type') {
if (payload && typeof payload == 'string') { if (payload && typeof payload == 'string') {
const previousData = this.clients.get(clientId); const previousData = this.getOrCreateClient(clientId);
this.clients.set(clientId, { ...previousData, type: payload }); this.clients.set(clientId, { ...previousData, type: payload });
} }
this.sendClientList(); this.sendClientList();
@@ -145,7 +145,7 @@ export class SocketServer implements IAdapter {
if (type === 'set-client-path') { if (type === 'set-client-path') {
if (payload && typeof payload == 'string') { if (payload && typeof payload == 'string') {
const previousData = this.clients.get(clientId); const previousData = this.getOrCreateClient(clientId);
previousData.path = payload; previousData.path = payload;
this.clients.set(clientId, previousData); this.clients.set(clientId, previousData);
@@ -166,13 +166,13 @@ export class SocketServer implements IAdapter {
if (type === 'set-client-name') { if (type === 'set-client-name') {
if (payload) { if (payload) {
const previousData = this.clients.get(clientId); const previousData = this.getOrCreateClient(clientId);
logger.info(LogOrigin.Client, `Client ${previousData.name} renamed to ${payload}`); logger.info(LogOrigin.Client, `Client ${previousData.name} renamed to ${payload}`);
this.clients.set(clientId, { ...previousData, name: payload }); this.clients.set(clientId, { ...previousData, name: payload });
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: 'client-name', type: 'client-name',
payload: this.clients.get(clientId).name, payload: this.getOrCreateClient(clientId).name,
}), }),
); );
} }
@@ -215,6 +215,19 @@ export class SocketServer implements IAdapter {
}; };
} }
private getOrCreateClient(clientId: string): Client {
if (!this.clients.has(clientId)) {
this.clients.set(clientId, {
type: 'unknown',
identify: false,
name: getRandomName(),
origin: '',
path: '',
});
}
return this.clients.get(clientId) as Client;
}
private sendClientList(): void { private sendClientList(): void {
const payload = Object.fromEntries(this.clients.entries()); const payload = Object.fromEntries(this.clients.entries());
this.sendAsJson({ type: 'client-list', payload }); this.sendAsJson({ type: 'client-list', payload });
+2 -2
View File
@@ -4,7 +4,7 @@
* @param {string} value - value to assign * @param {string} value - value to assign
* @returns {object | string | null} nested object or null if no object was created * @returns {object | string | null} nested object or null if no object was created
*/ */
export const integrationPayloadFromPath = (path: string[], value?: unknown): object | string | null => { export function integrationPayloadFromPath(path: string[], value?: unknown): object | string | null {
if (path.length === 1) { if (path.length === 1) {
const key = path[0]; const key = path[0];
return value === undefined ? key : { [key]: value }; return value === undefined ? key : { [key]: value };
@@ -16,4 +16,4 @@ export const integrationPayloadFromPath = (path: string[], value?: unknown): obj
const obj = shortenedPath.reduceRight((result, key) => ({ [key]: result }), parsedValue); const obj = shortenedPath.reduceRight((result, key) => ({ [key]: result }), parsedValue);
return typeof obj === 'object' ? obj : null; return typeof obj === 'object' ? obj : null;
}; }
@@ -157,7 +157,12 @@ async function saveChanges(patch: Partial<AutomationSettings>) {
const automation = getDataProvider().getAutomation(); const automation = getDataProvider().getAutomation();
// remove undefined keys from object, we probably want a better solution // remove undefined keys from object, we probably want a better solution
Object.keys(patch).forEach((key) => (patch[key] === undefined ? delete patch[key] : {})); Object.keys(patch).forEach((key) => {
const typedKey = key as keyof AutomationSettings;
if (patch[typedKey] === undefined) {
delete patch[typedKey];
}
});
await getDataProvider().setAutomation({ ...automation, ...patch }); await getDataProvider().setAutomation({ ...automation, ...patch });
} }
+3 -3
View File
@@ -18,9 +18,9 @@ import * as projectService from '../../services/project-service/ProjectService.j
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) { export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
try { try {
const { rundown, project, settings, viewSettings, urlPresets, customFields, automation } = req.body; const { rundowns, project, settings, viewSettings, urlPresets, customFields, automation } = req.body;
const patchDb: DatabaseModel = { const patchDb: DatabaseModel = {
rundown, rundowns,
project, project,
settings, settings,
viewSettings, viewSettings,
@@ -90,7 +90,7 @@ export async function quickProjectFile(req: Request, res: Response<{ filename: s
*/ */
export async function currentProjectDownload(_req: Request, res: Response) { export async function currentProjectDownload(_req: Request, res: Response) {
const { filename, pathToFile } = await projectService.getCurrentProject(); const { filename, pathToFile } = await projectService.getCurrentProject();
res.download(pathToFile, filename, (error) => { res.download(pathToFile, filename, (error: Error | null) => {
if (error) { if (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
res.status(500).send({ message }); res.status(500).send({ message });
+1 -1
View File
@@ -18,7 +18,7 @@ const filterImageFile = (_req: Request, file: Express.Multer.File, cb: FileFilte
} else { } else {
cb(null, false); cb(null, false);
} }
} };
// Build multer uploader for a single file // Build multer uploader for a single file
export const uploadProjectFile = multer({ export const uploadProjectFile = multer({
+1 -1
View File
@@ -58,7 +58,7 @@ export const validatePatchProject = [
next(); next();
}, },
body('rundown').isArray().optional({ nullable: false }), body('rundowns').isObject().optional({ nullable: false }),
body('project').isObject().optional({ nullable: false }), body('project').isObject().optional({ nullable: false }),
body('settings').isObject().optional({ nullable: false }), body('settings').isObject().optional({ nullable: false }),
body('viewSettings').isObject().optional({ nullable: false }), body('viewSettings').isObject().optional({ nullable: false }),
@@ -5,10 +5,12 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { generateRundownPreview, listWorksheets, saveExcelFile } from './excel.service.js'; import { generateRundownPreview, listWorksheets, saveExcelFile } from './excel.service.js';
import { CustomFields, Rundown } from 'ontime-types';
export async function postExcel(req: Request, res: Response) { export async function postExcel(req: Request, res: Response) {
try { try {
const filePath = req.file.path; // file has been validated by middleware
const filePath = (req.file as Express.Multer.File).path;
await saveExcelFile(filePath); await saveExcelFile(filePath);
res.status(200).send(); res.status(200).send();
} catch (error) { } catch (error) {
@@ -29,7 +31,10 @@ export async function getWorksheets(req: Request, res: Response) {
* parses an Excel spreadsheet * parses an Excel spreadsheet
* @returns parsed result * @returns parsed result
*/ */
export async function previewExcel(req: Request, res: Response) { export async function previewExcel(
req: Request,
res: Response<{ rundown: Rundown; customFields: CustomFields } | { message: string }>,
) {
try { try {
const { options } = req.body; const { options } = req.body;
const data = generateRundownPreview(options); const data = generateRundownPreview(options);
@@ -12,5 +12,3 @@ export const router = express.Router();
router.post('/upload', uploadExcel, validateFileExists, postExcel); router.post('/upload', uploadExcel, validateFileExists, postExcel);
router.get('/worksheets', getWorksheets); router.get('/worksheets', getWorksheets);
router.post('/preview', validateImportMapOptions, previewExcel); router.post('/preview', validateImportMapOptions, previewExcel);
// TODO: validate import map
@@ -3,8 +3,8 @@
* Google Sheets * Google Sheets
*/ */
import { CustomFields, OntimeRundown } from 'ontime-types'; import { CustomFields, Rundown } from 'ontime-types';
import type { ImportMap } from 'ontime-utils'; import { type ImportMap } from 'ontime-utils';
import { extname } from 'path'; import { extname } from 'path';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
@@ -12,7 +12,7 @@ import xlsx from 'xlsx';
import type { WorkBook } from 'xlsx'; import type { WorkBook } from 'xlsx';
import { parseExcel } from '../../utils/parser.js'; import { parseExcel } from '../../utils/parser.js';
import { parseRundown } from '../../utils/parserFunctions.js'; import { parseCustomFields, parseRundown } from '../../utils/parserFunctions.js';
import { deleteFile } from '../../utils/parserUtils.js'; import { deleteFile } from '../../utils/parserUtils.js';
import { getCustomFields } from '../../services/rundown-service/rundownCache.js'; import { getCustomFields } from '../../services/rundown-service/rundownCache.js';
@@ -34,7 +34,7 @@ export function listWorksheets(): string[] {
return excelData.SheetNames; return excelData.SheetNames;
} }
export function generateRundownPreview(options: ImportMap): { rundown: OntimeRundown; customFields: CustomFields } { export function generateRundownPreview(options: ImportMap): { rundown: Rundown; customFields: CustomFields } {
const data = excelData.Sheets[options.worksheet]; const data = excelData.Sheets[options.worksheet];
if (!data) { if (!data) {
@@ -43,15 +43,17 @@ export function generateRundownPreview(options: ImportMap): { rundown: OntimeRun
const arrayOfData: unknown[][] = xlsx.utils.sheet_to_json(data, { header: 1, blankrows: false, raw: false }); const arrayOfData: unknown[][] = xlsx.utils.sheet_to_json(data, { header: 1, blankrows: false, raw: false });
const dataFromExcel = parseExcel(arrayOfData, getCustomFields(), options); const dataFromExcel = parseExcel(arrayOfData, getCustomFields(), options.worksheet, options);
const parsedCustomFields = parseCustomFields(dataFromExcel);
// we run the parsed data through an extra step to ensure the objects shape // we run the parsed data through an extra step to ensure the objects shape
const { rundown, customFields } = parseRundown(dataFromExcel); const Rundown = parseRundown(dataFromExcel.rundown, parsedCustomFields);
if (rundown.length === 0) { if (Rundown.order.length === 0) {
throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`); throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`);
} }
// clear the data // clear the data
excelData = xlsx.utils.book_new(); excelData = xlsx.utils.book_new();
return { rundown, customFields }; return { rundown: Rundown, customFields: parsedCustomFields };
} }
@@ -60,6 +60,5 @@ export function triggerReportEntry(
sendRefetch({ sendRefetch({
target: 'REPORT', target: 'REPORT',
}); });
return;
} }
} }
@@ -1,11 +1,4 @@
import { import { ErrorResponse, MessageResponse, OntimeEntry, ProjectRundownsList, Rundown } from 'ontime-types';
ErrorResponse,
MessageResponse,
OntimeRundown,
OntimeRundownEntry,
RundownCached,
RundownPaginated,
} from 'ontime-types';
import { getErrorMessage } from 'ontime-utils'; import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
@@ -21,24 +14,19 @@ import {
reorderEvent, reorderEvent,
swapEvents, swapEvents,
} from '../../services/rundown-service/RundownService.js'; } from '../../services/rundown-service/RundownService.js';
import { import { getEventWithId, getCurrentRundown } from '../../services/rundown-service/rundownUtils.js';
getEventWithId,
getNormalisedRundown,
getPaginated,
getRundown,
} from '../../services/rundown-service/rundownUtils.js';
export async function rundownGetAll(_req: Request, res: Response<OntimeRundown>) { export async function rundownGetAll(_req: Request, res: Response<ProjectRundownsList>) {
const rundown = getRundown(); const rundown = getCurrentRundown();
res.json(rundown); res.json([{ id: rundown.id, title: rundown.title, numEntries: rundown.order.length, revision: rundown.revision }]);
} }
export async function rundownGetNormalised(_req: Request, res: Response<RundownCached>) { export async function rundownGetCurrent(_req: Request, res: Response<Rundown>) {
const cachedRundown = getNormalisedRundown(); const cachedRundown = getCurrentRundown();
res.json(cachedRundown); res.json(cachedRundown);
} }
export async function rundownGetById(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) { export async function rundownGetById(req: Request, res: Response<OntimeEntry | ErrorResponse>) {
const { eventId } = req.params; const { eventId } = req.params;
try { try {
@@ -55,35 +43,7 @@ export async function rundownGetById(req: Request, res: Response<OntimeRundownEn
} }
} }
export async function rundownGetPaginated(req: Request, res: Response<RundownPaginated | ErrorResponse>) { export async function rundownPost(req: Request, res: Response<OntimeEntry | ErrorResponse>) {
const { limit, offset } = req.query;
if (limit == null && offset == null) {
return res.json({
rundown: getRundown(),
total: getRundown().length,
});
}
try {
let parsedOffset = Number(offset);
if (Number.isNaN(parsedOffset)) {
parsedOffset = 0;
}
let parsedLimit = Number(limit);
if (Number.isNaN(parsedLimit)) {
parsedLimit = Infinity;
}
const paginatedRundown = getPaginated(parsedOffset, parsedLimit);
res.status(200).json(paginatedRundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).json({ message });
}
}
export async function rundownPost(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) { if (failEmptyObjects(req.body, res)) {
return; return;
} }
@@ -97,7 +57,7 @@ export async function rundownPost(req: Request, res: Response<OntimeRundownEntry
} }
} }
export async function rundownPut(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) { export async function rundownPut(req: Request, res: Response<OntimeEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) { if (failEmptyObjects(req.body, res)) {
return; return;
} }
@@ -126,7 +86,7 @@ export async function rundownBatchPut(req: Request, res: Response<MessageRespons
} }
} }
export async function rundownReorder(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) { export async function rundownReorder(req: Request, res: Response<OntimeEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) { if (failEmptyObjects(req.body, res)) {
return; return;
} }
@@ -7,8 +7,7 @@ import {
rundownDelete, rundownDelete,
rundownGetAll, rundownGetAll,
rundownGetById, rundownGetById,
rundownGetNormalised, rundownGetCurrent,
rundownGetPaginated,
rundownPost, rundownPost,
rundownPut, rundownPut,
rundownReorder, rundownReorder,
@@ -18,7 +17,6 @@ import {
paramsMustHaveEventId, paramsMustHaveEventId,
rundownArrayOfIds, rundownArrayOfIds,
rundownBatchPutValidator, rundownBatchPutValidator,
rundownGetPaginatedQueryParams,
rundownPostValidator, rundownPostValidator,
rundownPutValidator, rundownPutValidator,
rundownReorderValidator, rundownReorderValidator,
@@ -27,9 +25,8 @@ import {
export const router = express.Router(); export const router = express.Router();
router.get('/', rundownGetAll); // not used in Ontime frontend router.get('/', rundownGetAll);
router.get('/paginated', rundownGetPaginatedQueryParams, rundownGetPaginated); // not used in Ontime frontend router.get('/current', rundownGetCurrent);
router.get('/normalised', rundownGetNormalised);
router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend
router.post('/', rundownPostValidator, rundownPost); router.post('/', rundownPostValidator, rundownPost);
@@ -1,4 +1,4 @@
import { body, param, query, validationResult } from 'express-validator'; import { body, param, validationResult } from 'express-validator';
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from 'express';
export const rundownPostValidator = [ export const rundownPostValidator = [
@@ -77,14 +77,3 @@ export const rundownArrayOfIds = [
next(); next();
}, },
]; ];
export const rundownGetPaginatedQueryParams = [
query('offset').isNumeric().optional(),
query('limit').isNumeric().optional(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -4,7 +4,7 @@ import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { publicDir } from '../../setup/index.js'; import { publicDir } from '../../setup/index.js';
import { socket } from '../../adapters/WebsocketAdapter.js'; import { socket } from '../../adapters/WebsocketAdapter.js';
import { getLastRequest } from '../../api-integration/integration.controller.js'; import { getLastRequest } from '../../api-integration/integration.controller.js';
import { getLastLoadedProject } from '../../services/app-state-service/AppStateService.js'; import { getCurrentProject } from '../../services/project-service/ProjectService.js';
import { runtimeService } from '../../services/runtime-service/RuntimeService.js'; import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
import { getNetworkInterfaces } from '../../utils/network.js'; import { getNetworkInterfaces } from '../../utils/network.js';
import { getTimezoneLabel } from '../../utils/time.js'; import { getTimezoneLabel } from '../../utils/time.js';
@@ -17,7 +17,7 @@ const startedAt = new Date();
export async function getSessionStats(): Promise<SessionStats> { export async function getSessionStats(): Promise<SessionStats> {
const { connectedClients, lastConnection } = socket.getStats(); const { connectedClients, lastConnection } = socket.getStats();
const lastRequest = getLastRequest(); const lastRequest = getLastRequest();
const projectName = await getLastLoadedProject(); const { filename } = await getCurrentProject();
const { playback } = runtimeService.getRuntimeState(); const { playback } = runtimeService.getRuntimeState();
return { return {
@@ -25,7 +25,7 @@ export async function getSessionStats(): Promise<SessionStats> {
connectedClients, connectedClients,
lastConnection: lastConnection !== null ? lastConnection.toISOString() : null, lastConnection: lastConnection !== null ? lastConnection.toISOString() : null,
lastRequest: lastRequest !== null ? lastRequest.toISOString() : null, lastRequest: lastRequest !== null ? lastRequest.toISOString() : null,
projectName, projectName: filename,
playback, playback,
timezone: getTimezoneLabel(startedAt), timezone: getTimezoneLabel(startedAt),
}; };
@@ -6,7 +6,7 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import type { AuthenticationStatus, CustomFields, ErrorResponse, OntimeRundown } from 'ontime-types'; import type { AuthenticationStatus, CustomFields, ErrorResponse, Rundown } from 'ontime-types';
import { deleteFile } from '../../utils/parserUtils.js'; import { deleteFile } from '../../utils/parserUtils.js';
import { import {
@@ -25,10 +25,11 @@ export async function requestConnection(
res: Response<{ verification_url: string; user_code: string } | ErrorResponse>, res: Response<{ verification_url: string; user_code: string } | ErrorResponse>,
) { ) {
const { sheetId } = req.params; const { sheetId } = req.params;
const file = req.file.path; // the check for the file is done in the validation middleware
const filePath = (req.file as Express.Multer.File).path;
try { try {
const client = readFileSync(file, 'utf-8'); const client = readFileSync(filePath, 'utf-8');
const clientSecret = handleClientSecret(client); const clientSecret = handleClientSecret(client);
const { verification_url, user_code } = await handleInitialConnection(clientSecret, sheetId); const { verification_url, user_code } = await handleInitialConnection(clientSecret, sheetId);
@@ -40,7 +41,7 @@ export async function requestConnection(
// delete uploaded file after parsing // delete uploaded file after parsing
try { try {
deleteFile(file); await deleteFile(filePath);
} catch (_error) { } catch (_error) {
/** we dont handle failure here */ /** we dont handle failure here */
} }
@@ -87,7 +88,7 @@ export async function readFromSheet(
req: Request, req: Request,
res: Response< res: Response<
| { | {
rundown: OntimeRundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
} }
| ErrorResponse | ErrorResponse
@@ -16,6 +16,10 @@ export const validateRequestConnection = [
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req); const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
// check that the file exists
if (!req.file) {
return res.status(422).json({ errors: 'File not found' });
}
next(); next();
}, },
]; ];
@@ -16,7 +16,7 @@ export async function postUrlPresets(req: Request, res: Response<URLPreset[] | E
return; return;
} }
try { try {
const newPresets: URLPreset[] = req.body.map((preset) => ({ const newPresets: URLPreset[] = req.body.map((preset: URLPreset) => ({
enabled: preset.enabled, enabled: preset.enabled,
alias: preset.alias, alias: preset.alias,
pathAndParams: preset.pathAndParams, pathAndParams: preset.pathAndParams,
@@ -1,36 +0,0 @@
import { handleLegacyMessageConversion } from '../integration.legacy.js';
describe('handleLegacyConversion', () => {
it('should return the payload as is if it is not a legacy message', () => {
expect(handleLegacyMessageConversion({})).toEqual({});
const newPayload = {
timer: {
text: 'text',
visible: true,
blink: true,
blackout: true,
},
external: 'text',
};
expect(handleLegacyMessageConversion(newPayload)).toEqual(newPayload);
});
it('should convert a legacy payload with external message', () => {
expect(handleLegacyMessageConversion({ external: { text: 'text', visible: true } })).toEqual({
external: 'text',
timer: {
secondarySource: 'external',
},
});
expect(handleLegacyMessageConversion({ external: { visible: true } })).toEqual({
timer: {
secondarySource: 'external',
},
});
expect(handleLegacyMessageConversion({ external: { text: 'text' } })).toEqual({
external: 'text',
});
});
});
@@ -16,8 +16,6 @@ import { socket } from '../adapters/WebsocketAdapter.js';
import { throttle } from '../utils/throttle.js'; import { throttle } from '../utils/throttle.js';
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js'; import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
import { handleLegacyMessageConversion } from './integration.legacy.js';
const throttledUpdateEvent = throttle(updateEvent, 20); const throttledUpdateEvent = throttle(updateEvent, 20);
let lastRequest: Date | null = null; let lastRequest: Date | null = null;
@@ -89,12 +87,9 @@ const actionHandlers: Record<string, ActionHandler> = {
message: (payload) => { message: (payload) => {
assert.isObject(payload); assert.isObject(payload);
// TODO: remove this once we feel its been enough time, ontime 3.6.0, 20/09/2024
const migratedPayload = handleLegacyMessageConversion(payload);
const patch: DeepPartial<MessageState> = { const patch: DeepPartial<MessageState> = {
timer: 'timer' in migratedPayload ? validateTimerMessage(migratedPayload.timer) : undefined, timer: 'timer' in payload ? validateTimerMessage(payload.timer) : undefined,
external: 'external' in migratedPayload ? validateMessage(migratedPayload.external) : undefined, external: 'external' in payload ? validateMessage(payload.external) : undefined,
}; };
const newMessage = messageService.patch(patch); const newMessage = messageService.patch(patch);

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