Compare commits

..

1 Commits

Author SHA1 Message Date
google-labs-jules[bot] fbbbaa5922 fix: stabilize cuesheet table components to prevent focus and data loss
This commit addresses the issue where focusing cells or inputs in the cuesheet table was lost during re-renders.

Changes:
- Extracted Virtuoso components (Table, TableRow, TableHead, EmptyPlaceholder) into stable, memoized components outside the main CuesheetTable component.
- Used Virtuoso's context API to pass dynamic state to the extracted components.
- Stabilized the 'meta' object of TanStack Table by using a ref for the data, reducing unnecessary re-renders of cell components.
- Modified useReactiveTextInput to skip synchronizing the 'text' state with 'initialText' when the input is focused, preventing user input from being overwritten by external changes.

These changes ensure that focus is maintained even when the table re-renders due to concurrent edits or submitting changes, and typed content is preserved.

Co-authored-by: cpvalente <34649812+cpvalente@users.noreply.github.com>
2026-02-08 11:24:48 +00:00
73 changed files with 898 additions and 1126 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.4.2",
"version": "4.3.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.4.2",
"version": "4.3.1",
"private": true,
"type": "module",
"dependencies": {
-3
View File
@@ -3,9 +3,6 @@ import { Tooltip } from '@base-ui/react/tooltip';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// apply global axios config defaults
import './common/api/axios.config';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
import { AppContextProvider } from './common/context/AppContext';
+4 -5
View File
@@ -4,15 +4,14 @@ import { TranslationObject } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
import type { RequestOptions } from './requestOptions';
const assetsPath = `${apiEntryUrl}/assets`;
/**
* HTTP request to get css contents
*/
export async function getCSSContents(options?: RequestOptions): Promise<string> {
const res = await axios.get(`${assetsPath}/css`, { signal: options?.signal });
export async function getCSSContents(): Promise<string> {
const res = await axios.get(`${assetsPath}/css`);
return res.data;
}
@@ -36,8 +35,8 @@ export async function restoreCSSContents(): Promise<string> {
/**
* HTTP request to get user translation
*/
export async function getUserTranslation(options?: RequestOptions): Promise<TranslationObject> {
const res = await axios.get(customTranslationsURL, { signal: options?.signal });
export async function getUserTranslation(): Promise<TranslationObject> {
const res = await axios.get(customTranslationsURL);
return res.data;
}
+2 -3
View File
@@ -9,15 +9,14 @@ import type {
} from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const automationsPath = `${apiEntryUrl}/automations`;
/**
* HTTP request to get the automations settings
*/
export async function getAutomationSettings(options?: RequestOptions): Promise<AutomationSettings> {
const res = await axios.get(automationsPath, { signal: options?.signal });
export async function getAutomationSettings(): Promise<AutomationSettings> {
const res = await axios.get(automationsPath);
return res.data;
}
+1 -4
View File
@@ -1,8 +1,5 @@
import axios from 'axios';
import { axiosConfig } from './requestTimeouts';
axios.defaults.validateStatus = (status) => {
return status >= 200 && status < 300;
return (status >= 200 && status < 300) || status === 304;
};
axios.defaults.timeout = axiosConfig.shortTimeout;
+2 -3
View File
@@ -2,15 +2,14 @@ import axios from 'axios';
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const customFieldsPath = `${apiEntryUrl}/custom-fields`;
/**
* Requests list of known custom fields
*/
export async function getCustomFields(options?: RequestOptions): Promise<CustomFields> {
const res = await axios.get(customFieldsPath, { signal: options?.signal });
export async function getCustomFields(): Promise<CustomFields> {
const res = await axios.get(customFieldsPath);
return res.data;
}
+3 -9
View File
@@ -2,8 +2,6 @@ import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { createBlob, downloadBlob } from './utils';
const dbPath = `${apiEntryUrl}/db`;
@@ -12,7 +10,7 @@ const dbPath = `${apiEntryUrl}/db`;
* HTTP request to the current DB
*/
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download`, { filename }, { timeout: axiosConfig.longTimeout });
return axios.post(`${dbPath}/download`, { filename });
}
/**
@@ -39,7 +37,6 @@ export async function uploadProjectFile(file: File): Promise<MessageResponse> {
const formData = new FormData();
formData.append('project', file);
const response = await axios.post(`${dbPath}/upload`, formData, {
timeout: axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
@@ -79,11 +76,8 @@ export async function quickProject(data: QuickStartData): Promise<MessageRespons
/**
* HTTP request to get the list of available project files
*/
export async function getProjects(options?: RequestOptions): Promise<ProjectFileListResponse> {
const res = await axios.get(`${dbPath}/all`, {
signal: options?.signal,
timeout: options?.timeout,
});
export async function getProjects(): Promise<ProjectFileListResponse> {
const res = await axios.get(`${dbPath}/all`);
return res.data;
}
+6 -22
View File
@@ -3,8 +3,6 @@ import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
import { downloadBlob } from './utils';
const excelPath = `${apiEntryUrl}/excel`;
@@ -13,12 +11,10 @@ const excelPath = `${apiEntryUrl}/excel`;
* upload Excel file to server
* @return string - file ID op the uploaded file
*/
export async function upload(file: File, requestOptions?: RequestOptions): Promise<string[]> {
export async function upload(file: File): Promise<string[]> {
const formData = new FormData();
formData.append('excel', file);
const response = await axios.post(`${excelPath}/upload`, formData, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
@@ -31,31 +27,19 @@ type PreviewSpreadsheetResponse = {
customFields: CustomFields;
summary: RundownSummary;
};
export async function importRundownPreview(
options: ImportMap,
requestOptions?: RequestOptions,
): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(
`${excelPath}/preview`,
{
options,
},
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
options,
});
return response.data;
}
/**
* Downloads a xlsx representation of the rundown from the server
*/
export async function downloadAsExcel(rundownId: string, fileName?: string, requestOptions?: RequestOptions) {
export async function downloadAsExcel(rundownId: string, fileName?: string) {
try {
const response = await axios.get(`${excelPath}/${rundownId}/export`, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
responseType: 'blob',
});
+2 -4
View File
@@ -2,8 +2,6 @@ import axios from 'axios';
import { apiRepoLatest } from '../../externals';
import type { RequestOptions } from './requestOptions';
export type HasUpdate = {
url: string;
version: string;
@@ -12,8 +10,8 @@ export type HasUpdate = {
/**
* HTTP request to get the latest version and url from github
*/
export async function getLatestVersion(options?: RequestOptions): Promise<HasUpdate> {
const res = await axios.get(apiRepoLatest, { signal: options?.signal });
export async function getLatestVersion(): Promise<HasUpdate> {
const res = await axios.get(apiRepoLatest);
return {
url: res.data.html_url as string,
version: res.data.tag_name as string,
+2 -8
View File
@@ -2,19 +2,14 @@ import axios, { AxiosResponse } from 'axios';
import { ProjectData, ProjectLogoResponse } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
const projectPath = `${apiEntryUrl}/project`;
/**
* HTTP request to fetch project data
*/
export async function getProjectData(options?: RequestOptions): Promise<ProjectData> {
const res = await axios.get(projectPath, {
signal: options?.signal,
timeout: options?.timeout,
});
export async function getProjectData(): Promise<ProjectData> {
const res = await axios.get(projectPath);
return res.data;
}
@@ -32,7 +27,6 @@ export async function uploadProjectLogo(file: File): Promise<AxiosResponse<Proje
const formData = new FormData();
formData.append('image', file);
const response = await axios.post(`${projectPath}/upload`, formData, {
timeout: axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
+2 -3
View File
@@ -4,15 +4,14 @@ import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, REPORT } from './constants';
import type { RequestOptions } from './requestOptions';
export const reportUrl = `${apiEntryUrl}/report`;
/**
* HTTP request to fetch all reports
*/
export async function fetchReport(options?: RequestOptions): Promise<OntimeReport> {
const res = await axios.get(reportUrl, { signal: options?.signal });
export async function fetchReport(): Promise<OntimeReport> {
const res = await axios.get(reportUrl);
return res.data;
}
@@ -1,4 +0,0 @@
export type RequestOptions = {
signal?: AbortSignal;
timeout?: number;
};
@@ -1,8 +0,0 @@
/**
* Keep a short global timeout for regular API requests, but allow
* longer windows for file transfer and heavy import/export operations.
*/
export const axiosConfig = {
shortTimeout: 20 * 1000, // 20 seconds
longTimeout: 3 * 60 * 1000, // 3 minutes
} as const;
+4 -5
View File
@@ -2,7 +2,6 @@ import axios, { AxiosResponse } from 'axios';
import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
type RundownId = string;
const rundownPath = `${apiEntryUrl}/rundowns`;
@@ -12,16 +11,16 @@ const rundownPath = `${apiEntryUrl}/rundowns`;
/**
* HTTP request to fetch a list of existing rundowns
*/
export async function fetchProjectRundownList(options?: RequestOptions): Promise<ProjectRundownsList> {
const res = await axios.get(rundownPath, { signal: options?.signal });
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
const res = await axios.get(rundownPath);
return res.data;
}
/**
* HTTP request to fetch all entries in the currently loaded rundown
*/
export async function fetchCurrentRundown(options?: RequestOptions): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`, { signal: options?.signal });
export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`);
if (!isValidRundown(res.data)) {
throw new Error('Invalid rundown payload');
}
+2 -3
View File
@@ -2,15 +2,14 @@ import axios from 'axios';
import { GetInfo, LinkOptions } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const sessionPath = `${apiEntryUrl}/session`;
/**
* HTTP request to retrieve application info
*/
export async function getInfo(options?: RequestOptions): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`, { signal: options?.signal });
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`);
return res.data;
}
+2 -3
View File
@@ -2,15 +2,14 @@ import axios, { AxiosResponse } from 'axios';
import { Settings } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const settingsPath = `${apiEntryUrl}/settings`;
/**
* HTTP request to retrieve application settings
*/
export async function getSettings(options?: RequestOptions): Promise<Settings> {
const res = await axios.get(settingsPath, { signal: options?.signal });
export async function getSettings(): Promise<Settings> {
const res = await axios.get(settingsPath);
return res.data;
}
+5 -36
View File
@@ -3,8 +3,6 @@ import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ont
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
import { axiosConfig } from './requestTimeouts';
const sheetsPath = `${apiEntryUrl}/sheets`;
@@ -25,7 +23,6 @@ export const verifyAuthenticationStatus = async (): Promise<{
export const requestConnection = async (
file: File,
sheetId: string,
requestOptions?: RequestOptions,
): Promise<{
verification_url: string;
user_code: string;
@@ -34,8 +31,6 @@ export const requestConnection = async (
formData.append('client_secret', file);
const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, {
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
headers: {
'Content-Type': 'multipart/form-data',
},
@@ -58,50 +53,24 @@ export const revokeAuthentication = async (): Promise<{ authenticated: Authentic
export const previewRundown = async (
sheetId: string,
options: ImportMap,
requestOptions?: RequestOptions,
): Promise<{
rundown: Rundown;
customFields: CustomFields;
summary: RundownSummary;
}> => {
const response = await axios.post(
`${sheetsPath}/${sheetId}/read`,
{ options },
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
return response.data;
};
export const getWorksheetNames = async (sheetId: string, requestOptions?: RequestOptions): Promise<string[]> => {
const response: AxiosResponse<string[]> = await axios.post(
`${sheetsPath}/${sheetId}/worksheets`,
undefined,
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
export const getWorksheetNames = async (sheetId: string): Promise<string[]> => {
const response: AxiosResponse<string[]> = await axios.post(`${sheetsPath}/${sheetId}/worksheets`);
return response.data;
};
/**
* HTTP request to upload the rundown to a google sheet
*/
export const uploadRundown = async (
sheetId: string,
options: ImportMap,
requestOptions?: RequestOptions,
): Promise<void> => {
const response = await axios.post(
`${sheetsPath}/${sheetId}/write`,
{ options },
{
signal: requestOptions?.signal,
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
},
);
export const uploadRundown = async (sheetId: string, options: ImportMap): Promise<void> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/write`, { options });
return response.data;
};
+2 -3
View File
@@ -2,15 +2,14 @@ import axios from 'axios';
import { URLPreset } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
/**
* HTTP request to retrieve all presets
*/
export async function getUrlPresets(options?: RequestOptions): Promise<URLPreset[]> {
const res = await axios.get(urlPresetsPath, { signal: options?.signal });
export async function getUrlPresets(): Promise<URLPreset[]> {
const res = await axios.get(urlPresetsPath);
return res.data;
}
+2 -3
View File
@@ -2,7 +2,6 @@ import axios from 'axios';
import type { ViewSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
@@ -10,8 +9,8 @@ const viewSettingsPath = `${apiEntryUrl}/view-settings`;
* HTTP request to fetch view settings
* @returns
*/
export async function getViewSettings(options?: RequestOptions): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath, { signal: options?.signal });
export async function getViewSettings(): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath);
return res.data;
}
@@ -25,12 +25,15 @@ export default function useReactiveTextInput(
const isKeyboardSubmitting = useRef(false);
useEffect(() => {
const isFocused = document.activeElement === ref.current;
if (isFocused) return;
if (typeof initialText === 'undefined') {
setText('');
} else {
setText(initialText);
}
}, [initialText]);
}, [initialText, ref]);
/**
* @description Handles Input value change
@@ -11,7 +11,7 @@ import IconButton from '../buttons/IconButton';
import Info from '../info/Info';
import { ViewOption } from './viewParams.types';
import { getPreservedSearchParams, getURLSearchParamsFromObj } from './viewParams.utils';
import { getURLSearchParamsFromObj } from './viewParams.utils';
import { useViewParamsEditorStore } from './viewParamsEditor.store';
import { ViewParamsPresets } from './ViewParamsPresets';
import ViewParamsSection from './ViewParamsSection';
@@ -25,19 +25,17 @@ interface EditFormDrawerProps {
export default memo(ViewParamsEditor);
function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const [_, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore();
const isSmallScreen = useIsSmallScreen();
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
const handleClose = () => {
close();
};
const resetParams = () => {
setSearchParams(getPreservedParams());
setSearchParams();
};
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
@@ -45,10 +43,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
const preservedParams = getPreservedParams();
preservedParams.forEach((value, key) => {
newSearchParams.append(key, value);
});
setSearchParams(newSearchParams);
if (isSmallScreen) {
@@ -191,26 +191,3 @@ export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields:
return newSearchParams;
}
/**
* Extracts search params that are not managed by the view params editor
* @param currentParams - The current URL search params
* @param paramFields - The view options that define the managed parameters
* @returns A new URLSearchParams object with preserved params
*/
export function getPreservedSearchParams(currentParams: URLSearchParams, paramFields: ViewOption[]) {
const managedParamIds = new Set<string>();
paramFields.forEach((section) => {
section.options.forEach((option) => {
managedParamIds.add(option.id);
});
});
const preserved = new URLSearchParams();
currentParams.forEach((value, key) => {
if (!managedParamIds.has(key)) {
preserved.append(key, value);
}
});
return preserved;
}
@@ -17,7 +17,7 @@ export default function useAppVersion() {
refetch,
} = useQuery({
queryKey: APP_VERSION,
queryFn: ({ signal }) => getLatestVersion({ signal }),
queryFn: getLatestVersion,
placeholderData: (previousData, _previousQuery) => previousData,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
@@ -8,9 +8,12 @@ import { automationPlaceholderSettings } from '../models/AutomationSettings';
export default function useAutomationSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: AUTOMATION,
queryFn: ({ signal }) => getAutomationSettings({ signal }),
queryFn: getAutomationSettings,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
@@ -10,9 +10,12 @@ const placeholder: CustomFields = {};
export default function useCustomFields() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS,
queryFn: ({ signal }) => getCustomFields({ signal }),
queryFn: getCustomFields,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? placeholder, status, isFetching, isError, refetch };
@@ -8,7 +8,7 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
export function useCustomTranslation() {
const { data, status, refetch } = useQuery({
queryKey: TRANSLATION,
queryFn: ({ signal }) => getUserTranslation({ signal }),
queryFn: getUserTranslation,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
@@ -9,9 +9,12 @@ import { ontimePlaceholderInfo } from '../models/Info';
export default function useInfo() {
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
queryKey: APP_INFO,
queryFn: ({ signal }) => getInfo({ signal }),
queryFn: getInfo,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? ontimePlaceholderInfo, status, isError, refetch, isFetching };
@@ -8,7 +8,7 @@ import { projectDataPlaceholder } from '../models/ProjectData';
export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: PROJECT_DATA,
queryFn: ({ signal }) => getProjectData({ signal }),
queryFn: getProjectData,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
@@ -14,9 +14,12 @@ const placeholderProjectList: ProjectFileListResponse = {
function useProjectList() {
const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST,
queryFn: ({ signal }) => getProjects({ signal }),
queryFn: getProjects,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? placeholderProjectList, status, refetch };
}
@@ -11,7 +11,7 @@ import { createRundown, deleteRundown, duplicateRundown, fetchProjectRundownList
export function useProjectRundowns() {
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
queryKey: PROJECT_RUNDOWNS,
queryFn: ({ signal }) => fetchProjectRundownList({ signal }),
queryFn: fetchProjectRundownList,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
@@ -8,8 +8,11 @@ import { fetchReport } from '../api/report';
export default function useReport() {
const { data, refetch } = useQuery<OntimeReport>({
queryKey: REPORT,
queryFn: ({ signal }) => fetchReport({ signal }),
queryFn: fetchReport,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
networkMode: 'always',
staleTime: MILLIS_PER_HOUR,
});
@@ -24,7 +24,7 @@ const cachedRundownPlaceholder: Rundown = {
export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: RUNDOWN,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
queryFn: fetchCurrentRundown,
refetchInterval: queryRefetchIntervalSlow,
});
@@ -8,7 +8,7 @@ import { ontimePlaceholderSettings } from '../models/OntimeSettings';
export default function useSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: APP_SETTINGS,
queryFn: ({ signal }) => getSettings({ signal }),
queryFn: getSettings,
placeholderData: (previousData, _previousQuery) => previousData,
select: (data) => {
const unobfuscated = { ...data };
@@ -13,7 +13,7 @@ interface FetchProps {
export default function useUrlPresets({ skip = false }: FetchProps = {}) {
const { data, status, isError, refetch } = useQuery({
queryKey: URL_PRESETS,
queryFn: ({ signal }) => getUrlPresets({ signal }),
queryFn: getUrlPresets,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
enabled: !skip,
@@ -9,7 +9,7 @@ import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() {
const { data, status } = useQuery({
queryKey: VIEW_SETTINGS,
queryFn: ({ signal }) => getViewSettings({ signal }),
queryFn: getViewSettings,
placeholderData: (previousData, _previousQuery) => previousData,
staleTime: MILLIS_PER_HOUR,
});
+14 -111
View File
@@ -6,31 +6,15 @@ import {
isOntimeEvent,
isOntimeGroup,
MaybeString,
OntimeDelay,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
PatchWithId,
Rundown,
SupportedEntry,
TimeField,
TimeStrategy,
TransientEventPayload,
} from 'ontime-types';
import {
addToRundown,
createDelay,
createEvent,
createGroup,
createMilestone,
dayInMs,
generateId,
getInsertAfterId,
MILLIS_PER_SECOND,
parseUserTime,
resolveInsertParent,
swapEventData,
} from 'ontime-utils';
import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, swapEventData } from 'ontime-utils';
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
import { RUNDOWN } from '../api/constants';
@@ -101,64 +85,9 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: addEntryMutation } = useMutation({
mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry),
onMutate: async ([_rundownId, entry]) => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) {
const optimisticEntry = createOptimisticEntry(entry);
const parentId = resolveInsertParent(previousData, entry);
const maybeParent = parentId ? previousData.entries[parentId] : null;
const parent = maybeParent && isOntimeGroup(maybeParent) ? maybeParent : null;
const afterId = getInsertAfterId(previousData, parent, entry.after, entry.before);
// create a mutable copy — addToRundown mutates in place using immutable array operations
const newRundown: Rundown = {
...previousData,
entries: { ...previousData.entries },
revision: -1,
};
// copy the parent group so we don't mutate the original
if (parent && parentId) {
newRundown.entries[parentId] = { ...parent, entries: [...parent.entries] };
}
addToRundown(
newRundown,
optimisticEntry,
afterId,
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
);
queryClient.setQueryData<Rundown>(RUNDOWN, newRundown);
}
return { previousData };
},
onSuccess: (response) => {
if (!response.data) return;
const serverEntry = response.data;
const currentData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (currentData) {
queryClient.setQueryData<Rundown>(RUNDOWN, {
...currentData,
entries: { ...currentData.entries, [serverEntry.id]: serverEntry },
});
}
},
onError: (_error, _variables, context) => {
if (context?.previousData) {
queryClient.setQueryData<Rundown>(RUNDOWN, context.previousData);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
mutationFn: ([rundownId, entry]: Parameters<typeof postAddEntry>) => postAddEntry(rundownId, entry),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
/**
@@ -173,15 +102,7 @@ export const useEntryActions = () => {
throw new Error('Rundown not initialised');
}
const newEntry: PatchWithId & InsertOptions = { ...entry, id: generateId() };
// handle adding options that concern all event types
if (options?.after) {
newEntry.after = options.after;
}
if (options?.before) {
newEntry.before = options.before;
}
const newEntry: TransientEventPayload = { ...entry, id: generateId() };
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) {
@@ -221,6 +142,14 @@ export const useEntryActions = () => {
}
}
// handle adding options that concern all event type
if (options?.after) {
(newEntry as TransientEventPayload).after = options.after;
}
if (options?.before) {
(newEntry as TransientEventPayload).before = options.before;
}
try {
await addEntryMutation([rundownId, newEntry]);
} catch (error) {
@@ -976,29 +905,3 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
return { entries, order, flatOrder };
}
/**
* Utility to create an optimistic entry for immediate cache insertion
*/
function createOptimisticEntry(payload: PatchWithId & InsertOptions): OntimeEntry {
const { after: _after, before: _before, ...entryData } = payload;
const id = entryData.id;
let parent: EntryId | null = null;
if ('parent' in entryData && entryData.parent) {
parent = entryData.parent;
}
switch (entryData.type) {
case SupportedEntry.Event: {
return createEvent({ ...entryData, id, parent }) as OntimeEvent;
}
case SupportedEntry.Delay:
return createDelay({ id, duration: (entryData as Partial<OntimeDelay>).duration ?? 0, parent });
case SupportedEntry.Group:
return createGroup({ ...(entryData as Partial<OntimeGroup>), id });
case SupportedEntry.Milestone:
return createMilestone({ ...(entryData as Partial<OntimeMilestone>), id, parent });
default:
throw new Error('Unknown entry type');
}
}
+3 -10
View File
@@ -1,5 +1,4 @@
import { QueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { isOntimeCloud } from '../externals';
@@ -9,15 +8,10 @@ export const ontimeQueryClient = new QueryClient({
queries: {
gcTime: 10 * MILLIS_PER_MINUTE,
// staleTime: MILLIS_PER_HOUR, //TODO: when all routes have implemented refetch signal from server, we can the assume that the data is not stale until we get the signal
networkMode: isOntimeCloud ? 'online' : 'always',
networkMode: 'always',
refetchOnWindowFocus: false,
retry: (failureCount, error) => {
if (axios.isCancel(error)) {
return false;
}
return failureCount < 5;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 5000),
retry: 5,
retryDelay: (attempt) => attempt * 2500,
},
mutations: {
/**
@@ -27,7 +21,6 @@ export const ontimeQueryClient = new QueryClient({
* - use 'online' for clients that are connected to the cloud
*/
networkMode: isOntimeCloud ? 'online' : 'always',
retry: 0,
},
},
});
+7 -31
View File
@@ -38,13 +38,7 @@ import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null;
const socketConfig = {
reconnectBaseInterval: 1000, // 1 second
reconnectMaxInterval: 30000, // 30 seconds
reconnectMinInterval: 500, // 0.5 seconds
reconnectJitter: 0.25,
offlineAttemptsThreshold: 2, // when we consider the client disconnected
} as const;
const reconnectInterval = 1000;
export let hasConnected = false;
export let reconnectAttempts = 0;
@@ -55,11 +49,7 @@ export const connectSocket = () => {
const preferredClientName = getClientName();
websocket.onopen = () => {
const isReconnect = hasConnected;
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
clearTimeout(reconnectTimeout as NodeJS.Timeout);
hasConnected = true;
reconnectAttempts = 0;
@@ -69,38 +59,24 @@ export const connectSocket = () => {
path: window.location.pathname + window.location.search,
name: preferredClientName,
});
if (isReconnect) {
invalidateAllCaches();
}
invalidateAllCaches(); // assume all data to be stale after a reconnect
setOnlineStatus(true);
};
websocket.onclose = () => {
console.warn('WebSocket disconnected');
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
const exponentialDelay = Math.min(
socketConfig.reconnectBaseInterval * 2 ** reconnectAttempts,
socketConfig.reconnectMaxInterval,
);
const jitterOffset = exponentialDelay * socketConfig.reconnectJitter * (Math.random() * 2 - 1);
const delay = Math.max(socketConfig.reconnectMinInterval, Math.round(exponentialDelay + jitterOffset));
// we decide to allows reconnect
reconnectTimeout = setTimeout(() => {
reconnectTimeout = null;
if (reconnectAttempts > socketConfig.offlineAttemptsThreshold) {
if (reconnectAttempts > 2) {
setOnlineStatus(false);
}
console.warn(`WebSocket: reconnecting now (#${reconnectAttempts + 1}, waited ${delay}ms)`);
console.warn('WebSocket: attempting reconnect');
if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1;
connectSocket();
}
}, delay);
}, reconnectInterval);
};
websocket.onerror = (error) => {
@@ -32,6 +32,7 @@ export default function GeneralSettings() {
} = useForm<Settings>({
mode: 'onChange',
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
@@ -31,6 +31,7 @@ export default function ViewSettings() {
formState: { isSubmitting, isDirty, errors },
} = useForm<ViewSettingsType>({
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
@@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
import { lazy, useEffect, useRef, useState } from 'react';
import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../../common/api/assets';
import Button from '../../../../../common/components/buttons/Button';
@@ -81,9 +81,7 @@ export default function CodeEditorModal({ isOpen, onClose }: CodeEditorModalProp
showCloseButton
showBackdrop
bodyElements={
<Suspense fallback={null}>
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
</Suspense>
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
}
footerElements={
<div className={style.column}>
+3 -2
View File
@@ -1,4 +1,4 @@
import { type HTMLProps, forwardRef, Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { type HTMLProps, forwardRef, Fragment, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { TbFlagFilled } from 'react-icons/tb';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { closestCenter, DndContext } from '@dnd-kit/core';
@@ -26,13 +26,14 @@ import RundownGroup from './rundown-group/RundownGroup';
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
import { filterVisibleEntries, makeSortableList } from './rundown.utils';
import RundownEmpty from './RundownEmpty';
import RundownEntry from './RundownEntry';
import { useCollapsedGroups } from './useCollapsedGroups';
import { useEditorFollowMode } from './useEditorFollowMode';
import { useEventSelection } from './useEventSelection';
import style from './Rundown.module.scss';
const RundownEntry = lazy(() => import('./RundownEntry'));
interface RundownProps {
entries: RundownType['entries'];
id: RundownType['id'];
@@ -1,15 +1,8 @@
import { ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import {
ContextProp,
ItemProps,
TableComponents,
TableProps,
TableVirtuoso,
TableVirtuosoHandle,
} from 'react-virtuoso';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
import { useTableNav } from '@table-nav/react';
import { ColumnDef, getCoreRowModel, Table, useReactTable } from '@tanstack/react-table';
import { isOntimeDelay, isOntimeGroup, isOntimeMilestone, OntimeEntry, TimeField } from 'ontime-types';
import { ColumnDef, getCoreRowModel, Row, Table, useReactTable } from '@tanstack/react-table';
import { EntryId, isOntimeDelay, isOntimeGroup, isOntimeMilestone, OntimeEntry, TimeField } from 'ontime-types';
import EmptyPage from '../../../common/components/state/EmptyPage';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
@@ -34,6 +27,115 @@ import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumn
import style from './CuesheetTable.module.scss';
interface VirtuosoContext {
table: Table<ExtendedEntry>;
rows: Row<ExtendedEntry>[];
cursor: EntryId | null;
columnSizeVars: Record<string, number | string>;
listeners: object;
}
const VirtuosoTable = memo(({ style: injectedStyles, context, ...virtuosoProps }: any) => {
const { columnSizeVars, listeners } = context as VirtuosoContext;
return (
<table
className={style.cuesheet}
id="cuesheet"
style={{ ...injectedStyles, ...columnSizeVars }}
{...listeners}
{...virtuosoProps}
/>
);
});
VirtuosoTable.displayName = 'VirtuosoTable';
const VirtuosoTableRow = memo(({ item: _item, context, ...virtuosoProps }: any) => {
const { table, rows, cursor } = context as VirtuosoContext;
const rowIndex = virtuosoProps['data-index'];
const row = rows[rowIndex];
if (!row) return null;
const key = row.original.id;
const entry = row.original;
const hasCursor = entry.id === cursor;
if (isOntimeGroup(entry)) {
return (
<GroupRow
key={key}
groupId={entry.id}
colour={entry.colour}
rowId={row.id}
rowIndex={row.index}
table={table}
injectedStyles={virtuosoProps.style}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
}
if (isOntimeDelay(entry)) {
return (
<DelayRow
key={key}
duration={entry.duration}
injectedStyles={virtuosoProps.style}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
}
if (isOntimeMilestone(entry)) {
return (
<MilestoneRow
key={key}
entryId={entry.id}
isPast={entry.isPast}
parentBgColour={entry.groupColour}
parentId={entry.parent}
colour={entry.colour}
rowId={row.id}
rowIndex={rowIndex}
table={table}
injectedStyles={virtuosoProps.style}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
}
return (
<EventRow
key={row.id}
id={entry.id}
eventIndex={entry.eventIndex}
colour={entry.colour}
isFirstAfterGroup={entry.isFirstAfterGroup}
isLoaded={entry.isLoaded}
isPast={entry.isPast}
groupColour={entry.groupColour}
flag={entry.flag}
skip={entry.skip}
parent={entry.parent}
rowId={row.id}
rowIndex={rowIndex}
table={table}
injectedStyles={virtuosoProps.style}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
});
VirtuosoTableRow.displayName = 'VirtuosoTableRow';
const VirtuosoTableHead = memo((props: any) => <thead className={style.tableHeader} {...props} />);
VirtuosoTableHead.displayName = 'VirtuosoTableHead';
const VirtuosoEmptyPlaceholder = memo(() => <EmptyTableBody text="No data in rundown" />);
VirtuosoEmptyPlaceholder.displayName = 'VirtuosoEmptyPlaceholder';
interface CuesheetTableProps {
columns: ColumnDef<ExtendedEntry>[];
cuesheetMode: AppMode;
@@ -56,11 +158,14 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
const { listeners } = useTableNav();
const dataRef = useRef(data);
dataRef.current = data;
const meta = useMemo(
() => ({
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => {
// check if value is the same
const event = data[rowIndex];
const event = dataRef.current[rowIndex];
if (!event) {
return;
@@ -91,7 +196,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
hideIndexColumn,
},
}),
[cuesheetMode, data, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
[cuesheetMode, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
);
const { columnOrder, resetColumnOrder } = useColumnOrder(columns, tableRoot);
@@ -177,29 +282,18 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
const allLeafColumns = table.getAllLeafColumns();
const { rows } = table.getRowModel();
const virtuosoContext = useMemo(
() => ({
columnSizeVars,
cursor,
listeners,
rows,
table,
rows,
cursor,
columnSizeVars,
listeners,
}),
[columnSizeVars, cursor, listeners, rows, table],
);
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
const fixedHeaderContent = useCallback(() => {
return table.getHeaderGroups().map((headerGroup) => {
const HeaderComponent = table.getState().columnSizingInfo.isResizingColumn
? CuesheetHeader
: SortableCuesheetHeader;
// if the table is being resized, we render non-sortable headers to avoid performance issues
return <HeaderComponent key={headerGroup.id} cuesheetMode={cuesheetMode} headerGroup={headerGroup} />;
});
}, [cuesheetMode, table]);
const isLoading = !data || status === 'pending';
if (isLoading) {
@@ -222,144 +316,26 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
data={data}
context={virtuosoContext}
style={tableRoot === 'editor' ? { paddingLeft: '1rem' } : undefined}
computeItemKey={computeItemKey}
increaseViewportBy={{ top: 100, bottom: 200 }}
components={virtuosoComponents}
fixedHeaderContent={fixedHeaderContent}
components={{
EmptyPlaceholder: VirtuosoEmptyPlaceholder,
Table: VirtuosoTable,
TableRow: VirtuosoTableRow,
TableHead: VirtuosoTableHead,
}}
fixedHeaderContent={() => {
return table.getHeaderGroups().map((headerGroup) => {
const HeaderComponent = table.getState().columnSizingInfo.isResizingColumn
? CuesheetHeader
: SortableCuesheetHeader;
// if the table is being resized, we render non-sortable headers to avoid performance issues
return <HeaderComponent key={headerGroup.id} cuesheetMode={cuesheetMode} headerGroup={headerGroup} />;
});
}}
/>
<TableMenu />
</>
);
}
interface CuesheetVirtuosoContext {
columnSizeVars: { [key: string]: number };
cursor: string | null;
listeners: ReturnType<typeof useTableNav>['listeners'];
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
table: Table<ExtendedEntry>;
}
const EmptyPlaceholder = memo(function EmptyPlaceholder() {
return <EmptyTableBody text='No data in rundown' />;
});
const CuesheetTableElement = memo(function CuesheetTableElement({
style: injectedStyles,
context,
...virtuosoProps
}: TableProps & ContextProp<CuesheetVirtuosoContext>) {
return (
<table
className={style.cuesheet}
id='cuesheet'
style={{ ...injectedStyles, ...context.columnSizeVars }}
{...context.listeners}
{...virtuosoProps}
/>
);
});
const CuesheetTableHead = memo(function CuesheetTableHead({
context: _context,
className: _className,
...virtuosoProps
}: ComponentProps<'thead'> & ContextProp<CuesheetVirtuosoContext>) {
return <thead className={style.tableHeader} {...virtuosoProps} />;
});
const CuesheetTableRow = memo(function CuesheetTableRow({
item: _item,
style: injectedStyles,
context,
...virtuosoProps
}: ItemProps<ExtendedEntry> & ContextProp<CuesheetVirtuosoContext>) {
// eslint-disable-next-line react/destructuring-assignment
const rowIndex = virtuosoProps['data-index'];
const row = context.rows[rowIndex];
if (!row) {
return null;
}
const key = row.original.id;
const entry = row.original;
const hasCursor = entry.id === context.cursor;
if (isOntimeGroup(entry)) {
return (
<GroupRow
key={key}
groupId={entry.id}
colour={entry.colour}
rowId={row.id}
rowIndex={row.index}
table={context.table}
injectedStyles={injectedStyles}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
}
if (isOntimeDelay(entry)) {
return (
<DelayRow
key={key}
duration={entry.duration}
injectedStyles={injectedStyles}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
}
if (isOntimeMilestone(entry)) {
return (
<MilestoneRow
key={key}
entryId={entry.id}
isPast={entry.isPast}
parentBgColour={entry.groupColour}
parentId={entry.parent}
colour={entry.colour}
rowId={row.id}
rowIndex={rowIndex}
table={context.table}
injectedStyles={injectedStyles}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
}
return (
<EventRow
key={row.id}
id={entry.id}
eventIndex={entry.eventIndex}
colour={entry.colour}
isFirstAfterGroup={entry.isFirstAfterGroup}
isLoaded={entry.isLoaded}
isPast={entry.isPast}
groupColour={entry.groupColour}
flag={entry.flag}
skip={entry.skip}
parent={entry.parent}
rowId={row.id}
rowIndex={rowIndex}
table={context.table}
injectedStyles={injectedStyles}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
});
const virtuosoComponents: TableComponents<ExtendedEntry, CuesheetVirtuosoContext> = {
EmptyPlaceholder,
Table: CuesheetTableElement,
TableHead: CuesheetTableHead,
TableRow: CuesheetTableRow,
};
@@ -91,7 +91,6 @@ export default function EventRow({
}}
data-cursor={hasCursor}
data-testid='cuesheet-event'
data-entry-id={id}
{...virtuosoProps}
>
{cuesheetMode === AppMode.Edit && (
@@ -128,8 +127,6 @@ export default function EventRow({
}}
tabIndex={-1}
role='cell'
data-testid={`cuesheet-cell-${cell.column.id}`}
data-column-id={cell.column.id}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
@@ -5,14 +5,12 @@ import useReactiveTextInput from '../../../../common/components/input/text-input
interface MultiLineCellProps {
initialValue: string;
fieldId?: string;
fieldLabel?: string;
handleUpdate: (newValue: string) => void;
}
export default memo(MultiLineCell);
function MultiLineCell({ initialValue, fieldId, fieldLabel, handleUpdate }: MultiLineCellProps) {
function MultiLineCell({ initialValue, handleUpdate }: MultiLineCellProps) {
const ref = useRef<HTMLTextAreaElement | null>(null);
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
@@ -32,8 +30,6 @@ function MultiLineCell({ initialValue, fieldId, fieldLabel, handleUpdate }: Mult
onBlur={onBlur}
onKeyDown={onKeyDown}
spellCheck={false}
data-testid={fieldId ? `cuesheet-editor-${fieldId}` : undefined}
aria-label={fieldLabel ? `${fieldLabel} editor` : undefined}
/>
);
}
@@ -5,18 +5,13 @@ import useReactiveTextInput from '../../../../common/components/input/text-input
interface SingleLineCellProps {
initialValue: string;
fieldId?: string;
fieldLabel?: string;
allowSubmitSameValue?: boolean;
handleUpdate: (newValue: string) => void;
handleCancelUpdate?: () => void;
}
const SingleLineCell = forwardRef(
(
{ initialValue, fieldId, fieldLabel, allowSubmitSameValue, handleUpdate, handleCancelUpdate }: SingleLineCellProps,
inputRef,
) => {
({ initialValue, allowSubmitSameValue, handleUpdate, handleCancelUpdate }: SingleLineCellProps, inputRef) => {
const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
@@ -52,8 +47,6 @@ const SingleLineCell = forwardRef(
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
data-testid={fieldId ? `cuesheet-editor-${fieldId}` : undefined}
aria-label={fieldLabel ? `${fieldLabel} editor` : undefined}
/>
);
},
@@ -17,10 +17,6 @@ import MutedText from './MutedText';
import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput';
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string {
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
}
function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
if (!table.options.meta) {
return null;
@@ -150,14 +146,7 @@ function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, u
return <GhostedText multiline>{initialValue}</GhostedText>;
}
return (
<MultiLineCell
initialValue={initialValue as string}
fieldId={column.id}
fieldLabel={getColumnLabel(column)}
handleUpdate={update}
/>
);
return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />;
}
function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
@@ -197,14 +186,7 @@ function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry,
return <GhostedText>{initialValue}</GhostedText>;
}
return (
<SingleLineCell
initialValue={initialValue as string}
fieldId={column.id}
fieldLabel={getColumnLabel(column)}
handleUpdate={update}
/>
);
return <SingleLineCell initialValue={initialValue as string} handleUpdate={update} />;
}
function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
@@ -237,14 +219,7 @@ function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unkn
return <GhostedText multiline>{initialValue}</GhostedText>;
}
return (
<MultiLineCell
initialValue={initialValue}
fieldId={column.id}
fieldLabel={getColumnLabel(column)}
handleUpdate={update}
/>
);
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
}
/**
+2 -2
View File
@@ -181,10 +181,10 @@ export function getCardData(
: getPropertyValue(eventNow, secondarySource, entries);
return {
showNow: mainSource !== 'none' && (Boolean(nowMain) || Boolean(nowSecondary)),
showNow: mainSource !== 'none' || Boolean(nowSecondary),
nowMain,
nowSecondary,
showNext: mainSource !== 'none' && (Boolean(nextMain) || Boolean(nextSecondary)),
showNext: mainSource !== 'none' || Boolean(nextSecondary),
nextMain,
nextSecondary,
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.4.2",
"version": "4.3.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/resolver",
"version": "4.4.2",
"version": "4.3.1",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.4.2",
"version": "4.3.1",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -17,15 +17,10 @@ import {
URLPreset,
ViewSettings,
} from 'ontime-types';
import {
customFieldLabelToKey,
checkRegex,
isKnownTimerType,
validateEndAction,
eventDef as eventModel,
} from 'ontime-utils';
import { customFieldLabelToKey, checkRegex, isKnownTimerType, validateEndAction } from 'ontime-utils';
import { is } from '../../../utils/is.js';
import { event as eventModel } from '../../../models/eventsDefinition.js';
import { ONTIME_VERSION } from '../../../ONTIME_VERSION.js';
import { getPartialProject } from '../../../models/dataModel.js';
@@ -18,12 +18,12 @@ import {
isKnownTimerType,
validateTimerType,
validateEndAction,
makeString,
} from 'ontime-utils';
import { Prettify } from 'ts-essentials';
import { is } from '../../utils/is.js';
import { makeString } from '../../utils/parserUtils.js';
import { parseExcelDate } from '../../utils/time.js';
import { generateImportHandlers, getCustomFieldData, parseBooleanString, SheetMetadata } from './excel.utils.js';
@@ -1,5 +1,5 @@
import { TimeStrategy, EndAction, TimerType, OntimeEvent } from 'ontime-types';
import { createEvent, MILLIS_PER_HOUR } from 'ontime-utils';
import { TimeStrategy, EndAction, TimerType, OntimeEvent, OntimeGroup } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { assertType } from 'vitest';
@@ -7,9 +7,11 @@ import { demoDb } from '../../../models/demoProject.js';
import {
calculateDayOffset,
createEvent,
deleteById,
doesInvalidateMetadata,
duplicateRundown,
getInsertAfterId,
hasChanges,
makeDeepClone,
} from '../rundown.utils.js';
@@ -224,6 +226,45 @@ describe('calculateDayOffset()', () => {
});
});
describe('getInsertAfterId()', () => {
const rundown = makeRundown({
entries: {
'1': makeOntimeEvent({ id: '1', parent: null }),
'2': makeOntimeEvent({ id: '2', parent: null }),
group: makeOntimeGroup({ id: 'group', entries: ['31', '32'] }),
'31': makeOntimeEvent({ id: '31', parent: 'group' }),
'32': makeOntimeEvent({ id: '32', parent: 'group' }),
'4': makeOntimeEvent({ id: '4', parent: null }),
},
order: ['1', '2', 'group', '4'],
flatOrder: ['1', '2', 'group', '31', '32', '4'],
});
it('returns afterId if provided', () => {
expect(getInsertAfterId(rundown, null, 'b')).toBe('b');
});
it('returns null if neither afterId nor beforeId is provided', () => {
expect(getInsertAfterId(rundown, null)).toBeNull();
});
it('returns null if beforeId is not found', () => {
expect(getInsertAfterId(rundown, null, undefined, 'z')).toBeNull();
expect(getInsertAfterId(rundown, null, undefined, '1')).toBeNull();
});
it('returns the previous id of an entry in the rundown', () => {
expect(getInsertAfterId(rundown, null, undefined, '2')).toBe('1');
expect(getInsertAfterId(rundown, null, undefined, '4')).toBe('group');
expect(getInsertAfterId(rundown, null, undefined, 'group')).toBe('2');
});
it('returns the previous id of an event in a group', () => {
expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '31')).toBeNull();
expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '32')).toBe('31');
});
});
describe('duplicateRundown', () => {
it('duplicates a given rundown', () => {
const demoRundown = demoDb.rundowns['default'];
+66 -12
View File
@@ -26,7 +26,7 @@ import {
Rundown,
InsertOptions,
} from 'ontime-types';
import { addToRundown, customFieldLabelToKey, getInsertAfterId, insertAtIndex, createGroup } from 'ontime-utils';
import { customFieldLabelToKey, insertAtIndex } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { consoleError } from '../../utils/console.js';
@@ -35,8 +35,10 @@ import type { RundownMetadata } from './rundown.types.js';
import {
applyPatchToEntry,
cloneSimpleRundownEntry,
createGroup,
deleteById,
doesInvalidateMetadata,
getInsertAfterId,
getUniqueId,
makeDeepClone,
} from './rundown.utils.js';
@@ -108,6 +110,11 @@ export function createTransaction(options: TransactionOptions): Transaction {
function commit(shouldProcess: boolean = true) {
// if the rundown is mutable we persist the changes
if (options.mutableRundown) {
// schedule a database update
setImmediate(async () => {
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
});
// update fields which are agnostic of whether the rundown is processed
cachedRundown.revision = cachedRundown.revision + 1;
cachedRundown.title = rundown.title;
@@ -128,17 +135,16 @@ export function createTransaction(options: TransactionOptions): Transaction {
cachedRundown.flatOrder = metadata.flatEntryOrder;
rundownMetadata = metadata;
}
// persist after all mutations are applied
getDataProvider().setRundown(cachedRundown.id, cachedRundown);
}
// if the customFields are mutable we persist the changes
if (options.mutableCustomFields) {
projectCustomFields = customFields;
// schedule a database update
setImmediate(async () => {
await getDataProvider().setCustomFields(projectCustomFields);
});
// persist after reassignment
getDataProvider().setCustomFields(projectCustomFields);
projectCustomFields = customFields;
}
return {
@@ -157,6 +163,50 @@ export function createTransaction(options: TransactionOptions): Transaction {
};
}
/**
* Add entry to rundown, handles the following cases:
* - 1a. add entry in group, after a given entry
* - 1b. add entry in group, at the beginning
* - 2a. add entry to the rundown, after a given entry
* - 2b. add entry to the rundown, at the beginning
*/
function add(rundown: Rundown, entry: OntimeEntry, afterId: EntryId | null, parent: OntimeGroup | null): OntimeEntry {
if (parent) {
// 1. inserting an entry inside a group
if ('parent' in entry) {
entry.parent = parent.id;
}
if (afterId) {
const atEventsIndex = parent.entries.indexOf(afterId) + 1;
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
parent.entries = insertAtIndex(atEventsIndex, entry.id, parent.entries);
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
} else {
parent.entries = insertAtIndex(0, entry.id, parent.entries);
const atFlatIndex = rundown.flatOrder.indexOf(parent.id) + 1;
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
}
} else {
// 2. inserting an entry at top level
if (afterId) {
const atOrderIndex = rundown.order.indexOf(afterId) + 1;
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
rundown.order = insertAtIndex(atOrderIndex, entry.id, rundown.order);
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
} else {
rundown.order = insertAtIndex(0, entry.id, rundown.order);
rundown.flatOrder = insertAtIndex(0, entry.id, rundown.flatOrder);
}
}
// either way, we insert the entry into the rundown
rundown.entries[entry.id] = entry;
return entry;
}
/**
* Applies a patch of changes to an existing entry
* @returns { entry: OntimeEntry, didInvalidate: boolean } - didInvalidate indicates whether the change warrants a recalculation of the cache
@@ -466,7 +516,7 @@ function clone(rundown: Rundown, entry: OntimeEntry, options?: InsertOptions): O
after = entry.id;
}
return addToRundown(rundown, clonedEntry, after, parent);
return add(rundown, clonedEntry, after, parent);
}
}
@@ -533,7 +583,7 @@ function ungroup(rundown: Rundown, group: OntimeGroup) {
}
export const rundownMutation = {
add: addToRundown,
add,
edit,
remove,
removeAll,
@@ -548,8 +598,10 @@ export const rundownMutation = {
/**
* Exposes a way to update a rundown which is not active
*/
export async function updateBackgroundRundown(rundownId: string, rundown: Rundown) {
await getDataProvider().setRundown(rundownId, rundown);
export function updateBackgroundRundown(rundownId: string, rundown: Rundown) {
setImmediate(async () => {
await getDataProvider().setRundown(rundownId, rundown);
});
}
/**
@@ -646,7 +698,9 @@ export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Rea
rundownMetadata = metadata;
// defer writing to the database
getDataProvider().setRundown(cachedRundown.id, cachedRundown);
setImmediate(async () => {
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
});
return { rundown, rundownMetadata, customFields, revision: rundown.revision };
}
@@ -17,22 +17,13 @@ import {
OntimeMilestone,
OntimeGroup,
} from 'ontime-types';
import {
isObjectEmpty,
generateId,
getLinkedTimes,
getTimeFrom,
isNewLatest,
createDelay,
createEvent,
createGroup,
createMilestone,
} from 'ontime-utils';
import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
import { delay as delayDef } from '../../models/eventsDefinition.js';
import { makeNewRundown } from '../../models/dataModel.js';
import type { ErrorEmitter } from '../../utils/parserUtils.js';
import { calculateDayOffset, cleanupCustomFields } from './rundown.utils.js';
import { calculateDayOffset, cleanupCustomFields, createGroup, createEvent, createMilestone } from './rundown.utils.js';
import { RundownMetadata } from './rundown.types.js';
/**
@@ -116,7 +107,7 @@ export function parseRundown(
cleanupCustomFields(newEvent.custom, parsedCustomFields);
eventIndex += 1;
} else if (isOntimeDelay(event)) {
newEvent = createDelay({ duration: event.duration, id });
newEvent = { ...delayDef, duration: event.duration, id };
} else if (isOntimeMilestone(event)) {
newEvent = createMilestone({ ...event, id });
cleanupCustomFields(newEvent.custom, parsedCustomFields);
@@ -143,7 +134,7 @@ export function parseRundown(
cleanupCustomFields(newNestedEvent.custom, parsedCustomFields);
eventIndex += 1;
} else if (isOntimeDelay(nestedEvent)) {
newNestedEvent = createDelay({ duration: nestedEvent.duration, id: nestedEventId });
newNestedEvent = { ...delayDef, duration: nestedEvent.duration, id: nestedEventId };
newNestedEvent.parent = event.id;
} else if (isOntimeMilestone(nestedEvent)) {
newNestedEvent = createMilestone({ ...nestedEvent, id: nestedEventId });
@@ -16,7 +16,7 @@ import {
ProjectRundowns,
InsertOptions,
} from 'ontime-types';
import { customFieldLabelToKey, getInsertAfterId, resolveInsertParent } from 'ontime-utils';
import { customFieldLabelToKey } from 'ontime-utils';
import { updateRundownData } from '../../stores/runtimeState.js';
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
@@ -33,7 +33,7 @@ import {
updateBackgroundRundown,
} from './rundown.dao.js';
import type { RundownMetadata } from './rundown.types.js';
import { generateEvent, hasChanges } from './rundown.utils.js';
import { generateEvent, getInsertAfterId, hasChanges } from './rundown.utils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
/**
@@ -47,15 +47,28 @@ export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry
throw new Error(`Event with ID ${eventData.id} already exists`);
}
// resolve the parent, either from the payload or inferred from sibling position
const parentId = resolveInsertParent(rundown, eventData);
// the parent can be provided or inferred from position
let parent: OntimeGroup | null = null;
if (parentId) {
const maybeParent = rundown.entries[parentId];
if ('parent' in eventData && eventData.parent != null) {
// if the user provides a parent (inside a group), we make sure it exists and it is a group
const maybeParent = rundown.entries[eventData.parent];
if (!maybeParent || !isOntimeGroup(maybeParent)) {
throw new Error(`Invalid parent event with ID ${parentId}`);
throw new Error(`Invalid parent event with ID ${eventData.parent}`);
}
parent = maybeParent;
} else {
// otherwise, we may infer the parent from relative positioning (after/before)
const referenceId = eventData?.after ?? eventData?.before;
if (referenceId) {
const maybeSibling = rundown.entries[referenceId];
if (maybeSibling && 'parent' in maybeSibling && maybeSibling.parent) {
const maybeParent = rundown.entries[maybeSibling.parent];
if (maybeParent && isOntimeGroup(maybeParent)) {
parent = maybeParent;
}
}
}
}
// normalise the position of the event in the rundown order
@@ -486,7 +499,7 @@ export async function editCustomField(
if (rundownId !== rundown.id) {
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey);
await updateBackgroundRundown(rundownId, backgroundRundown);
updateBackgroundRundown(rundown.id, backgroundRundown);
}
}
@@ -526,7 +539,7 @@ export async function deleteCustomField(key: CustomFieldKey, projectRundowns: Pr
if (rundownId !== rundown.id) {
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
customFieldMutation.removeUsages(backgroundRundown, key);
await updateBackgroundRundown(rundownId, backgroundRundown);
updateBackgroundRundown(rundown.id, backgroundRundown);
}
}
@@ -22,16 +22,19 @@ import {
dayInMs,
generateId,
getCueCandidate,
createDelay,
createEvent,
createGroup,
createMilestone,
makeString,
validateEndAction,
validateTimerType,
validateTimes,
} from 'ontime-utils';
import {
event as eventDef,
group as groupDef,
delay as delayDef,
milestone as milestoneDef,
} from '../../models/eventsDefinition.js';
import { makeString } from '../../utils/parserUtils.js';
import { RundownMetadata } from './rundown.types.js';
type CompleteEntry<T> =
@@ -58,7 +61,7 @@ export function generateEvent<
const id = eventData.id || getUniqueId(rundown);
if (isOntimeDelay(eventData)) {
return createDelay({ duration: eventData.duration ?? 0, id }) as CompleteEntry<T>;
return { ...delayDef, duration: eventData.duration ?? 0, id } as CompleteEntry<T>;
}
// TODO(v4): allow user to provide a larger patch of the group entry
@@ -195,6 +198,74 @@ export function applyPatchToEntry(eventFromRundown: OntimeEntry, patch: Partial<
return { ...eventFromRundown, ...patch } as OntimeDelay;
}
/**
* @description Enforces formatting for events
* @param {object} eventArgs - attributes of event
* @param {number} eventIndex - can be a string when we pass the a suggested cue name
* @returns {object|null} - formatted object or null in case is invalid
*/
export const createEvent = (eventArgs: Partial<OntimeEvent>, eventIndex: number | string): OntimeEvent | null => {
if (Object.keys(eventArgs).length === 0) {
return null;
}
const cue = typeof eventIndex === 'number' ? String(eventIndex + 1) : eventIndex;
const baseEvent = {
id: eventArgs?.id ?? generateId(),
cue,
...eventDef,
};
const event = createEventPatch(baseEvent, eventArgs);
return event;
};
/**
* Creates a new group from an optional patch
*/
export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
if (!patch) {
return { ...groupDef, id: generateId() };
}
return {
id: patch.id ?? generateId(),
type: SupportedEntry.Group,
title: patch.title ?? '',
note: patch.note ?? '',
entries: patch.entries ?? [],
targetDuration: patch.targetDuration ?? null,
colour: makeString(patch.colour, ''),
custom: patch.custom ?? {},
revision: 0,
timeStart: null,
timeEnd: null,
duration: 0,
isFirstLinked: false,
};
}
/**
* Creates a new milestone from an optional patch
*/
export function createMilestone(patch?: Partial<OntimeMilestone>): OntimeMilestone {
if (!patch) {
return { ...milestoneDef, id: generateId() };
}
return {
id: patch.id ?? generateId(),
type: SupportedEntry.Milestone,
cue: patch.cue ?? '',
title: patch.title ?? '',
note: patch.note ?? '',
colour: makeString(patch.colour, ''),
custom: patch.custom ?? {},
parent: patch.parent ?? null,
revision: 0,
};
}
/**
* Function infers strategy for a patch with only partial timer data
* @param end
@@ -412,6 +483,31 @@ export function calculateDayOffset(
return 0;
}
/**
* Receives an insertion order and returns the reference to an event ID
* after which we will insert the new event
*/
export function getInsertAfterId(
rundown: Rundown,
parent: OntimeGroup | null,
afterId?: EntryId,
beforeId?: EntryId,
): EntryId | null {
if (afterId) return afterId;
if (!beforeId) return null;
/**
* At this point we know we want to insert before a given ID
* We need to check which list we should use to insert and find the event there
*/
const insertionList = parent ? parent.entries : rundown.order;
if (!insertionList || insertionList.length === 0) return null;
const atIndex = insertionList.findIndex((id) => id === beforeId);
if (atIndex < 1) return null;
return insertionList[atIndex - 1];
}
/**
* Sanitises custom fields in an entry by removing fields
* - if it does not exist in the project
+1 -5
View File
@@ -25,7 +25,7 @@ import { integrationRouter } from './api-integration/integration.router.js';
// Import adapters
import { socket } from './adapters/WebsocketAdapter.js';
import { getDataProvider, flushPendingWrites } from './classes/data-provider/DataProvider.js';
import { getDataProvider } from './classes/data-provider/DataProvider.js';
// Services
import { logger } from './classes/Logger.js';
@@ -266,10 +266,6 @@ export const startIntegrations = async () => {
export const shutdown = async (exitCode = 0) => {
consoleHighlight(`Ontime shutting down with code ${exitCode}`);
await flushPendingWrites().catch((_error) => {
/** nothing do to here */
});
// clear the restore file if it was a normal exit
// 0 means it was a SIGNAL
// 1 means crash -> keep the file
@@ -180,58 +180,10 @@ async function mergeIntoData(newData: Partial<DatabaseModel>): ReadonlyPromise<D
return db.data;
}
// Module-level state for debounced writes
let pendingWrite: NodeJS.Timeout | null = null;
let activeWrite: Promise<void> | null = null;
const writeDelayMs = 3000; // 3 seconds
/**
* Handles persisting data to file with trailing-edge debounce
* Multiple rapid calls will be coalesced into a single write
* Handles persisting data to file
*/
async function persist() {
if (isTest) return;
// Cancel any pending write and reschedule
if (pendingWrite) {
clearTimeout(pendingWrite);
}
// Schedule new write after quiet period
pendingWrite = setTimeout(async () => {
pendingWrite = null;
// Wait for any in-progress write to finish first
if (activeWrite) {
await activeWrite;
}
try {
activeWrite = db.write();
await activeWrite;
} catch (error) {
console.error('Failed to persist database:', error);
} finally {
activeWrite = null;
}
}, writeDelayMs);
}
/**
* Force immediate write of any pending changes
*/
export async function flushPendingWrites() {
if (isTest) return;
if (pendingWrite) {
clearTimeout(pendingWrite);
pendingWrite = null;
}
// Wait for any in-progress write to finish
if (activeWrite) {
await activeWrite;
}
await db.write();
}
@@ -1,5 +1,13 @@
import type { OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
import { EndAction, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
import {
EndAction,
OntimeGroup,
OntimeDelay,
OntimeEvent,
OntimeMilestone,
SupportedEntry,
TimeStrategy,
TimerType,
} from 'ontime-types';
export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
type: SupportedEntry.Event,
@@ -1,6 +1,4 @@
import { makeString } from 'ontime-utils';
import { isEmptyObject, removeUndefined } from '../parserUtils.js';
import { isEmptyObject, makeString, removeUndefined } from '../parserUtils.js';
describe('isEmptyObject()', () => {
test('finds an empty object', () => {
+12
View File
@@ -1,5 +1,17 @@
export type ErrorEmitter = (message: string) => void;
/**
* @description Ensures variable is string, it skips object types
* @param val - variable to convert
* @param {string} [fallback=''] - fallback value
* @returns {string} - value as string or fallback if not possible
*/
export const makeString = (val: unknown, fallback = ''): string => {
if (typeof val === 'string') return val.trim();
else if (val == null || val.constructor === Object) return fallback;
return val.toString().trim();
};
/**
* @description Verifies if object is empty
* @param {object} obj
+94
View File
@@ -0,0 +1,94 @@
> ontime@4.3.1 dev /app
> turbo run dev
Attention:
Turborepo now collects completely anonymous telemetry regarding usage.
This information is used to shape the Turborepo roadmap and prioritize features.
You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL:
https://turborepo.com/docs/telemetry
turbo 2.5.8
• Packages in scope: @getontime/cli, @getontime/resolver, ontime-electron, ontime-server, ontime-types, ontime-ui, ontime-utils
• Running dev in 7 packages
• Remote caching disabled
ontime-server:dev: cache bypass, force executing 736e67d68a6b5681
ontime-ui:dev: cache bypass, force executing 9eacdb30152cd80e
ontime-ui:dev:
ontime-ui:dev: > ontime-ui@4.3.1 dev /app/apps/client
ontime-ui:dev: > cross-env BROWSER=none vite
ontime-ui:dev:
ontime-server:dev:
ontime-server:dev: > ontime-server@4.3.1 dev /app/apps/server
ontime-server:dev: > cross-env NODE_ENV=development tsx watch ./src/index.ts
ontime-server:dev:
ontime-ui:dev:
ontime-ui:dev: VITE v6.3.1 ready in 748 ms
ontime-ui:dev:
ontime-ui:dev: ➜ Local: http://localhost:3000/
ontime-ui:dev: ➜ Network: use --host to expose
ontime-server:dev:
ontime-server:dev:
ontime-server:dev: Starting Ontime version 4.3.1
ontime-server:dev: Ontime running in development environment
ontime-server:dev: Ontime source directory at /app/apps/server/src/
ontime-server:dev: Ontime public directory at /home/jules/.Ontime
ontime-server:dev:
ontime-server:dev:
ontime-server:dev: Request: Initialise assets...
ontime-server:dev: [INFO] SERVER Loaded project demo project.json
ontime-server:dev: [INFO] SERVER Switch to rundown: default
ontime-server:dev: [INFO] SERVER Initialised Ontime with demo project.json
ontime-server:dev:
ontime-server:dev:
ontime-server:dev: Request: Start server...
ontime-server:dev: [INFO] SERVER Runtime service started
ontime-server:dev: Local: http://localhost:4001/editor
ontime-server:dev: Network: http://192.168.0.2:4001/editor
ontime-server:dev: [INFO] SERVER Ontime is listening on port 4001
ontime-server:dev:
ontime-server:dev:
ontime-server:dev: Request: Start integrations...
ontime-server:dev: [INFO] SERVER Skipping OSC integration
ontime-ui:dev: Browserslist: browsers data (caniuse-lite) is 9 months old. Please run:
ontime-ui:dev: npx update-browserslist-db@latest
ontime-ui:dev: Why you should do it regularly: https://github.com/browserslist/update-db#readme
ontime-ui:dev: Browserslist: caniuse-lite is outdated. Please run:
ontime-ui:dev: npx update-browserslist-db@latest
ontime-ui:dev: Why you should do it regularly: https://github.com/browserslist/update-db#readme
ontime-server:dev: [INFO] CLIENT 1 Connections with new: low-fi instrument
ontime-ui:dev: Proxy: GET /data/settings
ontime-ui:dev: Proxy: GET /user/translations/translations.json
ontime-ui:dev: Proxy: GET /data/report
ontime-ui:dev: Proxy: GET /data/view-settings
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-ui:dev: Proxy: GET /data/project
ontime-ui:dev: Proxy: GET /data/db/all
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-ui:dev: Proxy: GET /data/project
ontime-ui:dev: Proxy: GET /data/db/all
ontime-server:dev: [INFO] CLIENT 0 Connections with disconnected: low-fi instrument
ontime-server:dev: [INFO] CLIENT 1 Connections with new: mellow uplight
ontime-ui:dev: Proxy: GET /data/settings
ontime-ui:dev: Proxy: GET /user/translations/translations.json
ontime-ui:dev: Proxy: GET /data/report
ontime-ui:dev: Proxy: GET /data/view-settings
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-ui:dev: Proxy: GET /data/project
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-ui:dev: Proxy: GET /data/project
ontime-server:dev: [INFO] CLIENT 0 Connections with disconnected: mellow uplight
ontime-server:dev: [INFO] CLIENT 1 Connections with new: nostalgic mix
ontime-ui:dev: Proxy: GET /data/settings
ontime-ui:dev: Proxy: GET /user/translations/translations.json
ontime-ui:dev: Proxy: GET /data/report
ontime-ui:dev: Proxy: GET /data/view-settings
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-ui:dev: Proxy: GET /data/project
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-ui:dev: Proxy: GET /data/project
ontime-ui:dev: Proxy: GET /data/custom-fields
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-server:dev: [INFO] CLIENT 0 Connections with disconnected: nostalgic mix
+6 -53
View File
@@ -1,57 +1,10 @@
import { expect, test } from '@playwright/test';
test('cuesheet displays events', async ({ page }) => {
await page.goto('/cuesheet');
await expect(page.getByTestId('cuesheet')).toBeVisible();
await expect(page.getByTestId('cuesheet-event').first()).toBeVisible();
});
test('cuesheet datagrid keeps keyboard focus flow while editing text cells', async ({ page }) => {
await page.goto('/cuesheet');
const firstEvent = page.getByTestId('cuesheet-event').first();
await expect(firstEvent).toBeVisible();
const cueEditor = firstEvent.getByTestId('cuesheet-editor-cue');
const titleEditor = firstEvent.getByTestId('cuesheet-editor-title');
const noteEditor = firstEvent.getByTestId('cuesheet-editor-note');
/**
* 1. focus a cell in the datagrid single line text
* submitting the data returns the focus to the parent
*/
await titleEditor.click();
await expect(titleEditor).toBeFocused();
const updatedTitle = `focus-title-${Date.now()}`;
await titleEditor.fill(updatedTitle);
await titleEditor.press('Enter');
await expect(titleEditor).not.toBeFocused();
await expect(titleEditor).toHaveValue(updatedTitle);
/**
* 2. navigate and modify multiline text cell
* submitting works with ctrl/cmd + enter and the focus returns to the parent
*/
await page.keyboard.press('ArrowRight');
await page.keyboard.press('Enter');
await expect(noteEditor).toBeFocused();
const updatedNote = `focus-note-${Date.now()}`;
await noteEditor.fill(updatedNote);
await noteEditor.press('ControlOrMeta+Enter');
await expect(noteEditor).not.toBeFocused();
await expect(noteEditor).toHaveValue(updatedNote);
/**
* 2. navigate and modify single line text cell again
* pressing escape cancels the edit and the focus returns to the parent
*/
await page.keyboard.press('ArrowLeft');
await page.keyboard.press('Enter');
await expect(titleEditor).toBeFocused();
const cueBeforeCancel = await cueEditor.inputValue();
await cueEditor.click();
await cueEditor.fill(`${cueBeforeCancel} temporary`);
await cueEditor.press('Escape');
await expect(cueEditor).not.toBeFocused();
await expect(cueEditor).toHaveValue(cueBeforeCancel);
// same elements in cuesheet
await page.goto('http://localhost:4001/cuesheet');
await expect(page.getByRole('row', { name: 'Lunch break' })).toBeVisible();
await expect(page.getByRole('row', { name: 'Afternoon break' })).toBeVisible();
await expect(page.locator('#cuesheet')).toBeVisible();
});
+1 -1
View File
@@ -28,7 +28,7 @@ test('CRUD operations on the rundown', async ({ page }) => {
// test quick add options - star2+5-t is last end
await page.getByTestId('entry-2').getByTestId('time-input-duration').fill('20m');
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await expect(page.getByTestId('entry-3').getByTestId('time-input-timeStart')).toHaveValue('00:30:00');
expect(await page.getByTestId('entry-3').getByTestId('time-input-timeStart').inputValue()).toContain('00:30:00');
await expect(page.getByTestId('rundown-event')).toHaveCount(3);
await expect(page.getByTestId('rundown-delay')).toHaveCount(1);
await expect(page.getByTestId('rundown-group')).toHaveCount(1);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "4.4.2",
"version": "4.3.1",
"description": "Time keeping for live events",
"keywords": [
"ontime",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "4.4.2",
"version": "4.3.1",
"name": "ontime-types",
"type": "module",
"main": "./src/index.ts",
+4 -9
View File
@@ -8,34 +8,29 @@ export { sanitiseCue } from './src/cue-utils/cueUtils.js';
export { getCueCandidate } from './src/cue-utils/cueUtils.js';
export { generateId } from './src/generate-id/generateId.js';
export {
addToRundown,
getEventWithId,
getFirstEvent,
getFirstEventNormal,
getFirstNormal,
getFirstGroupNormal,
getInsertAfterId,
getLastEvent,
getLastEventNormal,
getLastNormal,
getLastGroupNormal,
getNext,
getNextGroupNormal,
getNextEvent,
getNextEventNormal,
getNextNormal,
getPrevious,
getPreviousEvent,
getPreviousEventNormal,
getPreviousNormal,
getPreviousGroup,
getPreviousGroupNormal,
resolveInsertParent,
swapEventData,
} from './src/rundown-utils/rundownUtils.js';
export { getFirstRundown } from './src/rundown/rundown.utils.js';
export {
event as eventDef,
group as groupDef,
milestone as milestoneDef,
} from './src/rundown-utils/entryDefinitions.js';
export { createDelay, createEvent, createGroup, createMilestone, makeString } from './src/rundown-utils/entryUtils.js';
// time format utils
export {
@@ -1,155 +0,0 @@
import type { OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
import { SupportedEntry, TimeStrategy } from 'ontime-types';
import { generateId } from '../generate-id/generateId.js';
import { validateEndAction, validateTimerType } from '../validate-events/validateEvent.js';
import { validateTimes } from '../validate-times/validateTimes.js';
import { event as eventDef, group as groupDef, milestone as milestoneDef } from './entryDefinitions.js';
/**
* @description Ensures variable is string, it skips object types
* @param val - variable to convert
* @param {string} [fallback=''] - fallback value
* @returns {string} - value as string or fallback if not possible
*/
export const makeString = (val: unknown, fallback = ''): string => {
if (typeof val === 'string') return val.trim();
else if (val == null || val.constructor === Object) return fallback;
return val.toString().trim();
};
/**
* Creates a new delay from an optional patch
*/
export function createDelay(patch?: Partial<OntimeDelay>): OntimeDelay {
return {
type: SupportedEntry.Delay,
id: patch?.id ?? generateId(),
duration: patch?.duration ?? 0,
parent: patch?.parent ?? null,
};
}
/**
* @description Enforces formatting for events
* @param {object} eventArgs - attributes of event
* @param {number} eventIndex - can be a string when we pass the a suggested cue name
* @returns {object|null} - formatted object or null in case is invalid
*/
export const createEvent = (eventArgs: Partial<OntimeEvent>, eventIndex: number | string = ''): OntimeEvent | null => {
if (Object.keys(eventArgs).length === 0) {
return null;
}
const cue = typeof eventIndex === 'number' ? String(eventIndex + 1) : eventIndex;
const baseEvent = {
id: eventArgs?.id ?? generateId(),
cue,
...eventDef,
};
const event = createEventPatch(baseEvent, eventArgs);
return event;
};
/**
* Creates a new group from an optional patch
*/
export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
if (!patch) {
return { ...groupDef, id: generateId() };
}
return {
id: patch.id ?? generateId(),
type: SupportedEntry.Group,
title: patch.title ?? '',
note: patch.note ?? '',
entries: patch.entries ?? [],
targetDuration: patch.targetDuration ?? null,
colour: makeString(patch.colour, ''),
custom: patch.custom ?? {},
revision: 0,
timeStart: null,
timeEnd: null,
duration: 0,
isFirstLinked: false,
};
}
/**
* Creates a new milestone from an optional patch
*/
export function createMilestone(patch?: Partial<OntimeMilestone>): OntimeMilestone {
if (!patch) {
return { ...milestoneDef, id: generateId() };
}
return {
id: patch.id ?? generateId(),
type: SupportedEntry.Milestone,
cue: patch.cue ?? '',
title: patch.title ?? '',
note: patch.note ?? '',
colour: makeString(patch.colour, ''),
custom: patch.custom ?? {},
parent: patch.parent ?? null,
revision: 0,
};
}
/**
* Function infers strategy for a patch with only partial timer data
*/
function inferStrategy(end: unknown, duration: unknown, fallback: TimeStrategy): TimeStrategy {
if (end && !duration) {
return TimeStrategy.LockEnd;
}
if (!end && duration) {
return TimeStrategy.LockDuration;
}
return fallback;
}
function createEventPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
if (Object.keys(patchEvent).length === 0) {
return originalEvent;
}
const { timeStart, timeEnd, duration, timeStrategy } = validateTimes(
patchEvent?.timeStart ?? originalEvent.timeStart,
patchEvent?.timeEnd ?? originalEvent.timeEnd,
patchEvent?.duration ?? originalEvent.duration,
patchEvent?.timeStrategy ?? inferStrategy(patchEvent?.timeEnd, patchEvent?.duration, originalEvent.timeStrategy),
);
return {
id: originalEvent.id,
type: SupportedEntry.Event,
flag: typeof patchEvent.flag === 'boolean' ? patchEvent.flag : originalEvent.flag,
title: makeString(patchEvent.title, originalEvent.title),
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart: typeof patchEvent.linkStart === 'boolean' ? patchEvent.linkStart : originalEvent.linkStart,
endAction: validateEndAction(patchEvent.endAction, originalEvent.endAction),
timerType: validateTimerType(patchEvent.timerType, originalEvent.timerType),
countToEnd: typeof patchEvent.countToEnd === 'boolean' ? patchEvent.countToEnd : originalEvent.countToEnd,
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
note: makeString(patchEvent.note, originalEvent.note),
colour: makeString(patchEvent.colour, originalEvent.colour),
delay: originalEvent.delay, // is regenerated if timer related data is changed
dayOffset: originalEvent.dayOffset, // is regenerated if timer related data is changed
gap: originalEvent.gap, // is regenerated if timer related data is changed
// short circuit empty string
cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
parent: originalEvent.parent,
revision: originalEvent.revision,
timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning,
timeDanger: patchEvent.timeDanger ?? originalEvent.timeDanger,
custom: { ...originalEvent.custom, ...patchEvent.custom },
triggers: patchEvent.triggers ?? originalEvent.triggers,
};
}
@@ -1,23 +1,71 @@
import type { OntimeDelay, OntimeEntry, OntimeEvent, OntimeGroup, Rundown } from 'ontime-types';
import type { OntimeDelay, OntimeEntry, OntimeEvent, OntimeGroup } from 'ontime-types';
import { SupportedEntry } from 'ontime-types';
import {
addToRundown,
getFirstGroupNormal,
getInsertAfterId,
getLastEvent,
getLastGroupNormal,
getLastNormal,
getNext,
getNextEvent,
getNextGroupNormal,
getNextNormal,
getPrevious,
getPreviousEvent,
getPreviousGroup,
getPreviousGroupNormal,
getPreviousNormal,
resolveInsertParent,
swapEventData,
} from './rundownUtils';
import { demoDb } from './rundownUtils.mock';
describe('getNext()', () => {
it('returns the next event of type event', () => {
const testRundown = {
entries: {
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
'3': { id: '3', type: SupportedEntry.Event } as OntimeEvent,
},
order: ['1', '2', '3'],
};
const { nextEvent, nextIndex } = getNext(testRundown, '1');
expect(nextEvent?.id).toBe('2');
expect(nextIndex).toBe(1);
});
it('returns any type of OntimeEntry ', () => {
const testRundown = {
entries: {
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
'2': { id: '2', type: SupportedEntry.Delay } as OntimeDelay,
'3': { id: '3', type: SupportedEntry.Group } as OntimeGroup,
'4': { id: '4', type: SupportedEntry.Event } as OntimeEvent,
},
order: ['1', '2', '3', '4'],
};
const { nextEvent, nextIndex } = getNext(testRundown, '1');
expect(nextEvent?.id).toBe('2');
expect(nextIndex).toBe(1);
});
it('returns null if none found', () => {
const testRundown = {
entries: {
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
'3': { id: '3', type: SupportedEntry.Event } as OntimeEvent,
},
order: ['1', '2', '3'],
};
const { nextEvent, nextIndex } = getNext(testRundown, '3');
expect(nextEvent).toBe(null);
expect(nextIndex).toBe(null);
});
});
describe('getNextEvent()', () => {
it('returns the next event of type event', () => {
const testRundown = [
@@ -57,6 +105,101 @@ describe('getNextEvent()', () => {
});
});
describe('getPrevious()', () => {
it('returns the previous event of type event', () => {
const testRundown = {
entries: {
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
'3': { id: '3', type: SupportedEntry.Event } as OntimeEvent,
},
order: ['1', '2', '3'],
};
const { entry, index } = getPrevious(testRundown, '3');
expect(entry?.id).toBe('2');
expect(index).toBe(1);
});
it('allow other event types', () => {
const testRundown = {
entries: {
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
'2': { id: '2', type: SupportedEntry.Delay } as OntimeDelay,
'3': { id: '3', type: SupportedEntry.Group } as OntimeGroup,
'4': { id: '4', type: SupportedEntry.Event } as OntimeEvent,
},
order: ['1', '2', '3', '4'],
};
const { entry, index } = getPrevious(testRundown, '3');
expect(entry?.id).toBe('2');
expect(index).toBe(1);
});
it('returns null if none found', () => {
const testRundown = {
entries: {
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
'3': { id: '3', type: SupportedEntry.Event } as OntimeEvent,
},
order: ['1', '2', '3'],
};
const { entry, index } = getPrevious(testRundown, '2');
expect(entry).toBe(null);
expect(index).toBe(null);
});
});
describe('getPreviousEvent()', () => {
it('returns the previous event of type event', () => {
const testRundown = {
entries: {
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
'3': { id: '3', type: SupportedEntry.Event } as OntimeEvent,
},
order: ['1', '2', '3'],
};
const { previousEvent, previousIndex } = getPreviousEvent(testRundown, '3');
expect(previousEvent?.id).toBe('2');
expect(previousIndex).toBe(1);
});
it('ignores other event types', () => {
const testRundown = {
entries: {
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
'2': { id: '2', type: SupportedEntry.Delay } as OntimeDelay,
'3': { id: '3', type: SupportedEntry.Group } as OntimeGroup,
'4': { id: '4', type: SupportedEntry.Event } as OntimeEvent,
},
order: ['1', '2', '3', '4'],
};
const { previousEvent, previousIndex } = getPreviousEvent(testRundown, '4');
expect(previousEvent?.id).toBe('1');
expect(previousIndex).toBe(0);
});
it('returns null if none found', () => {
const testRundown = {
entries: {
'2': { id: '2', type: SupportedEntry.Delay } as OntimeDelay,
'3': { id: '3', type: SupportedEntry.Group } as OntimeGroup,
'4': { id: '4', type: SupportedEntry.Event } as OntimeEvent,
},
order: ['2', '3', '4'],
};
const { previousEvent, previousIndex } = getPreviousEvent(testRundown, '2');
expect(previousEvent).toBe(null);
expect(previousIndex).toBe(null);
});
});
describe('swapEventData', () => {
it('swaps some data between two events', () => {
const eventA = {
@@ -229,163 +372,56 @@ describe('getLastEvent', () => {
expect(lastEntry).toBe(null);
});
});
});
describe('getInsertAfterId()', () => {
const rundown = {
id: 'test',
title: 'test',
entries: {
'1': { id: '1', type: SupportedEntry.Event, parent: null } as OntimeEvent,
'2': { id: '2', type: SupportedEntry.Event, parent: null } as OntimeEvent,
group: { id: 'group', type: SupportedEntry.Group, entries: ['31', '32'] } as unknown as OntimeGroup,
'31': { id: '31', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent,
'32': { id: '32', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent,
'4': { id: '4', type: SupportedEntry.Event, parent: null } as OntimeEvent,
},
order: ['1', '2', 'group', '4'],
flatOrder: ['1', '2', 'group', '31', '32', '4'],
revision: 1,
} as Rundown;
it('returns afterId if provided', () => {
expect(getInsertAfterId(rundown, null, 'b')).toBe('b');
});
it('returns null if neither afterId nor beforeId is provided', () => {
expect(getInsertAfterId(rundown, null)).toBeNull();
});
it('returns null if beforeId is not found', () => {
expect(getInsertAfterId(rundown, null, undefined, 'z')).toBeNull();
expect(getInsertAfterId(rundown, null, undefined, '1')).toBeNull();
});
it('returns the previous id of an entry in the rundown', () => {
expect(getInsertAfterId(rundown, null, undefined, '2')).toBe('1');
expect(getInsertAfterId(rundown, null, undefined, '4')).toBe('group');
expect(getInsertAfterId(rundown, null, undefined, 'group')).toBe('2');
});
it('returns the previous id of an event in a group', () => {
expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '31')).toBeNull();
expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '32')).toBe('31');
});
});
describe('resolveInsertParent()', () => {
const rundown = {
id: 'test',
title: 'test',
entries: {
top: { id: 'top', type: SupportedEntry.Event, parent: null } as OntimeEvent,
group: { id: 'group', type: SupportedEntry.Group, entries: ['31', '32'] } as unknown as OntimeGroup,
'31': { id: '31', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent,
'32': { id: '32', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent,
},
order: ['top', 'group'],
flatOrder: ['top', 'group', '31', '32'],
revision: 1,
} as Rundown;
it('returns explicit parent id', () => {
expect(resolveInsertParent(rundown, { parent: 'group' })).toBe('group');
});
it('returns non-existent explicit parent id as-is', () => {
expect(resolveInsertParent(rundown, { parent: 'missing' })).toBe('missing');
});
it('infers parent id from sibling when parent is omitted', () => {
expect(resolveInsertParent(rundown, { after: '31' })).toBe('group');
expect(resolveInsertParent(rundown, { before: '32' })).toBe('group');
});
it('returns null when no grouped sibling reference exists', () => {
expect(resolveInsertParent(rundown, { after: 'top' })).toBeNull();
});
it('returns null when no references are provided', () => {
expect(resolveInsertParent(rundown, {})).toBeNull();
});
});
describe('addToRundown()', () => {
const makeTestRundown = (): Rundown =>
({
id: 'test',
title: 'test',
describe('getPreviousGroup()', () => {
const testRundown = {
entries: {
'1': { id: '1', type: SupportedEntry.Event, parent: null } as OntimeEvent,
'2': { id: '2', type: SupportedEntry.Event, parent: null } as OntimeEvent,
group: { id: 'group', type: SupportedEntry.Group, entries: ['31', '32'] } as unknown as OntimeGroup,
'31': { id: '31', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent,
'32': { id: '32', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent,
a: { id: 'a', type: SupportedEntry.Event } as OntimeEvent,
b: { id: 'b', type: SupportedEntry.Event } as OntimeEvent,
c: { id: 'c', type: SupportedEntry.Event } as OntimeEvent,
d: { id: 'd', type: SupportedEntry.Delay } as OntimeDelay,
e: { id: 'e', type: SupportedEntry.Group } as OntimeGroup,
f: { id: 'f', type: SupportedEntry.Event } as OntimeEvent,
g: { id: 'g', type: SupportedEntry.Group } as OntimeGroup,
h: { id: 'h', type: SupportedEntry.Event } as OntimeEvent,
},
order: ['1', '2', 'group'],
flatOrder: ['1', '2', 'group', '31', '32'],
revision: 1,
}) as Rundown;
order: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'],
};
it('adds an entry to an empty rundown', () => {
const rundown = { id: 'test', title: '', entries: {}, order: [], flatOrder: [], revision: 0 } as Rundown;
const newEntry = { id: 'new', type: SupportedEntry.Event } as OntimeEvent;
test.each([
['h', 'g'],
['f', 'e'],
])('returns the relevant group', (id, expected) => {
const group = getPreviousGroup(testRundown, id);
expect(group?.id).toBe(expected);
});
addToRundown(rundown, newEntry, null, null);
it('returns null if there is no parent group relevant group', () => {
const group = getPreviousGroup(testRundown, 'a');
expect(group).toBe(null);
});
expect(rundown.order).toEqual(['new']);
expect(rundown.flatOrder).toEqual(['new']);
expect(rundown.entries['new']).toBe(newEntry);
});
it('also works on index 0', () => {
testRundown.order.unshift('0');
// @ts-expect-error -- we are adding an event to the rundown
testRundown.entries['0'] = { id: '0', type: SupportedEntry.Group } as OntimeGroup;
const group = getPreviousGroup(testRundown, 'a');
expect(group?.id).toBe('0');
});
// case 2b: insert at the beginning of the rundown
it('adds at the beginning of order and flatOrder when afterId is null', () => {
const rundown = makeTestRundown();
const newEntry = { id: 'new', type: SupportedEntry.Event } as OntimeEvent;
addToRundown(rundown, newEntry, null, null);
expect(rundown.order).toEqual(['new', '1', '2', 'group']);
expect(rundown.flatOrder).toEqual(['new', '1', '2', 'group', '31', '32']);
});
// case 2a: insert after a given entry at top level
it('inserts after the referenced entry in both order and flatOrder', () => {
const rundown = makeTestRundown();
const newEntry = { id: 'new', type: SupportedEntry.Event } as OntimeEvent;
addToRundown(rundown, newEntry, '1', null);
expect(rundown.order).toEqual(['1', 'new', '2', 'group']);
expect(rundown.flatOrder).toEqual(['1', 'new', '2', 'group', '31', '32']);
});
// case 1b: insert at the beginning of a group
it('inserts right after the group header in flatOrder and sets parent', () => {
const rundown = makeTestRundown();
const parent = rundown.entries['group'] as OntimeGroup;
const newEntry = { id: 'new', type: SupportedEntry.Event, parent: null } as OntimeEvent;
addToRundown(rundown, newEntry, null, parent);
expect(parent.entries).toEqual(['new', '31', '32']);
expect(newEntry.parent).toBe('group');
expect(rundown.flatOrder).toEqual(['1', '2', 'group', 'new', '31', '32']);
// top-level order must not change when inserting into a group
expect(rundown.order).toEqual(['1', '2', 'group']);
});
// case 1a: insert after a given entry within a group
it('inserts after the referenced entry within the group and in flatOrder', () => {
const rundown = makeTestRundown();
const parent = rundown.entries['group'] as OntimeGroup;
const newEntry = { id: 'new', type: SupportedEntry.Event, parent: null } as OntimeEvent;
addToRundown(rundown, newEntry, '31', parent);
expect(parent.entries).toEqual(['31', 'new', '32']);
expect(newEntry.parent).toBe('group');
expect(rundown.flatOrder).toEqual(['1', '2', 'group', '31', 'new', '32']);
expect(rundown.order).toEqual(['1', '2', 'group']);
it('returns the parent group if nested event', () => {
const testRundown = {
entries: {
1: { id: '1', type: SupportedEntry.Event } as OntimeEvent,
group: { id: 'group', type: SupportedEntry.Group, entries: ['21', '22', '23'] } as OntimeGroup,
21: { id: '21', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent,
22: { id: '22', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent,
23: { id: '23', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent,
},
order: ['1', 'group'],
};
const group = getPreviousGroup(testRundown, '21');
expect(group?.id).toBe('group');
});
});
});
@@ -9,8 +9,6 @@ import type {
} from 'ontime-types';
import { isOntimeEvent, isOntimeGroup, isPlayableEvent } from 'ontime-types';
import { insertAtIndex } from '../common/arrayUtils.js';
type IndexAndEntry = { entry: OntimeEntry | null; index: number | null };
type GroupIndexAndEntry = { entry: OntimeGroup | null; index: number | null };
@@ -116,6 +114,24 @@ export function getLastEventNormal(
return { lastEvent: null, lastIndex: null };
}
/**
* Gets next entry in rundown, if it exists
*/
export function getNext(
rundown: Pick<Rundown, 'entries' | 'order'>,
currentId: EntryId,
): { nextEvent: OntimeEntry | null; nextIndex: number | null } {
const index = rundown.order.findIndex((entryId) => entryId === currentId);
if (index !== -1 && index + 1 < rundown.order.length) {
const nextIndex = index + 1;
const nextId = rundown.order[nextIndex];
const nextEvent = rundown.entries[nextId];
return { nextEvent, nextIndex };
} else {
return { nextEvent: null, nextIndex: null };
}
}
/**
* Gets next entry in rundown, if it exists
*/
@@ -179,6 +195,22 @@ export function getNextEventNormal(
return { nextEvent: null, nextIndex: null };
}
/**
* Gets previous entry in rundown, if it exists
*/
export function getPrevious(rundown: Pick<Rundown, 'entries' | 'order'>, currentId: EntryId): IndexAndEntry {
const currentIndex = rundown.order.findIndex((entryId) => entryId === currentId);
if (currentIndex > 1) {
const index = currentIndex - 1;
const previousId = rundown.order[index];
const entry = rundown.entries[previousId];
return { entry, index };
} else {
return { entry: null, index: null };
}
}
/**
* Gets previous entry in a normalised rundown, if it exists
*/
@@ -230,6 +262,27 @@ export function getLastGroupNormal(entries: RundownEntries, flatOrder: EntryId[]
return { entry: null, index: null };
}
/**
* Gets previous scheduled event in rundown, if it exists
*/
export function getPreviousEvent(
rundown: Pick<Rundown, 'entries' | 'order'>,
currentId: EntryId,
): { previousEvent: OntimeEvent | null; previousIndex: number | null } {
const index = rundown.order.findIndex((entryId) => entryId === currentId);
if (index < 0) {
return { previousEvent: null, previousIndex: null };
}
for (let i = index - 1; i >= 0; i--) {
const previousId = rundown.order[i];
const previousEvent = rundown.entries[previousId];
if (isOntimeEvent(previousEvent)) {
return { previousEvent, previousIndex: i };
}
}
return { previousEvent: null, previousIndex: null };
}
/**
* Gets previous scheduled event in a normalised rundown, if it exists
*/
@@ -362,102 +415,31 @@ export function getNextGroupNormal(
}
/**
* Receives an insertion order and returns the reference to an entry ID
* after which we will insert the new entry
* Gets relevant group element for a given ID
*/
export function getInsertAfterId(
rundown: Rundown,
parent: OntimeGroup | null,
afterId?: EntryId,
beforeId?: EntryId,
): EntryId | null {
if (afterId) return afterId;
if (!beforeId) return null;
export function getPreviousGroup(rundown: Pick<Rundown, 'entries' | 'order'>, currentId: EntryId): OntimeGroup | null {
const currentEvent = rundown.entries[currentId];
const insertionList = parent ? parent.entries : rundown.order;
if (!insertionList || insertionList.length === 0) return null;
const atIndex = insertionList.findIndex((id) => id === beforeId);
if (atIndex < 1) return null;
return insertionList[atIndex - 1];
}
type ResolveInsertParentOptions = {
parent?: EntryId | null;
after?: EntryId;
before?: EntryId;
};
/**
* Resolves the parent ID for an insertion.
* Uses explicit parent first, then infers from sibling references.
*/
export function resolveInsertParent(rundown: Rundown, options: ResolveInsertParentOptions): EntryId | null {
if (options.parent) {
return options.parent;
// check if entry is inside a group
if ('parent' in currentEvent && currentEvent.parent) {
return rundown.entries[currentEvent.parent] as OntimeGroup;
}
const referenceId = options.after ?? options.before;
if (!referenceId) return null;
const maybeSibling = rundown.entries[referenceId];
if (maybeSibling && 'parent' in maybeSibling && maybeSibling.parent) {
return maybeSibling.parent;
let foundCurrentEvent = false;
// Iterate backwards through the rundown to find the current event
for (let i = rundown.order.length - 1; i >= 0; i--) {
const entryId = rundown.order[i];
const entry = rundown.entries[entryId];
if (!foundCurrentEvent && entry.id === currentId) {
// set the flag when the current event is found
foundCurrentEvent = true;
continue;
}
// the first group before the current event is the relevant one
if (foundCurrentEvent && isOntimeGroup(entry)) {
return entry;
}
}
// no groups exist before null event
return null;
}
/**
* Add entry to rundown, mutates the rundown in place.
* Handles the following cases:
* - 1a. add entry in group, after a given entry
* - 1b. add entry in group, at the beginning (right after the group header)
* - 2a. add entry to the rundown, after a given entry
* - 2b. add entry to the rundown, at the beginning
*/
export function addToRundown(
rundown: Rundown,
entry: OntimeEntry,
afterId: EntryId | null,
parent: OntimeGroup | null,
): OntimeEntry {
if (parent) {
// 1. inserting an entry inside a group
// assign the parent reference on the entry
if ('parent' in entry) {
entry.parent = parent.id;
}
if (afterId) {
// 1a. insert after a given entry within the group
const atEventsIndex = parent.entries.indexOf(afterId) + 1;
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
parent.entries = insertAtIndex(atEventsIndex, entry.id, parent.entries);
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
} else {
// 1b. insert at the beginning of the group (right after the group header in flatOrder)
parent.entries = insertAtIndex(0, entry.id, parent.entries);
const atFlatIndex = rundown.flatOrder.indexOf(parent.id) + 1;
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
}
} else {
// 2. inserting an entry at top level
if (afterId) {
// 2a. insert after a given entry
const atOrderIndex = rundown.order.indexOf(afterId) + 1;
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
rundown.order = insertAtIndex(atOrderIndex, entry.id, rundown.order);
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
} else {
// 2b. insert at the beginning
rundown.order = insertAtIndex(0, entry.id, rundown.order);
rundown.flatOrder = insertAtIndex(0, entry.id, rundown.flatOrder);
}
}
// either way, we register the entry in the entries map
rundown.entries[entry.id] = entry;
return entry;
}