mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-10 00:29:41 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99a2f58502 | |||
| 535717fa90 | |||
| 477777552c | |||
| df2d083a16 | |||
| 079a2ea96d | |||
| c50c84f50b | |||
| 58dce0a18c | |||
| b2f9b812bd | |||
| 35d2474797 | |||
| b9f5a0a82b | |||
| 26a58a9241 | |||
| 588b04c46b | |||
| 5cf36f049a | |||
| c6248b0c72 | |||
| 2a890cf2b3 | |||
| a8c611911d | |||
| 8363f06a5c | |||
| 5f040092cb | |||
| 347c748dd9 | |||
| f234a1f892 | |||
| 0c5e87b7e7 | |||
| 64082ceac0 | |||
| f598e8dab1 | |||
| 0c84a3ff9e | |||
| c8760b5e9c | |||
| 34360a5b8f | |||
| 5e6debcce0 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ProjectRundownsList,
|
ProjectRundownsList,
|
||||||
RenumberCues,
|
RenumberCues,
|
||||||
Rundown,
|
Rundown,
|
||||||
|
RundownImportPayload,
|
||||||
TransientEventPayload,
|
TransientEventPayload,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
|
|
||||||
@@ -82,6 +83,15 @@ export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse
|
|||||||
return axios.delete(`${rundownPath}/${rundownId}`);
|
return axios.delete(`${rundownPath}/${rundownId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to apply an imported rundown using a merge strategy or into a new rundown
|
||||||
|
*/
|
||||||
|
export async function importRundownWithOptions(
|
||||||
|
payload: RundownImportPayload,
|
||||||
|
): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||||
|
return axios.post(`${rundownPath}/import`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
// #endregion operations on project rundowns ======================
|
// #endregion operations on project rundowns ======================
|
||||||
// #region operations on rundown entries ==========================
|
// #region operations on rundown entries ==========================
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
.wide {
|
.wide {
|
||||||
top: 4vh;
|
top: 4vh;
|
||||||
min-width: min(1280px, 96vw);
|
min-width: min(1280px, 96vw);
|
||||||
max-width: min(1600px, 96vw);
|
max-width: min(1800px, 98vw);
|
||||||
height: 88vh;
|
height: 88vh;
|
||||||
max-height: 88vh;
|
max-height: 88vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { PropsWithChildren, createContext, useContext } from 'react';
|
import { PropsWithChildren, createContext, useContext } from 'react';
|
||||||
|
|
||||||
import { useEntryActions } from '../hooks/useEntryAction';
|
import { useEntryActions, useScopedEntryActions } from '../hooks/useEntryAction';
|
||||||
|
import { useRundownSelectionContext } from './RundownSelectionContext';
|
||||||
|
|
||||||
type EntryActionsContextValue = ReturnType<typeof useEntryActions>;
|
type EntryActionsContextValue = ReturnType<typeof useEntryActions>;
|
||||||
const EntryActionsContext = createContext<EntryActionsContextValue | null>(null);
|
const EntryActionsContext = createContext<EntryActionsContextValue | null>(null);
|
||||||
|
|
||||||
interface EntryActionsProviderProps extends PropsWithChildren {
|
export function EntryActionsProvider({ children }: PropsWithChildren) {
|
||||||
actions: EntryActionsContextValue;
|
const { effectiveRundownId } = useRundownSelectionContext();
|
||||||
}
|
const actions = useScopedEntryActions(effectiveRundownId);
|
||||||
|
|
||||||
export function EntryActionsProvider({ children, actions }: EntryActionsProviderProps) {
|
|
||||||
return <EntryActionsContext.Provider value={actions}>{children}</EntryActionsContext.Provider>;
|
return <EntryActionsContext.Provider value={actions}>{children}</EntryActionsContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { Maybe, ProjectRundown } from 'ontime-types';
|
||||||
|
import { PropsWithChildren, createContext, startTransition, useCallback, useContext, useEffect, useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
|
||||||
|
|
||||||
|
export type RundownScopeValue = {
|
||||||
|
loadedRundownId: string;
|
||||||
|
selectedRundownId: Maybe<string>;
|
||||||
|
isLoadedRundown: boolean;
|
||||||
|
effectiveRundownId: string;
|
||||||
|
selectRundownId: (val: Maybe<string>) => void;
|
||||||
|
rundowns: ProjectRundown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const RundownScopeContext = createContext<RundownScopeValue | null>(null);
|
||||||
|
|
||||||
|
export function RundownSelectionContextProvider({ children }: PropsWithChildren) {
|
||||||
|
'use memo';
|
||||||
|
const { data } = useProjectRundowns();
|
||||||
|
const { loaded, rundowns } = data;
|
||||||
|
|
||||||
|
const [selectedRundownId, setSelectedRundownId] = useSelectRundownFromParams();
|
||||||
|
|
||||||
|
const selectRundownId = useCallback(
|
||||||
|
(rundownId: Maybe<string>) => {
|
||||||
|
startTransition(() => {
|
||||||
|
if (rundowns.find((entry) => entry.id === rundownId)) setSelectedRundownId(rundownId);
|
||||||
|
else setSelectedRundownId(null);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[rundowns, setSelectedRundownId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const effectiveRundownId = selectedRundownId ? selectedRundownId : loaded;
|
||||||
|
const isLoadedRundown = effectiveRundownId === loaded;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rundowns.find((entry) => entry.id === effectiveRundownId)) setSelectedRundownId(null);
|
||||||
|
}, [rundowns, effectiveRundownId, setSelectedRundownId]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
(): RundownScopeValue => ({
|
||||||
|
loadedRundownId: loaded,
|
||||||
|
isLoadedRundown,
|
||||||
|
selectedRundownId,
|
||||||
|
effectiveRundownId,
|
||||||
|
selectRundownId,
|
||||||
|
rundowns,
|
||||||
|
}),
|
||||||
|
[loaded, isLoadedRundown, selectedRundownId, effectiveRundownId, selectRundownId, rundowns],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <RundownScopeContext.Provider value={value}>{children}</RundownScopeContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRundownSelectionContext() {
|
||||||
|
const context = useContext(RundownScopeContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useRundownScopeSelection requires a RundownSelectionContextProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rundownParam = 'rundownId';
|
||||||
|
|
||||||
|
export function useSelectRundownFromParams(): [Maybe<string>, (id: Maybe<string>) => void] {
|
||||||
|
'use memo';
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const selectedRundownId = searchParams.get(rundownParam);
|
||||||
|
const setSelectedRundownId = useCallback(
|
||||||
|
(id: Maybe<string>) => {
|
||||||
|
if (id === null) {
|
||||||
|
setSearchParams((searchParams) => {
|
||||||
|
searchParams.delete(rundownParam);
|
||||||
|
return searchParams;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setSearchParams((searchParams) => {
|
||||||
|
searchParams.set(rundownParam, id);
|
||||||
|
return searchParams;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [selectedRundownId, setSelectedRundownId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* mutates the provided `searchParams`
|
||||||
|
*/
|
||||||
|
export function setSelectRundownInParams(id: Maybe<string>, searchParams: URLSearchParams): void {
|
||||||
|
if (id === null) {
|
||||||
|
searchParams.delete(rundownParam);
|
||||||
|
} else {
|
||||||
|
searchParams.set(rundownParam, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDirectLinkToBackgroundEdit() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [search] = useSearchParams();
|
||||||
|
return useCallback(
|
||||||
|
async (rundownId: string) => {
|
||||||
|
setSelectRundownInParams(rundownId, search);
|
||||||
|
navigate({
|
||||||
|
pathname: '/cuesheet',
|
||||||
|
search: search.toString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[navigate, search],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useRundownSelectionContext } from '../context/RundownSelectionContext';
|
||||||
|
import { useSelectedEventId } from '../hooks/useSocket';
|
||||||
|
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
|
||||||
|
import { useFlatRundown, useRundown } from './useRundown';
|
||||||
|
|
||||||
|
export function useContextRundownEditModal() {
|
||||||
|
'use memo';
|
||||||
|
const { effectiveRundownId } = useRundownSelectionContext();
|
||||||
|
const { data: rundown } = useRundown(effectiveRundownId);
|
||||||
|
return { rundown };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useContextRundownCueRenumberModal() {
|
||||||
|
'use memo';
|
||||||
|
const { effectiveRundownId } = useRundownSelectionContext();
|
||||||
|
const { data } = useRundown(effectiveRundownId);
|
||||||
|
const { flatOrder } = data;
|
||||||
|
return { flatOrder };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useContextRundownList() {
|
||||||
|
'use memo';
|
||||||
|
const { effectiveRundownId, isLoadedRundown } = useRundownSelectionContext();
|
||||||
|
const loadedEventId = useSelectedEventId();
|
||||||
|
const effectiveSelectedEventId = isLoadedRundown ? loadedEventId : null;
|
||||||
|
const { data: rundown, status } = useRundown(effectiveRundownId);
|
||||||
|
const rundownMetadata = useMemo(
|
||||||
|
() => getRundownMetadata(rundown, effectiveSelectedEventId),
|
||||||
|
[effectiveSelectedEventId, rundown],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
rundown,
|
||||||
|
rundownMetadata,
|
||||||
|
status,
|
||||||
|
isLoadedRundown,
|
||||||
|
}),
|
||||||
|
[rundown, rundownMetadata, status, isLoadedRundown],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useContextRundownTitleList() {
|
||||||
|
'use memo';
|
||||||
|
const { effectiveRundownId, isLoadedRundown } = useRundownSelectionContext();
|
||||||
|
const loadedEventId = useSelectedEventId();
|
||||||
|
const effectiveSelectedEventId = isLoadedRundown ? loadedEventId : null;
|
||||||
|
const { data: rundown, status } = useRundown(effectiveRundownId);
|
||||||
|
const flatRundown = useMemo(() => {
|
||||||
|
const flatData = getFlatRundownMetadata(rundown, effectiveSelectedEventId);
|
||||||
|
return flatData.filter(isOntimeEvent) as ExtendedEntry<OntimeEvent>[];
|
||||||
|
}, [effectiveSelectedEventId, rundown]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
flatRundown,
|
||||||
|
status,
|
||||||
|
isLoadedRundown,
|
||||||
|
}),
|
||||||
|
[status, isLoadedRundown, flatRundown],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useContextRundownTable() {
|
||||||
|
'use memo';
|
||||||
|
const { effectiveRundownId, isLoadedRundown } = useRundownSelectionContext();
|
||||||
|
const loadedEventId = useSelectedEventId();
|
||||||
|
const effectiveSelectedEventId = isLoadedRundown ? loadedEventId : null;
|
||||||
|
const { data: rundown, status } = useRundown(effectiveRundownId);
|
||||||
|
const flatRundown = useMemo(
|
||||||
|
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
|
||||||
|
[effectiveSelectedEventId, rundown],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
flatRundown,
|
||||||
|
status,
|
||||||
|
loadedEventId,
|
||||||
|
}),
|
||||||
|
[flatRundown, status, loadedEventId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useContextRundownFinder() {
|
||||||
|
'use memo';
|
||||||
|
const { effectiveRundownId } = useRundownSelectionContext();
|
||||||
|
const { data: rundown, status } = useFlatRundown(effectiveRundownId);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
rundown,
|
||||||
|
rundownId: effectiveRundownId,
|
||||||
|
status,
|
||||||
|
}),
|
||||||
|
[rundown, status, effectiveRundownId],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,18 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types';
|
import { ProjectFile, ProjectFileList } from 'ontime-types';
|
||||||
|
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
|
||||||
import { PROJECT_LIST } from '../api/constants';
|
import { PROJECT_LIST } from '../api/constants';
|
||||||
import { getProjects } from '../api/db';
|
import { getProjects } from '../api/db';
|
||||||
|
|
||||||
const placeholderProjectList: ProjectFileListResponse = {
|
export function useProjectList() {
|
||||||
files: [],
|
const { data, status, refetch } = useSuspenseQuery({
|
||||||
lastLoadedProject: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
function useProjectList() {
|
|
||||||
const { data, status, refetch } = useQuery({
|
|
||||||
queryKey: PROJECT_LIST,
|
queryKey: PROJECT_LIST,
|
||||||
queryFn: ({ signal }) => getProjects({ signal }),
|
queryFn: ({ signal }) => getProjects({ signal }),
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
staleTime: MILLIS_PER_HOUR,
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
|
||||||
});
|
});
|
||||||
return { data: data ?? placeholderProjectList, status, refetch };
|
return { data, status, refetch };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectSortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'modified-asc' | 'modified-desc';
|
export type ProjectSortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'modified-asc' | 'modified-desc';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { ProjectRundownsList } from 'ontime-types';
|
import { ProjectRundownsList } from 'ontime-types';
|
||||||
|
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
|
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
|
||||||
import { PROJECT_RUNDOWNS } from '../api/constants';
|
import { PROJECT_RUNDOWNS } from '../api/constants';
|
||||||
import {
|
import {
|
||||||
createRundown,
|
createRundown,
|
||||||
@@ -11,24 +11,21 @@ import {
|
|||||||
loadRundown,
|
loadRundown,
|
||||||
renameRundown,
|
renameRundown,
|
||||||
} from '../api/rundown';
|
} from '../api/rundown';
|
||||||
|
import { ontimeQueryClient } from '../queryClient';
|
||||||
|
|
||||||
//TODO: make suspends so we don't have to deal with no value all over
|
|
||||||
/**
|
/**
|
||||||
* Project rundowns
|
* Project rundowns
|
||||||
*/
|
*/
|
||||||
export function useProjectRundowns() {
|
export function useProjectRundowns() {
|
||||||
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
|
const { data, status, isError, refetch, isFetching } = useSuspenseQuery<ProjectRundownsList>({
|
||||||
queryKey: PROJECT_RUNDOWNS,
|
queryKey: PROJECT_RUNDOWNS,
|
||||||
queryFn: ({ signal }) => fetchProjectRundownList({ signal }),
|
queryFn: ({ signal }) => fetchProjectRundownList({ signal }),
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
staleTime: MILLIS_PER_HOUR,
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
|
||||||
});
|
});
|
||||||
return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching };
|
return { data, status, isError, refetch, isFetching };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMutateProjectRundowns() {
|
export function useMutateProjectRundowns() {
|
||||||
const ontimeQueryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { mutateAsync: create } = useMutation({
|
const { mutateAsync: create } = useMutation({
|
||||||
mutationFn: createRundown,
|
mutationFn: createRundown,
|
||||||
onMutate: () => {
|
onMutate: () => {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
|
import { EntryId, Maybe, OntimeEntry, Rundown } from 'ontime-types';
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
|
import { getRundownQueryKey } from '../api/constants';
|
||||||
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
|
import { fetchRundown } from '../api/rundown';
|
||||||
import { useSelectedEventId } from '../hooks/useSocket';
|
import { useSelectedEventId } from '../hooks/useSocket';
|
||||||
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
|
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
|
||||||
import { useProjectRundowns } from './useProjectRundowns';
|
import { useProjectRundowns } from './useProjectRundowns';
|
||||||
@@ -20,43 +20,36 @@ const cachedRundownPlaceholder: Rundown = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalised rundown data for the currently loaded rundown.
|
* Provides access to a specific rundown by ID.
|
||||||
*
|
* When rundownId is not provided the loaded rundown is provided
|
||||||
* Bootstraps via the `/current` alias so the first paint is a single round-trip,
|
|
||||||
* independent of the project rundown list. Once the loaded id is known, the
|
|
||||||
* query key swaps to the id-keyed cache that is shared with `useRundownById`.
|
|
||||||
*/
|
*/
|
||||||
export default function useRundown() {
|
export function useRundown(rundownId: Maybe<string>) {
|
||||||
const queryClient = useQueryClient();
|
'use memo';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: { loaded: loadedRundownId },
|
data: { loaded: loadedRundownId },
|
||||||
} = useProjectRundowns();
|
} = useProjectRundowns();
|
||||||
|
|
||||||
|
const effectiveRundownId = rundownId !== null ? rundownId : loadedRundownId;
|
||||||
|
const isLoadedRundown = effectiveRundownId === loadedRundownId;
|
||||||
|
|
||||||
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
|
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
|
||||||
queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
|
queryKey: getRundownQueryKey(effectiveRundownId),
|
||||||
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
|
queryFn: ({ signal }) => fetchRundown(effectiveRundownId, { signal }),
|
||||||
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Seed the id-keyed cache when fetching via the bootstrap alias
|
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching, isLoadedRundown };
|
||||||
useEffect(() => {
|
|
||||||
if (!data || loadedRundownId) return;
|
|
||||||
queryClient.setQueryData(getRundownQueryKey(data.id), data);
|
|
||||||
}, [data, loadedRundownId, queryClient]);
|
|
||||||
|
|
||||||
// Once we have the ID, drop the temporary current cache
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loadedRundownId) return;
|
|
||||||
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
|
||||||
}, [loadedRundownId, queryClient]);
|
|
||||||
|
|
||||||
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRundownWithMetadata() {
|
export function useRundownWithMetadata(rundownId: Maybe<string>) {
|
||||||
const { data, status } = useRundown();
|
'use memo';
|
||||||
|
|
||||||
|
const { data, status, isLoadedRundown } = useRundown(rundownId);
|
||||||
const selectedEventId = useSelectedEventId();
|
const selectedEventId = useSelectedEventId();
|
||||||
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
|
const effectiveSelectedEventId = isLoadedRundown ? selectedEventId : null;
|
||||||
|
const rundownMetadata = getRundownMetadata(data, effectiveSelectedEventId);
|
||||||
return { data, status, rundownMetadata };
|
return { data, status, rundownMetadata };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,8 +57,8 @@ export function useRundownWithMetadata() {
|
|||||||
* Provides access to a flat rundown
|
* Provides access to a flat rundown
|
||||||
* built from the order and rundown fields
|
* built from the order and rundown fields
|
||||||
*/
|
*/
|
||||||
export function useFlatRundown() {
|
export function useFlatRundown(rundownId: Maybe<string>) {
|
||||||
const { data, status } = useRundown();
|
const { data, status } = useRundown(rundownId);
|
||||||
|
|
||||||
const flatRundown = useMemo(() => {
|
const flatRundown = useMemo(() => {
|
||||||
if (data.revision === -1) {
|
if (data.revision === -1) {
|
||||||
@@ -77,11 +70,13 @@ export function useFlatRundown() {
|
|||||||
return { data: flatRundown, rundownId: data.id, status };
|
return { data: flatRundown, rundownId: data.id, status };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFlatRundownWithMetadata() {
|
export function useFlatRundownWithMetadata(rundownId: Maybe<string>) {
|
||||||
const { data, status } = useRundown();
|
'use memo';
|
||||||
const selectedEventId = useSelectedEventId();
|
|
||||||
|
|
||||||
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
|
const { data, status, isLoadedRundown } = useRundown(rundownId);
|
||||||
|
const selectedEventId = useSelectedEventId();
|
||||||
|
const effectiveSelectedEventId = isLoadedRundown ? selectedEventId : null;
|
||||||
|
const rundownWithMetadata = getFlatRundownMetadata(data, effectiveSelectedEventId);
|
||||||
return { data: rundownWithMetadata, status };
|
return { data: rundownWithMetadata, status };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,8 +87,8 @@ export function useFlatRundownWithMetadata() {
|
|||||||
* re-filtering on every render.
|
* re-filtering on every render.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
|
export function usePartialRundown(rundownId: Maybe<string>, cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
|
||||||
const { data, status } = useFlatRundownWithMetadata();
|
const { data, status } = useFlatRundownWithMetadata(rundownId);
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
return data.filter(cb);
|
return data.filter(cb);
|
||||||
}, [data, cb]);
|
}, [data, cb]);
|
||||||
@@ -103,37 +98,20 @@ export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boo
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to get a specific entry by ID from the rundown
|
* Hook to get a specific entry by ID from the rundown
|
||||||
|
* @deprecated
|
||||||
*/
|
*/
|
||||||
export function useEntry(entryId: EntryId | null): OntimeEntry | null {
|
export function useEntry(rundownId: Maybe<string>, entryId: EntryId | null): OntimeEntry | null {
|
||||||
const { data: rundown } = useRundown();
|
const { data: rundown } = useRundown(rundownId);
|
||||||
|
|
||||||
if (entryId === null) return null;
|
if (entryId === null) return null;
|
||||||
return rundown.entries[entryId] ?? null;
|
return rundown.entries[entryId] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRundownAuxData() {
|
export function useRundownAuxData(rundownId: Maybe<string>) {
|
||||||
const { data, status } = useRundown();
|
const { data, status } = useRundown(rundownId);
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
const { title, id } = data;
|
const { title, id } = data;
|
||||||
return { title, id };
|
return { title, id };
|
||||||
}, [data]);
|
}, [data]);
|
||||||
return { data: filteredData, status };
|
return { data: filteredData, status };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides access to a specific rundown by ID.
|
|
||||||
* When rundownId is null/undefined the query is disabled and returns the placeholder.
|
|
||||||
*/
|
|
||||||
export function useRundownById(rundownId: string | null | undefined) {
|
|
||||||
const enabled = Boolean(rundownId);
|
|
||||||
|
|
||||||
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
|
|
||||||
queryKey: getRundownQueryKey(rundownId ?? ''),
|
|
||||||
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
|
|
||||||
enabled,
|
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import { EntryId, Rundown } from 'ontime-types';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { useSelectedEventId } from '../hooks/useSocket';
|
|
||||||
import { getFlatRundownMetadata, type ExtendedEntry } from '../utils/rundownMetadata';
|
|
||||||
import { useProjectRundowns } from './useProjectRundowns';
|
|
||||||
import { useRundownById } from './useRundown';
|
|
||||||
|
|
||||||
export type RundownSource = {
|
|
||||||
rundownId: string | null;
|
|
||||||
rundown: Rundown;
|
|
||||||
flatRundown: ExtendedEntry[];
|
|
||||||
status: string;
|
|
||||||
selectedEventId: EntryId | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Explicitly scoped rundown data for views that may operate on a non-loaded rundown.
|
|
||||||
*/
|
|
||||||
export function useScopedRundown(rundownId: string | null): RundownSource {
|
|
||||||
const { data: projectRundowns } = useProjectRundowns();
|
|
||||||
return useRundownSource(rundownId, projectRundowns.loaded || null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loaded-rundown source for views that must follow the active runtime rundown.
|
|
||||||
*/
|
|
||||||
export function useLoadedRundownSource(): RundownSource {
|
|
||||||
const { data: projectRundowns } = useProjectRundowns();
|
|
||||||
const loadedRundownId = projectRundowns.loaded || null;
|
|
||||||
return useRundownSource(loadedRundownId, loadedRundownId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useRundownSource(rundownId: string | null, loadedRundownId: string | null): RundownSource {
|
|
||||||
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
|
|
||||||
const runtimeSelectedEventId = useSelectedEventId();
|
|
||||||
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
|
|
||||||
const { data: rundown, status } = useRundownById(rundownId);
|
|
||||||
const flatRundown = useMemo(
|
|
||||||
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
|
|
||||||
[effectiveSelectedEventId, rundown],
|
|
||||||
);
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
rundownId,
|
|
||||||
rundown,
|
|
||||||
flatRundown,
|
|
||||||
status,
|
|
||||||
selectedEventId: effectiveSelectedEventId,
|
|
||||||
}),
|
|
||||||
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
EntryId,
|
EntryId,
|
||||||
InsertOptions,
|
|
||||||
MaybeString,
|
MaybeString,
|
||||||
OntimeDelay,
|
OntimeDelay,
|
||||||
OntimeEntry,
|
OntimeEntry,
|
||||||
@@ -60,9 +59,15 @@ export type EventOptions = Partial<{
|
|||||||
before: MaybeString;
|
before: MaybeString;
|
||||||
// options of entries of type OntimeEvent
|
// options of entries of type OntimeEvent
|
||||||
linkPrevious: boolean;
|
linkPrevious: boolean;
|
||||||
|
/** the timing reference for the new entry, usually the same as after */
|
||||||
lastEventId: MaybeString;
|
lastEventId: MaybeString;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
type ClientInsertOptions = {
|
||||||
|
after?: EntryId;
|
||||||
|
before?: EntryId;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gather utilities for actions on entries in the loaded rundown.
|
* Gather utilities for actions on entries in the loaded rundown.
|
||||||
*/
|
*/
|
||||||
@@ -120,7 +125,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const { mutateAsync: addEntryMutation } = useMutation({
|
const { mutateAsync: addEntryMutation } = useMutation({
|
||||||
mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry),
|
mutationFn: ([rundownId, entry]: [string, PatchWithId & ClientInsertOptions]) => postAddEntry(rundownId, entry),
|
||||||
onMutate: async ([_rundownId, entry]) => {
|
onMutate: async ([_rundownId, entry]) => {
|
||||||
const queryKey = resolveCurrentRundownQueryKey();
|
const queryKey = resolveCurrentRundownQueryKey();
|
||||||
await queryClient.cancelQueries({ queryKey });
|
await queryClient.cancelQueries({ queryKey });
|
||||||
@@ -149,8 +154,9 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
|||||||
addToRundown(
|
addToRundown(
|
||||||
newRundown,
|
newRundown,
|
||||||
optimisticEntry,
|
optimisticEntry,
|
||||||
afterId,
|
|
||||||
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
|
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
|
||||||
|
afterId,
|
||||||
|
entry.before ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
queryClient.setQueryData<Rundown>(queryKey, newRundown);
|
queryClient.setQueryData<Rundown>(queryKey, newRundown);
|
||||||
@@ -193,7 +199,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
|||||||
throw new Error('Rundown not initialised');
|
throw new Error('Rundown not initialised');
|
||||||
}
|
}
|
||||||
|
|
||||||
const newEntry: PatchWithId & InsertOptions = { ...entry, id: generateId() };
|
const newEntry: PatchWithId & ClientInsertOptions = { ...entry, id: generateId() };
|
||||||
|
|
||||||
// handle adding options that concern all event types
|
// handle adding options that concern all event types
|
||||||
if (options?.after) {
|
if (options?.after) {
|
||||||
@@ -205,8 +211,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
|||||||
|
|
||||||
// ************* CHECK OPTIONS specific to events
|
// ************* CHECK OPTIONS specific to events
|
||||||
if (isOntimeEvent(newEntry)) {
|
if (isOntimeEvent(newEntry)) {
|
||||||
|
// last event id is the timing reference of the previous OntimeEvent in rundown
|
||||||
if (options?.lastEventId) {
|
if (options?.lastEventId) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
|
|
||||||
const previousEvent = rundownData.entries[options?.lastEventId];
|
const previousEvent = rundownData.entries[options?.lastEventId];
|
||||||
if (isOntimeEvent(previousEvent)) {
|
if (isOntimeEvent(previousEvent)) {
|
||||||
newEntry.timeStart = previousEvent.timeEnd;
|
newEntry.timeStart = previousEvent.timeEnd;
|
||||||
@@ -284,7 +290,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
|||||||
* Clone an entry
|
* Clone an entry
|
||||||
*/
|
*/
|
||||||
const clone = useCallback(
|
const clone = useCallback(
|
||||||
async (entryId: EntryId, options?: InsertOptions) => {
|
async (entryId: EntryId, options?: ClientInsertOptions) => {
|
||||||
try {
|
try {
|
||||||
const rundownId = getCurrentRundownData()?.id;
|
const rundownId = getCurrentRundownData()?.id;
|
||||||
if (!rundownId) {
|
if (!rundownId) {
|
||||||
@@ -1057,7 +1063,7 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
|
|||||||
/**
|
/**
|
||||||
* Utility to create an optimistic entry for immediate cache insertion
|
* Utility to create an optimistic entry for immediate cache insertion
|
||||||
*/
|
*/
|
||||||
function createOptimisticEntry(payload: PatchWithId & InsertOptions): OntimeEntry {
|
function createOptimisticEntry(payload: PatchWithId & ClientInsertOptions): OntimeEntry {
|
||||||
const { after: _after, before: _before, ...entryData } = payload;
|
const { after: _after, before: _before, ...entryData } = payload;
|
||||||
const id = entryData.id;
|
const id = entryData.id;
|
||||||
let parent: EntryId | null = null;
|
let parent: EntryId | null = null;
|
||||||
|
|||||||
@@ -139,6 +139,17 @@ export const setEventPlayback = {
|
|||||||
pause: () => sendSocket('pause', undefined),
|
pause: () => sendSocket('pause', undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useTimerProgress = createSelector((state: RuntimeStore) => ({
|
||||||
|
playback: state.timer.playback,
|
||||||
|
phase: state.timer.phase,
|
||||||
|
addedTime: state.timer.addedTime,
|
||||||
|
secondaryTimer: state.timer.secondaryTimer,
|
||||||
|
current: state.timer.current,
|
||||||
|
expectedFinish: state.timer.expectedFinish,
|
||||||
|
startedAt: state.timer.startedAt,
|
||||||
|
isCountToEnd: state.eventNow?.countToEnd ?? false,
|
||||||
|
}));
|
||||||
|
|
||||||
export const useTimer = createSelector((state: RuntimeStore) => ({
|
export const useTimer = createSelector((state: RuntimeStore) => ({
|
||||||
...state.timer,
|
...state.timer,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 0,
|
eventIndex: 0,
|
||||||
isPast: true,
|
isPast: true,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
|
isParentToLoaded: false,
|
||||||
totalGap: 0,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
@@ -107,6 +108,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 1, // UI indexes are 1 based
|
eventIndex: 1, // UI indexes are 1 based
|
||||||
isPast: true,
|
isPast: true,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
|
isParentToLoaded: false,
|
||||||
totalGap: 0,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
@@ -124,6 +126,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 1,
|
eventIndex: 1,
|
||||||
isPast: true,
|
isPast: true,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
|
isParentToLoaded: true,
|
||||||
totalGap: 0,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
@@ -140,6 +143,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 2,
|
eventIndex: 2,
|
||||||
isPast: true,
|
isPast: true,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
|
isParentToLoaded: false,
|
||||||
totalGap: 0,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
@@ -156,6 +160,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 2,
|
eventIndex: 2,
|
||||||
isPast: true,
|
isPast: true,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
|
isParentToLoaded: false,
|
||||||
totalGap: 0,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
@@ -172,6 +177,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 3,
|
eventIndex: 3,
|
||||||
isPast: false,
|
isPast: false,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
|
isParentToLoaded: false,
|
||||||
totalGap: 0,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: true,
|
isLinkedToLoaded: true,
|
||||||
isLoaded: true,
|
isLoaded: true,
|
||||||
@@ -188,6 +194,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 4,
|
eventIndex: 4,
|
||||||
isPast: false,
|
isPast: false,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
|
isParentToLoaded: false,
|
||||||
totalGap: 0,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: true,
|
isLinkedToLoaded: true,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
@@ -204,6 +211,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 5,
|
eventIndex: 5,
|
||||||
isPast: false,
|
isPast: false,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
|
isParentToLoaded: false,
|
||||||
totalGap: 7,
|
totalGap: 7,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
@@ -256,6 +264,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 0,
|
eventIndex: 0,
|
||||||
isPast: false,
|
isPast: false,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
|
isParentToLoaded: false,
|
||||||
totalGap: 0,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
@@ -273,6 +282,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 1,
|
eventIndex: 1,
|
||||||
isPast: false,
|
isPast: false,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
|
isParentToLoaded: false,
|
||||||
totalGap: 0,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
@@ -290,6 +300,7 @@ describe('initRundownMetadata()', () => {
|
|||||||
eventIndex: 2,
|
eventIndex: 2,
|
||||||
isPast: false,
|
isPast: false,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
|
isParentToLoaded: false,
|
||||||
totalGap: 0,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: false,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { removeFileExtension } from '../uploadUtils';
|
||||||
|
|
||||||
|
describe('removeFileExtension()', () => {
|
||||||
|
it('removes a trailing extension', () => {
|
||||||
|
expect(removeFileExtension('show.xlsx')).toBe('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only removes the last extension', () => {
|
||||||
|
expect(removeFileExtension('my.show.xlsx')).toBe('my.show');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the name unchanged when there is no extension', () => {
|
||||||
|
expect(removeFileExtension('rundown')).toBe('rundown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat a leading dot as an extension', () => {
|
||||||
|
expect(removeFileExtension('.gitignore')).toBe('.gitignore');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles an empty string', () => {
|
||||||
|
expect(removeFileExtension('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,6 +27,7 @@ export type RundownMetadata = {
|
|||||||
groupColour: string | undefined;
|
groupColour: string | undefined;
|
||||||
groupEntries: number | undefined;
|
groupEntries: number | undefined;
|
||||||
isFirstAfterGroup: boolean;
|
isFirstAfterGroup: boolean;
|
||||||
|
isParentToLoaded: boolean; // if the group contains the loaded event
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata;
|
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata;
|
||||||
@@ -94,6 +95,7 @@ export function initRundownMetadata(selectedEventId: MaybeString) {
|
|||||||
groupColour: undefined,
|
groupColour: undefined,
|
||||||
groupEntries: undefined,
|
groupEntries: undefined,
|
||||||
isFirstAfterGroup: false,
|
isFirstAfterGroup: false,
|
||||||
|
isParentToLoaded: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function process(entry: OntimeEntry): Readonly<RundownMetadata> {
|
function process(entry: OntimeEntry): Readonly<RundownMetadata> {
|
||||||
@@ -117,6 +119,7 @@ function processEntry(
|
|||||||
// initialise data to be overridden below
|
// initialise data to be overridden below
|
||||||
processedData.isNextDay = false;
|
processedData.isNextDay = false;
|
||||||
processedData.isLoaded = false;
|
processedData.isLoaded = false;
|
||||||
|
processedData.isParentToLoaded = false;
|
||||||
|
|
||||||
processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration
|
processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration
|
||||||
processedData.thisId = entry.id; // we reassign thisId
|
processedData.thisId = entry.id; // we reassign thisId
|
||||||
@@ -132,6 +135,7 @@ function processEntry(
|
|||||||
processedData.groupId = entry.id;
|
processedData.groupId = entry.id;
|
||||||
processedData.groupColour = entry.colour;
|
processedData.groupColour = entry.colour;
|
||||||
processedData.groupEntries = entry.entries.length;
|
processedData.groupEntries = entry.entries.length;
|
||||||
|
processedData.isParentToLoaded = selectedEventId ? entry.entries.includes(selectedEventId) : false;
|
||||||
} else {
|
} else {
|
||||||
// for delays and groups, we insert the group metadata
|
// for delays and groups, we insert the group metadata
|
||||||
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) {
|
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
APP_SETTINGS,
|
APP_SETTINGS,
|
||||||
CLIENT_LIST,
|
CLIENT_LIST,
|
||||||
CSS_OVERRIDE,
|
CSS_OVERRIDE,
|
||||||
CURRENT_RUNDOWN_QUERY_KEY,
|
|
||||||
CUSTOM_FIELDS,
|
CUSTOM_FIELDS,
|
||||||
PROJECT_DATA,
|
PROJECT_DATA,
|
||||||
REPORT,
|
REPORT,
|
||||||
@@ -25,6 +24,7 @@ import {
|
|||||||
VIEW_SETTINGS,
|
VIEW_SETTINGS,
|
||||||
getRundownQueryKey,
|
getRundownQueryKey,
|
||||||
PROJECT_RUNDOWNS,
|
PROJECT_RUNDOWNS,
|
||||||
|
PROJECT_LIST,
|
||||||
} from '../api/constants';
|
} from '../api/constants';
|
||||||
import { invalidateAllCaches } from '../api/utils';
|
import { invalidateAllCaches } from '../api/utils';
|
||||||
import { ontimeQueryClient } from '../queryClient';
|
import { ontimeQueryClient } from '../queryClient';
|
||||||
@@ -216,6 +216,9 @@ export const connectSocket = () => {
|
|||||||
case RefetchKey.ProjectRundowns:
|
case RefetchKey.ProjectRundowns:
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS });
|
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS });
|
||||||
break;
|
break;
|
||||||
|
case RefetchKey.ProjectFiles:
|
||||||
|
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_LIST });
|
||||||
|
break;
|
||||||
default: {
|
default: {
|
||||||
target satisfies never;
|
target satisfies never;
|
||||||
break;
|
break;
|
||||||
@@ -238,7 +241,6 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
|
|||||||
if (!rundownId) {
|
if (!rundownId) {
|
||||||
// we omit rundownId to signify invalidate all rundowns
|
// we omit rundownId to signify invalidate all rundowns
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,12 +254,6 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey, exact: true });
|
ontimeQueryClient.invalidateQueries({ queryKey, exact: true });
|
||||||
|
|
||||||
// keep current alias in sync with the ID-based cache
|
|
||||||
const loadedRundownId = ontimeQueryClient.getQueryData<{ loaded: string }>(PROJECT_RUNDOWNS)?.loaded;
|
|
||||||
if (!loadedRundownId || loadedRundownId === rundownId) {
|
|
||||||
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendSocket<T extends MessageTag | ApiActionTag>(
|
export function sendSocket<T extends MessageTag | ApiActionTag>(
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import {
|
|||||||
MILLIS_PER_HOUR,
|
MILLIS_PER_HOUR,
|
||||||
MILLIS_PER_MINUTE,
|
MILLIS_PER_MINUTE,
|
||||||
MILLIS_PER_SECOND,
|
MILLIS_PER_SECOND,
|
||||||
|
dayInMs,
|
||||||
formatFromMillis,
|
formatFromMillis,
|
||||||
|
getExpectedEnd,
|
||||||
getExpectedStart,
|
getExpectedStart,
|
||||||
} from 'ontime-utils';
|
} from 'ontime-utils';
|
||||||
|
|
||||||
@@ -29,6 +31,11 @@ export function nowInMillis(): number {
|
|||||||
return elapsed;
|
return elapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normaliseWallClock(time: number): number {
|
||||||
|
const timeOfDay = time % dayInMs;
|
||||||
|
return timeOfDay < 0 ? timeOfDay + dayInMs : timeOfDay;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Resolves format from url and store
|
* @description Resolves format from url and store
|
||||||
* @return {string|null} A format string like "hh:mm:ss a" or null
|
* @return {string|null} A format string like "hh:mm:ss a" or null
|
||||||
@@ -180,15 +187,13 @@ export function getExpectedTimesFromExtendedEvent(
|
|||||||
...state,
|
...state,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const expectedEnd = getExpectedEnd(event, expectedStart, state.currentDay);
|
||||||
const plannedEnd = event.timeStart + event.duration + event.delay;
|
const plannedEnd = event.timeStart + event.duration + event.delay;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
expectedStart,
|
expectedStart,
|
||||||
timeToStart: expectedStart - state.clock,
|
timeToStart: expectedStart - state.clock,
|
||||||
expectedEnd: event.countToEnd
|
expectedEnd,
|
||||||
? Math.max(expectedStart + event.duration, plannedEnd)
|
|
||||||
: expectedStart + event.duration,
|
|
||||||
plannedEnd,
|
plannedEnd,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ export function validateProjectFile(file: File) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a trailing file extension from a file name (e.g. "show.xlsx" -> "show")
|
||||||
|
* A leading dot (dotfiles like ".gitignore") is not treated as an extension
|
||||||
|
*/
|
||||||
|
export function removeFileExtension(fileName: string): string {
|
||||||
|
const lastDot = fileName.lastIndexOf('.');
|
||||||
|
if (lastDot <= 0) {
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
return fileName.slice(0, lastDot);
|
||||||
|
}
|
||||||
|
|
||||||
export function isExcelFile(file: File | null) {
|
export function isExcelFile(file: File | null) {
|
||||||
return file?.name.endsWith('.xlsx');
|
return file?.name.endsWith('.xlsx');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { deleteAllReport } from '../../../../common/api/report';
|
|||||||
import { createBlob, downloadBlob } from '../../../../common/api/utils';
|
import { createBlob, downloadBlob } from '../../../../common/api/utils';
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
import useReport from '../../../../common/hooks-query/useReport';
|
import useReport from '../../../../common/hooks-query/useReport';
|
||||||
import useRundown from '../../../../common/hooks-query/useRundown';
|
import { useRundown } from '../../../../common/hooks-query/useRundown';
|
||||||
import { cx } from '../../../../common/utils/styleUtils';
|
import { cx } from '../../../../common/utils/styleUtils';
|
||||||
import { formatTime } from '../../../../common/utils/time';
|
import { formatTime } from '../../../../common/utils/time';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
@@ -15,7 +15,7 @@ import style from './ReportSettings.module.scss';
|
|||||||
|
|
||||||
export default function ReportSettings() {
|
export default function ReportSettings() {
|
||||||
const { data: reportData } = useReport();
|
const { data: reportData } = useReport();
|
||||||
const { data } = useRundown();
|
const { data } = useRundown(null);
|
||||||
|
|
||||||
const clearReport = async () => await deleteAllReport();
|
const clearReport = async () => await deleteAllReport();
|
||||||
const downloadCSV = (combinedReport: CombinedReport[]) => {
|
const downloadCSV = (combinedReport: CombinedReport[]) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { useState } from 'react';
|
import { Suspense, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
IoAdd,
|
IoAdd,
|
||||||
IoDocumentOutline,
|
IoDocumentOutline,
|
||||||
@@ -18,9 +18,9 @@ import IconButton from '../../../../common/components/buttons/IconButton';
|
|||||||
import Dialog from '../../../../common/components/dialog/Dialog';
|
import Dialog from '../../../../common/components/dialog/Dialog';
|
||||||
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
|
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
|
||||||
import Tag from '../../../../common/components/tag/Tag';
|
import Tag from '../../../../common/components/tag/Tag';
|
||||||
|
import { useDirectLinkToBackgroundEdit } from '../../../../common/context/RundownSelectionContext';
|
||||||
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
||||||
import { cx } from '../../../../common/utils/styleUtils';
|
import { cx } from '../../../../common/utils/styleUtils';
|
||||||
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
|
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
import RundownRenameForm from './composite/RundownRenameForm';
|
import RundownRenameForm from './composite/RundownRenameForm';
|
||||||
import { ManageRundownForm } from './ManageRundownForm';
|
import { ManageRundownForm } from './ManageRundownForm';
|
||||||
@@ -28,6 +28,20 @@ import { ManageRundownForm } from './ManageRundownForm';
|
|||||||
import style from './ManagePanel.module.scss';
|
import style from './ManagePanel.module.scss';
|
||||||
|
|
||||||
export default function ManageRundowns() {
|
export default function ManageRundowns() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className={style.empty}>
|
||||||
|
<Panel.Loader isLoading />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ManageRundownsSuspense />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManageRundownsSuspense() {
|
||||||
const { data } = useProjectRundowns();
|
const { data } = useProjectRundowns();
|
||||||
const { duplicate, remove, load, rename } = useMutateProjectRundowns();
|
const { duplicate, remove, load, rename } = useMutateProjectRundowns();
|
||||||
const [isOpenDelete, deleteHandlers] = useDisclosure();
|
const [isOpenDelete, deleteHandlers] = useDisclosure();
|
||||||
|
|||||||
+7
@@ -25,6 +25,13 @@
|
|||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finishActions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.sourceGrid {
|
.sourceGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
+59
-22
@@ -1,4 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ImportedFields,
|
||||||
|
RundownImportMode,
|
||||||
SpreadsheetPreviewResponse,
|
SpreadsheetPreviewResponse,
|
||||||
SpreadsheetWorksheetMetadata,
|
SpreadsheetWorksheetMetadata,
|
||||||
SpreadsheetWorksheetOptions,
|
SpreadsheetWorksheetOptions,
|
||||||
@@ -6,6 +8,7 @@ import type {
|
|||||||
import { getErrorMessage, ImportMap } from 'ontime-utils';
|
import { getErrorMessage, ImportMap } from 'ontime-utils';
|
||||||
import { ChangeEvent, useCallback, useRef, useState } from 'react';
|
import { ChangeEvent, useCallback, useRef, useState } from 'react';
|
||||||
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
|
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getWorksheetMetadata as getExcelWorksheetMetadata,
|
getWorksheetMetadata as getExcelWorksheetMetadata,
|
||||||
@@ -21,8 +24,8 @@ import Button from '../../../../../common/components/buttons/Button';
|
|||||||
import Info from '../../../../../common/components/info/Info';
|
import Info from '../../../../../common/components/info/Info';
|
||||||
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
|
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
|
||||||
import Modal from '../../../../../common/components/modal/Modal';
|
import Modal from '../../../../../common/components/modal/Modal';
|
||||||
import useRundown from '../../../../../common/hooks-query/useRundown';
|
import { useRundown } from '../../../../../common/hooks-query/useRundown';
|
||||||
import { validateExcelImport } from '../../../../../common/utils/uploadUtils';
|
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils';
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
import GSheetSetup from './GSheetSetup';
|
import GSheetSetup from './GSheetSetup';
|
||||||
import SheetImportEditor from './sheet-import/SheetImportEditor';
|
import SheetImportEditor from './sheet-import/SheetImportEditor';
|
||||||
@@ -35,6 +38,7 @@ const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadshee
|
|||||||
type ActiveSource =
|
type ActiveSource =
|
||||||
| {
|
| {
|
||||||
kind: 'excel';
|
kind: 'excel';
|
||||||
|
fileName: string;
|
||||||
worksheetNames: string[];
|
worksheetNames: string[];
|
||||||
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
|
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
|
||||||
closedByUser: boolean;
|
closedByUser: boolean;
|
||||||
@@ -53,9 +57,11 @@ export default function SourcesPanel() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
|
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
|
||||||
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
|
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
|
||||||
|
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
|
||||||
|
|
||||||
const { data: currentRundown } = useRundown();
|
const { data: currentRundown } = useRundown(null);
|
||||||
const { importRundown } = useSpreadsheetImport();
|
const { applyImport } = useSpreadsheetImport();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -74,6 +80,7 @@ export default function SourcesPanel() {
|
|||||||
const worksheetOptions = await uploadExcel(fileToUpload);
|
const worksheetOptions = await uploadExcel(fileToUpload);
|
||||||
setActiveSource({
|
setActiveSource({
|
||||||
kind: 'excel',
|
kind: 'excel',
|
||||||
|
fileName: fileToUpload.name,
|
||||||
worksheetNames: worksheetOptions.worksheets,
|
worksheetNames: worksheetOptions.worksheets,
|
||||||
initialWorksheetMetadata: worksheetOptions.metadata,
|
initialWorksheetMetadata: worksheetOptions.metadata,
|
||||||
closedByUser: false,
|
closedByUser: false,
|
||||||
@@ -97,6 +104,7 @@ export default function SourcesPanel() {
|
|||||||
setHasFile('none');
|
setHasFile('none');
|
||||||
setActiveSource(null);
|
setActiveSource(null);
|
||||||
setError('');
|
setError('');
|
||||||
|
setCompletedRundownTitle('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const openGSheetFlow = () => {
|
const openGSheetFlow = () => {
|
||||||
@@ -119,29 +127,41 @@ export default function SourcesPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFinished = () => {
|
const handleFinished = (rundownTitle: string) => {
|
||||||
setImportFlow('finished');
|
setImportFlow('finished');
|
||||||
setHasFile('none');
|
setHasFile('none');
|
||||||
setActiveSource(null);
|
setActiveSource(null);
|
||||||
setError('');
|
setError('');
|
||||||
|
setCompletedRundownTitle(rundownTitle);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApplyImport = async (preview: SpreadsheetPreviewResponse) => {
|
const handleApplyImport = async (
|
||||||
|
preview: SpreadsheetPreviewResponse,
|
||||||
|
mode: RundownImportMode,
|
||||||
|
newRundownTitle: string,
|
||||||
|
providedFields: ImportedFields,
|
||||||
|
) => {
|
||||||
|
if (mode === 'new') {
|
||||||
|
const title = newRundownTitle.trim() || preview.rundown.title;
|
||||||
|
await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields });
|
||||||
|
handleFinished(title);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!currentRundown) {
|
if (!currentRundown) {
|
||||||
throw new Error('No current rundown loaded');
|
throw new Error('No current rundown loaded');
|
||||||
}
|
}
|
||||||
|
|
||||||
await importRundown(
|
// override or merge into the current rundown; merge uses providedFields to know which columns
|
||||||
{
|
// the sheet supplied so matched events keep the rest (e.g. automations)
|
||||||
[currentRundown.id]: {
|
await applyImport({
|
||||||
...preview.rundown,
|
mode,
|
||||||
id: currentRundown.id,
|
targetRundownId: currentRundown.id,
|
||||||
title: currentRundown.title,
|
rundown: preview.rundown,
|
||||||
},
|
customFields: preview.customFields,
|
||||||
},
|
providedFields,
|
||||||
preview.customFields,
|
});
|
||||||
);
|
handleFinished(currentRundown.title);
|
||||||
handleFinished();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadWorksheetMetadata = useCallback(
|
const loadWorksheetMetadata = useCallback(
|
||||||
@@ -208,6 +228,13 @@ export default function SourcesPanel() {
|
|||||||
if (activeSource.kind === 'excel') return 'excel';
|
if (activeSource.kind === 'excel') return 'excel';
|
||||||
return `gsheet:${activeSource.sheetId}`;
|
return `gsheet:${activeSource.sheetId}`;
|
||||||
})();
|
})();
|
||||||
|
// suggested name when importing into a new rundown: the spreadsheet file name (without extension)
|
||||||
|
// for Excel, or the document title for Google Sheets
|
||||||
|
const spreadsheetName = (() => {
|
||||||
|
if (!activeSource) return '';
|
||||||
|
if (activeSource.kind === 'excel') return removeFileExtension(activeSource.fileName);
|
||||||
|
return activeSource.title;
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
@@ -267,11 +294,20 @@ export default function SourcesPanel() {
|
|||||||
{showCompleted && (
|
{showCompleted && (
|
||||||
<div className={style.finishSection}>
|
<div className={style.finishSection}>
|
||||||
<span className={style.finishBadge}>Import complete</span>
|
<span className={style.finishBadge}>Import complete</span>
|
||||||
<div className={style.finishTitle}>Spreadsheet data applied.</div>
|
<div className={style.finishTitle}>
|
||||||
<div className={style.finishDescription}>You can close this flow or start another import.</div>
|
Spreadsheet data applied to {completedRundownTitle || 'your rundown'}.
|
||||||
<Button variant='subtle-white' onClick={resetFlow}>
|
</div>
|
||||||
Reset flow
|
<div className={style.finishDescription}>
|
||||||
</Button>
|
Review the imported rundown in the editor or start another import.
|
||||||
|
</div>
|
||||||
|
<div className={style.finishActions}>
|
||||||
|
<Button variant='primary' onClick={() => navigate('/editor')}>
|
||||||
|
Open editor
|
||||||
|
</Button>
|
||||||
|
<Button variant='subtle-white' onClick={resetFlow}>
|
||||||
|
Import another
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isGSheetFlow && (
|
{isGSheetFlow && (
|
||||||
@@ -287,6 +323,7 @@ export default function SourcesPanel() {
|
|||||||
bodyElements={
|
bodyElements={
|
||||||
<SheetImportEditor
|
<SheetImportEditor
|
||||||
sourceKey={sourceKey ?? 'spreadsheet'}
|
sourceKey={sourceKey ?? 'spreadsheet'}
|
||||||
|
defaultRundownName={spreadsheetName}
|
||||||
worksheetNames={activeSource?.worksheetNames ?? []}
|
worksheetNames={activeSource?.worksheetNames ?? []}
|
||||||
initialMetadata={activeSource?.initialWorksheetMetadata ?? null}
|
initialMetadata={activeSource?.initialWorksheetMetadata ?? null}
|
||||||
loadMetadata={loadWorksheetMetadata}
|
loadMetadata={loadWorksheetMetadata}
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
import type { RundownImportMode, SpreadsheetPreviewResponse } from 'ontime-types';
|
||||||
|
import { isOntimeEvent, isPlayableEvent, Playback } from 'ontime-types';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import Button from '../../../../../../common/components/buttons/Button';
|
||||||
|
import { usePlayback, useSelectedEventId } from '../../../../../../common/hooks/useSocket';
|
||||||
|
|
||||||
|
interface ApplyImportButtonProps {
|
||||||
|
preview: SpreadsheetPreviewResponse | null;
|
||||||
|
mode: RundownImportMode;
|
||||||
|
disabled: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
onApply: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply action for the spreadsheet import.
|
||||||
|
* Subscribes to playback state on its own so playback updates do not re-render the whole editor.
|
||||||
|
* Requires a second click to confirm when applying would stop a running playback.
|
||||||
|
*/
|
||||||
|
export default function ApplyImportButton({ preview, mode, disabled, loading, onApply }: ApplyImportButtonProps) {
|
||||||
|
const playback = usePlayback();
|
||||||
|
const loadedEventId = useSelectedEventId();
|
||||||
|
|
||||||
|
// the loaded (playing) event loses its playback unless it still exists as a playable event after the import
|
||||||
|
const loadedEntry = loadedEventId ? preview?.rundown.entries[loadedEventId] : undefined;
|
||||||
|
const willLoadedEventBeOverriden = !(
|
||||||
|
loadedEntry !== undefined &&
|
||||||
|
isOntimeEvent(loadedEntry) &&
|
||||||
|
isPlayableEvent(loadedEntry)
|
||||||
|
);
|
||||||
|
|
||||||
|
// applying stops playback when creating a new rundown, or when the playing event does not survive
|
||||||
|
const willStopPlayback = playback !== Playback.Stop && (mode === 'new' || willLoadedEventBeOverriden);
|
||||||
|
|
||||||
|
// two-step confirmation before applying an import that stops playback
|
||||||
|
const [confirmStop, setConfirmStop] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
setConfirmStop(false);
|
||||||
|
}, [mode, preview]);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (willStopPlayback && !confirmStop) {
|
||||||
|
setConfirmStop(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onApply();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant='primary' onClick={handleClick} disabled={disabled} loading={loading}>
|
||||||
|
{willStopPlayback && confirmStop ? 'Confirm — stop playback & apply import' : 'Apply import'}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
+9
@@ -33,6 +33,11 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.importModeTrigger {
|
||||||
|
min-width: 12rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
.addColumnTrigger {
|
.addColumnTrigger {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -87,6 +92,10 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbarWarning {
|
||||||
|
color: $orange-400;
|
||||||
|
}
|
||||||
|
|
||||||
.mappingPaneTitle {
|
.mappingPaneTitle {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|||||||
+133
-31
@@ -1,10 +1,20 @@
|
|||||||
import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types';
|
import type {
|
||||||
|
ImportedFields,
|
||||||
|
RundownImportMode,
|
||||||
|
SpreadsheetPreviewResponse,
|
||||||
|
SpreadsheetWorksheetMetadata,
|
||||||
|
} from 'ontime-types';
|
||||||
import type { ImportMap } from 'ontime-utils';
|
import type { ImportMap } from 'ontime-utils';
|
||||||
import { IoArrowUpOutline, IoEye } from 'react-icons/io5';
|
import { useMemo } from 'react';
|
||||||
|
import { IoArrowUpOutline, IoCheckmark, IoChevronDown, IoEye, IoWarningOutline } from 'react-icons/io5';
|
||||||
|
|
||||||
import Button from '../../../../../../common/components/buttons/Button';
|
import Button from '../../../../../../common/components/buttons/Button';
|
||||||
|
import { DropdownMenu, DropdownMenuOption } from '../../../../../../common/components/dropdown-menu/DropdownMenu';
|
||||||
|
import Input from '../../../../../../common/components/input/input/Input';
|
||||||
import Select from '../../../../../../common/components/select/Select';
|
import Select from '../../../../../../common/components/select/Select';
|
||||||
import * as Panel from '../../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../../panel-utils/PanelUtils';
|
||||||
|
import ApplyImportButton from './ApplyImportButton';
|
||||||
|
import { isIdColumnMapped } from './importMapUtils';
|
||||||
import PreviewTable from './preview/PreviewTable';
|
import PreviewTable from './preview/PreviewTable';
|
||||||
import SheetImportMappingPane from './SheetImportMappingPane';
|
import SheetImportMappingPane from './SheetImportMappingPane';
|
||||||
import { useSheetImportForm } from './useSheetImportForm';
|
import { useSheetImportForm } from './useSheetImportForm';
|
||||||
@@ -13,17 +23,46 @@ import style from './SheetImportEditor.module.scss';
|
|||||||
|
|
||||||
interface SheetImportEditorProps {
|
interface SheetImportEditorProps {
|
||||||
sourceKey: string;
|
sourceKey: string;
|
||||||
|
defaultRundownName: string;
|
||||||
worksheetNames: string[];
|
worksheetNames: string[];
|
||||||
initialMetadata: SpreadsheetWorksheetMetadata | null;
|
initialMetadata: SpreadsheetWorksheetMetadata | null;
|
||||||
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
|
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
|
||||||
previewImport: (importMap: ImportMap) => Promise<SpreadsheetPreviewResponse>;
|
previewImport: (importMap: ImportMap) => Promise<SpreadsheetPreviewResponse>;
|
||||||
onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>;
|
onApply: (
|
||||||
|
preview: SpreadsheetPreviewResponse,
|
||||||
|
mode: RundownImportMode,
|
||||||
|
newRundownTitle: string,
|
||||||
|
providedFields: ImportedFields,
|
||||||
|
) => Promise<void>;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onExport?: (importMap: ImportMap) => Promise<void>;
|
onExport?: (importMap: ImportMap) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const importModeOptions: Array<{
|
||||||
|
value: RundownImportMode;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
value: 'override',
|
||||||
|
label: 'Replace current rundown',
|
||||||
|
description: 'Spreadsheet data completely replaces current rundown',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'merge',
|
||||||
|
label: 'Merge with current rundown',
|
||||||
|
description: 'Merge entries referencing their IDs, entries not present in spreadsheet rundown are deleted',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'new',
|
||||||
|
label: 'New rundown',
|
||||||
|
description: 'Create a new rundown to import the data into. Loads the new rundown',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function SheetImportEditor({
|
export default function SheetImportEditor({
|
||||||
sourceKey,
|
sourceKey,
|
||||||
|
defaultRundownName,
|
||||||
worksheetNames,
|
worksheetNames,
|
||||||
initialMetadata,
|
initialMetadata,
|
||||||
loadMetadata,
|
loadMetadata,
|
||||||
@@ -49,11 +88,16 @@ export default function SheetImportEditor({
|
|||||||
isBusy,
|
isBusy,
|
||||||
canPreview,
|
canPreview,
|
||||||
displayError,
|
displayError,
|
||||||
|
importMode,
|
||||||
|
setImportMode,
|
||||||
|
newRundownTitle,
|
||||||
|
setNewRundownTitle,
|
||||||
handlePreviewSubmit,
|
handlePreviewSubmit,
|
||||||
handleExportSubmit,
|
handleExportSubmit,
|
||||||
handleApply,
|
handleApply,
|
||||||
} = useSheetImportForm({
|
} = useSheetImportForm({
|
||||||
sourceKey,
|
sourceKey,
|
||||||
|
defaultRundownName,
|
||||||
worksheetNames,
|
worksheetNames,
|
||||||
initialMetadata,
|
initialMetadata,
|
||||||
loadMetadata,
|
loadMetadata,
|
||||||
@@ -62,6 +106,19 @@ export default function SheetImportEditor({
|
|||||||
onExport,
|
onExport,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectedImportMode = importModeOptions.find((option) => option.value === importMode) ?? importModeOptions[0];
|
||||||
|
const importModeItems = useMemo<DropdownMenuOption[]>(
|
||||||
|
() =>
|
||||||
|
importModeOptions.map((option) => ({
|
||||||
|
type: 'item',
|
||||||
|
label: option.label,
|
||||||
|
description: option.description,
|
||||||
|
icon: importMode === option.value ? IoCheckmark : undefined,
|
||||||
|
onClick: () => setImportMode(option.value),
|
||||||
|
})),
|
||||||
|
[importMode, setImportMode],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Section as='form' id='spreadsheet-import-workspace' className={style.editor} onSubmit={handlePreviewSubmit}>
|
<Panel.Section as='form' id='spreadsheet-import-workspace' className={style.editor} onSubmit={handlePreviewSubmit}>
|
||||||
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorToolbar}>
|
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorToolbar}>
|
||||||
@@ -75,7 +132,16 @@ export default function SheetImportEditor({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{toolbarStatus && <Panel.Description>{toolbarStatus}</Panel.Description>}
|
{toolbarStatus && (
|
||||||
|
<Panel.Description>
|
||||||
|
{toolbarStatus.entries === '–'
|
||||||
|
? 'No import preview yet'
|
||||||
|
: `${toolbarStatus.entries} entries · ${toolbarStatus.groups} groups · ${toolbarStatus.milestones} milestones · ${toolbarStatus.start}–${toolbarStatus.end} · ${toolbarStatus.duration}`}
|
||||||
|
{toolbarStatus.warnings > 0 && (
|
||||||
|
<span className={style.toolbarWarning}> · {toolbarStatus.warnings} warnings</span>
|
||||||
|
)}
|
||||||
|
</Panel.Description>
|
||||||
|
)}
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
|
|
||||||
<div className={style.editorBody}>
|
<div className={style.editorBody}>
|
||||||
@@ -93,13 +159,19 @@ export default function SheetImportEditor({
|
|||||||
|
|
||||||
<section className={style.previewPane}>
|
<section className={style.previewPane}>
|
||||||
<div className={style.previewPaneHeader}>
|
<div className={style.previewPaneHeader}>
|
||||||
<span className={style.previewPaneTitle}>Import preview</span>
|
<div className={style.previewPaneHeading}>
|
||||||
|
<span className={style.previewPaneTitle}>Import preview</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.tableShell}>
|
<div className={style.tableShell}>
|
||||||
<PreviewTable
|
<PreviewTable
|
||||||
preview={state.preview}
|
preview={state.preview}
|
||||||
columnLabels={columnLabels}
|
columnLabels={columnLabels}
|
||||||
|
canRefresh={canPreview}
|
||||||
isLoadingMetadata={isLoadingMetadata}
|
isLoadingMetadata={isLoadingMetadata}
|
||||||
|
isRefreshing={state.loading === 'preview'}
|
||||||
|
needsPreviewRefresh={state.needsPreviewRefresh}
|
||||||
|
onRefresh={handlePreviewSubmit}
|
||||||
worksheetHeaders={worksheetHeaders}
|
worksheetHeaders={worksheetHeaders}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,33 +179,63 @@ export default function SheetImportEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{displayError && <Panel.Error>{displayError}</Panel.Error>}
|
{displayError && <Panel.Error>{displayError}</Panel.Error>}
|
||||||
<Panel.InlineElements align='end' wrap='wrap' className={style.editorFooter}>
|
{importMode === 'merge' && !isIdColumnMapped(values) && (
|
||||||
<Button onClick={onCancel} disabled={isBusy}>
|
<Panel.Description tone='warning'>
|
||||||
Cancel
|
<IoWarningOutline /> No ID column mapped — merge matches entries by ID, so it will behave like Replace. Export
|
||||||
</Button>
|
your rundown to a spreadsheet first to keep its IDs.
|
||||||
{onExport && (
|
</Panel.Description>
|
||||||
<Button onClick={handleExportSubmit} disabled={!canPreview} loading={state.loading === 'export'}>
|
)}
|
||||||
<IoArrowUpOutline />
|
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorFooter}>
|
||||||
Export
|
<Panel.InlineElements wrap='wrap'>
|
||||||
|
<label className={style.worksheetControl}>
|
||||||
|
<span className={style.worksheetLabel}>Import mode</span>
|
||||||
|
<DropdownMenu
|
||||||
|
render={<Button className={style.importModeTrigger} variant='subtle-white' />}
|
||||||
|
items={importModeItems}
|
||||||
|
>
|
||||||
|
{selectedImportMode.label}
|
||||||
|
<IoChevronDown />
|
||||||
|
</DropdownMenu>
|
||||||
|
</label>
|
||||||
|
{importMode === 'new' && (
|
||||||
|
<label className={style.worksheetControl}>
|
||||||
|
<span className={style.worksheetLabel}>New rundown name</span>
|
||||||
|
<Input
|
||||||
|
value={newRundownTitle}
|
||||||
|
onChange={(event) => setNewRundownTitle(event.target.value)}
|
||||||
|
placeholder={state.preview?.rundown.title || 'Imported rundown'}
|
||||||
|
aria-label='New rundown name'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</Panel.InlineElements>
|
||||||
|
<Panel.InlineElements wrap='wrap'>
|
||||||
|
<Button onClick={onCancel} disabled={isBusy}>
|
||||||
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
{onExport && (
|
||||||
<Button
|
<Button onClick={handleExportSubmit} disabled={!canPreview} loading={state.loading === 'export'}>
|
||||||
variant={state.preview ? undefined : 'primary'}
|
<IoArrowUpOutline />
|
||||||
onClick={handlePreviewSubmit}
|
Export
|
||||||
disabled={!canPreview}
|
</Button>
|
||||||
loading={state.loading === 'preview'}
|
)}
|
||||||
>
|
<Button
|
||||||
<IoEye />
|
variant={state.preview ? undefined : 'primary'}
|
||||||
Preview import
|
onClick={handlePreviewSubmit}
|
||||||
</Button>
|
disabled={!canPreview}
|
||||||
<Button
|
loading={state.loading === 'preview'}
|
||||||
variant='primary'
|
>
|
||||||
onClick={handleApply}
|
<IoEye />
|
||||||
disabled={!state.preview || isBusy}
|
Preview import
|
||||||
loading={state.loading === 'apply'}
|
</Button>
|
||||||
>
|
<ApplyImportButton
|
||||||
Apply import
|
preview={state.preview}
|
||||||
</Button>
|
mode={importMode}
|
||||||
|
disabled={!state.preview || isBusy}
|
||||||
|
loading={state.loading === 'apply'}
|
||||||
|
onApply={handleApply}
|
||||||
|
/>
|
||||||
|
</Panel.InlineElements>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
);
|
);
|
||||||
|
|||||||
+68
-1
@@ -1,15 +1,21 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
builtInFieldDefs,
|
builtInFieldDefs,
|
||||||
convertToImportMap,
|
convertToImportMap,
|
||||||
createDefaultFormValues,
|
createDefaultFormValues,
|
||||||
|
defaultImportMode,
|
||||||
getImportWarnings,
|
getImportWarnings,
|
||||||
|
getPersistedImportMode,
|
||||||
|
getProvidedImportFields,
|
||||||
getResolvedCustomFields,
|
getResolvedCustomFields,
|
||||||
|
isIdColumnMapped,
|
||||||
|
persistImportMode,
|
||||||
} from '../importMapUtils';
|
} from '../importMapUtils';
|
||||||
|
|
||||||
const cueIndex = builtInFieldDefs.findIndex((field) => field.label === 'Cue');
|
const cueIndex = builtInFieldDefs.findIndex((field) => field.label === 'Cue');
|
||||||
const titleIndex = builtInFieldDefs.findIndex((field) => field.label === 'Title');
|
const titleIndex = builtInFieldDefs.findIndex((field) => field.label === 'Title');
|
||||||
|
const idIndex = builtInFieldDefs.findIndex((field) => field.importKey === 'id');
|
||||||
|
|
||||||
describe('getImportWarnings()', () => {
|
describe('getImportWarnings()', () => {
|
||||||
it('warns when two mappings target the same spreadsheet column', () => {
|
it('warns when two mappings target the same spreadsheet column', () => {
|
||||||
@@ -128,3 +134,64 @@ describe('convertToImportMap()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getProvidedImportFields()', () => {
|
||||||
|
it('reports the mapped built-in and custom fields the sheet supplies', () => {
|
||||||
|
const values = createDefaultFormValues();
|
||||||
|
values.builtIn[titleIndex] = { header: 'title', enabled: true };
|
||||||
|
values.builtIn[cueIndex] = { header: '', enabled: false };
|
||||||
|
values.custom = [{ ontimeName: 'ignored', importName: 'FOH/Monitor' }];
|
||||||
|
|
||||||
|
const provided = getProvidedImportFields(convertToImportMap(values));
|
||||||
|
|
||||||
|
// enabled built-in mappings are reported as event fields, disabled ones are not
|
||||||
|
expect(provided.event).toContain('title');
|
||||||
|
expect(provided.event).not.toContain('cue');
|
||||||
|
// the id column is only used for matching, never overwritten
|
||||||
|
expect(provided.event).not.toContain('id');
|
||||||
|
// custom fields are reported symmetrically by their resolved Ontime name
|
||||||
|
expect(provided.custom).toStrictEqual(['FOH Monitor']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isIdColumnMapped()', () => {
|
||||||
|
it('is true when the ID field is enabled with a header', () => {
|
||||||
|
// the default form maps the ID column
|
||||||
|
expect(isIdColumnMapped(createDefaultFormValues())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when the ID field is disabled', () => {
|
||||||
|
const values = createDefaultFormValues();
|
||||||
|
values.builtIn[idIndex] = { header: 'id', enabled: false };
|
||||||
|
expect(isIdColumnMapped(values)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when the ID field header is blank', () => {
|
||||||
|
const values = createDefaultFormValues();
|
||||||
|
values.builtIn[idIndex] = { header: ' ', enabled: true };
|
||||||
|
expect(isIdColumnMapped(values)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('import mode persistence', () => {
|
||||||
|
const sourceKey = 'excel-test';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to override when nothing is persisted', () => {
|
||||||
|
expect(getPersistedImportMode(sourceKey)).toBe('override');
|
||||||
|
expect(defaultImportMode).toBe('override');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a persisted value', () => {
|
||||||
|
persistImportMode(sourceKey, 'merge');
|
||||||
|
expect(getPersistedImportMode(sourceKey)).toBe('merge');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the default when the persisted value is invalid', () => {
|
||||||
|
persistImportMode(sourceKey, 'nonsense' as never);
|
||||||
|
expect(getPersistedImportMode(sourceKey)).toBe('override');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+58
-3
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ImportedFields, RundownImportMode } from 'ontime-types';
|
||||||
import type { ImportMap } from 'ontime-utils';
|
import type { ImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
import { makeStageKey } from '../../../../../../common/utils/localStorage';
|
import { makeStageKey } from '../../../../../../common/utils/localStorage';
|
||||||
@@ -49,6 +50,16 @@ export function createDefaultFormValues(): ImportFormValues {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the mapping supplies an ID column. Merge matches entries by ID, so without one every
|
||||||
|
* imported entry gets a fresh id and nothing can reconcile with the current rundown.
|
||||||
|
*/
|
||||||
|
export function isIdColumnMapped(values: ImportFormValues): boolean {
|
||||||
|
const idIndex = builtInFieldDefs.findIndex((def) => def.importKey === 'id');
|
||||||
|
const field = values.builtIn[idIndex];
|
||||||
|
return Boolean(field?.enabled && field.header.trim());
|
||||||
|
}
|
||||||
|
|
||||||
function sanitiseOntimeCustomFieldLabel(importName: string): string {
|
function sanitiseOntimeCustomFieldLabel(importName: string): string {
|
||||||
// Replace punctuation with spaces, then collapse repeated whitespace into single spaces.
|
// Replace punctuation with spaces, then collapse repeated whitespace into single spaces.
|
||||||
const sanitised = importName
|
const sanitised = importName
|
||||||
@@ -81,6 +92,24 @@ export function getResolvedCustomFields(customFields: ImportFormValues['custom']
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the fields the import map supplies — the complete description of what the incoming data
|
||||||
|
* provides, for both built-in and custom fields. A merge uses this to patch exactly these fields
|
||||||
|
* onto a matched event and keep everything else (e.g. automations) untouched.
|
||||||
|
* Import-map keys are OntimeEvent field names; `worksheet`/`custom` are meta and `id` is only used
|
||||||
|
* for matching, not overwritten.
|
||||||
|
*/
|
||||||
|
export function getProvidedImportFields(importMap: ImportMap): ImportedFields {
|
||||||
|
const event: string[] = [];
|
||||||
|
for (const [key, value] of Object.entries(importMap)) {
|
||||||
|
if (key === 'worksheet' || key === 'custom' || key === 'id') continue;
|
||||||
|
if (typeof value === 'string' && value.trim() !== '') {
|
||||||
|
event.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { event, custom: Object.keys(importMap.custom) };
|
||||||
|
}
|
||||||
|
|
||||||
export function convertToImportMap(values: ImportFormValues): ImportMap {
|
export function convertToImportMap(values: ImportFormValues): ImportMap {
|
||||||
const custom = getResolvedCustomFields(values.custom).reduce<Record<string, string>>(
|
const custom = getResolvedCustomFields(values.custom).reduce<Record<string, string>>(
|
||||||
(accumulator, { ontimeName, importName }) => {
|
(accumulator, { ontimeName, importName }) => {
|
||||||
@@ -130,12 +159,12 @@ function isPersistedFormValues(obj: unknown): obj is ImportFormValues {
|
|||||||
export function getPersistedImportState(sourceKey: string): { values: ImportFormValues; isPersisted: boolean } {
|
export function getPersistedImportState(sourceKey: string): { values: ImportFormValues; isPersisted: boolean } {
|
||||||
const storageKey = getImportMapKey(sourceKey);
|
const storageKey = getImportMapKey(sourceKey);
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(storageKey);
|
const persistedData = localStorage.getItem(storageKey);
|
||||||
if (!raw) {
|
if (!persistedData) {
|
||||||
return { values: createDefaultFormValues(), isPersisted: false };
|
return { values: createDefaultFormValues(), isPersisted: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed: unknown = JSON.parse(raw);
|
const parsed: unknown = JSON.parse(persistedData);
|
||||||
if (isPersistedFormValues(parsed)) {
|
if (isPersistedFormValues(parsed)) {
|
||||||
return { values: parsed, isPersisted: true };
|
return { values: parsed, isPersisted: true };
|
||||||
}
|
}
|
||||||
@@ -150,6 +179,32 @@ export function getPersistedImportState(sourceKey: string): { values: ImportForm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The import mode (new / merge / override) is persisted separately from the field mapping
|
||||||
|
* so the mapping schema guard stays untouched.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Default import mode: replace matched elements in the current rundown */
|
||||||
|
export const defaultImportMode: RundownImportMode = 'override';
|
||||||
|
|
||||||
|
function getImportModeKey(sourceKey: string) {
|
||||||
|
return makeStageKey(`import-mode:${sourceKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persists the import mode for a given source */
|
||||||
|
export function persistImportMode(sourceKey: string, mode: RundownImportMode) {
|
||||||
|
localStorage.setItem(getImportModeKey(sourceKey), mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the persisted import mode for a source, falling back to the default when absent or invalid */
|
||||||
|
export function getPersistedImportMode(sourceKey: string): RundownImportMode {
|
||||||
|
const persisted = localStorage.getItem(getImportModeKey(sourceKey));
|
||||||
|
if (persisted === 'new' || persisted === 'merge' || persisted === 'override') {
|
||||||
|
return persisted;
|
||||||
|
}
|
||||||
|
return defaultImportMode;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates import mappings and generates warnings for duplicate or missing spreadsheet columns.
|
* Validates import mappings and generates warnings for duplicate or missing spreadsheet columns.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+102
-24
@@ -1,27 +1,35 @@
|
|||||||
.emptyState {
|
.emptyState {
|
||||||
height: 100%;
|
padding: 3rem 1.5rem;
|
||||||
min-height: 16rem;
|
|
||||||
display: grid;
|
|
||||||
place-content: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emptyMessage {
|
||||||
|
width: min(30rem, 100%);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.emptyTitle {
|
.emptyTitle {
|
||||||
color: $ui-white;
|
margin-bottom: 0.25rem;
|
||||||
font-size: 1rem;
|
color: rgba($gray-200, 0.72);
|
||||||
font-weight: 600;
|
font-size: calc(1rem + 2px);
|
||||||
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyBody {
|
.emptyBody {
|
||||||
color: $gray-400;
|
color: rgba($gray-200, 0.55);
|
||||||
font-size: 0.95rem;
|
font-size: calc(1rem - 3px);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyAction {
|
||||||
|
margin: 1rem auto 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
color: $ui-white;
|
||||||
font-size: calc(1rem - 2px);
|
font-size: calc(1rem - 2px);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
table-layout: auto;
|
table-layout: auto;
|
||||||
@@ -34,31 +42,101 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
font-weight: 400;
|
color: $gray-300;
|
||||||
color: $gray-400;
|
font-size: 0.8rem;
|
||||||
text-transform: capitalize;
|
font-weight: 600;
|
||||||
vertical-align: top;
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
vertical-align: bottom;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
padding: 0.5rem;
|
box-sizing: border-box;
|
||||||
min-width: 8rem;
|
min-width: 8rem;
|
||||||
vertical-align: top;
|
max-width: 20rem;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
border-bottom: 1px solid $white-10;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
tr:nth-child(even) {
|
tbody tr {
|
||||||
background-color: $white-1;
|
--entry-colour: transparent;
|
||||||
|
background-color: color-mix(in srgb, $gray-1300 96%, var(--entry-colour) 4%);
|
||||||
|
box-shadow: inset 3px 0 var(--entry-colour);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
td[data-empty='true'] {
|
||||||
|
color: $gray-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowNumber,
|
||||||
|
.rowType {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 1;
|
||||||
|
background-color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowNumber {
|
.rowNumber {
|
||||||
width: 4.5rem;
|
left: 0;
|
||||||
min-width: 4.5rem;
|
width: 3.25rem;
|
||||||
|
min-width: 3.25rem !important;
|
||||||
|
color: $gray-400;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowType {
|
.rowType {
|
||||||
width: 7rem;
|
left: 3.25rem;
|
||||||
min-width: 7rem;
|
width: 6.25rem;
|
||||||
|
min-width: 6.25rem !important;
|
||||||
|
color: $gray-400;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
box-shadow: 1px 0 $white-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead .rowNumber,
|
||||||
|
thead .rowType {
|
||||||
|
z-index: 2;
|
||||||
|
background-color: $gray-1350;
|
||||||
|
}
|
||||||
|
|
||||||
|
.numericCell {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multilineCell {
|
||||||
|
max-width: 30rem !important;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-overflow: clip !important;
|
||||||
|
white-space: pre-wrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eventRow {
|
||||||
|
.rowNumber {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupRow {
|
||||||
|
background-color: color-mix(in srgb, $gray-1300 88%, var(--entry-colour) 12%) !important;
|
||||||
|
box-shadow: inset 4px 0 var(--entry-colour) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
td {
|
||||||
|
min-height: 3.25rem;
|
||||||
|
border-top: 0.75rem solid $gray-1350;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestoneRow {
|
||||||
|
background-color: color-mix(in srgb, $gray-1300 93%, var(--entry-colour) 7%) !important;
|
||||||
|
box-shadow: inset 3px 0 var(--entry-colour) !important;
|
||||||
|
color: $gray-300;
|
||||||
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|||||||
+126
-14
@@ -1,7 +1,11 @@
|
|||||||
import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types';
|
import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types';
|
||||||
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
import { isOntimeDelay, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import Button from '../../../../../../../common/components/buttons/Button';
|
||||||
|
import Tag from '../../../../../../../common/components/tag/Tag';
|
||||||
|
import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata';
|
||||||
import { getCellValue } from './previewTableUtils';
|
import { getCellValue } from './previewTableUtils';
|
||||||
|
|
||||||
import style from './PreviewTable.module.scss';
|
import style from './PreviewTable.module.scss';
|
||||||
@@ -9,14 +13,85 @@ import style from './PreviewTable.module.scss';
|
|||||||
interface PreviewTableProps {
|
interface PreviewTableProps {
|
||||||
preview: SpreadsheetPreviewResponse | null;
|
preview: SpreadsheetPreviewResponse | null;
|
||||||
columnLabels: string[];
|
columnLabels: string[];
|
||||||
|
canRefresh: boolean;
|
||||||
isLoadingMetadata: boolean;
|
isLoadingMetadata: boolean;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
needsPreviewRefresh: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
worksheetHeaders: string[];
|
worksheetHeaders: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const priorityColumns = ['Title', 'Cue', 'Start', 'End', 'Duration'];
|
||||||
|
const numericColumns = new Set(['Start', 'End', 'Duration', 'Time warning', 'Time danger']);
|
||||||
|
const transparentColour = 'transparent';
|
||||||
|
|
||||||
|
type PreviewEntry = SpreadsheetPreviewResponse['rundown']['entries'][string];
|
||||||
|
|
||||||
|
function getEntryDisplay(entry: PreviewEntry, groupColour?: string) {
|
||||||
|
if (isOntimeGroup(entry)) {
|
||||||
|
return {
|
||||||
|
rowClassName: style.groupRow,
|
||||||
|
entryColour: entry.colour,
|
||||||
|
entryType: 'Group',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryColour = groupColour ?? transparentColour;
|
||||||
|
|
||||||
|
if (isOntimeMilestone(entry)) {
|
||||||
|
return {
|
||||||
|
rowClassName: style.milestoneRow,
|
||||||
|
entryColour,
|
||||||
|
entryType: 'Milestone',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOntimeDelay(entry)) {
|
||||||
|
return {
|
||||||
|
rowClassName: style.eventRow,
|
||||||
|
entryColour,
|
||||||
|
entryType: 'Delay',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rowClassName: style.eventRow,
|
||||||
|
entryColour,
|
||||||
|
entryType: 'Event',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCellClassName(label: string, value: string) {
|
||||||
|
if (value.includes('\n')) {
|
||||||
|
return style.multilineCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numericColumns.has(label)) {
|
||||||
|
return style.numericCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayColumns(columnLabels: string[]) {
|
||||||
|
return [...columnLabels].sort((left, right) => {
|
||||||
|
const leftPriority = priorityColumns.indexOf(left);
|
||||||
|
const rightPriority = priorityColumns.indexOf(right);
|
||||||
|
return (
|
||||||
|
(leftPriority === -1 ? priorityColumns.length : leftPriority) -
|
||||||
|
(rightPriority === -1 ? priorityColumns.length : rightPriority)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function PreviewTable({
|
export default function PreviewTable({
|
||||||
preview,
|
preview,
|
||||||
columnLabels,
|
columnLabels,
|
||||||
|
canRefresh,
|
||||||
isLoadingMetadata,
|
isLoadingMetadata,
|
||||||
|
isRefreshing,
|
||||||
|
needsPreviewRefresh,
|
||||||
|
onRefresh,
|
||||||
worksheetHeaders,
|
worksheetHeaders,
|
||||||
}: PreviewTableProps) {
|
}: PreviewTableProps) {
|
||||||
const customFieldKeyByLabel = useMemo(() => {
|
const customFieldKeyByLabel = useMemo(() => {
|
||||||
@@ -24,33 +99,57 @@ export default function PreviewTable({
|
|||||||
return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId]));
|
return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId]));
|
||||||
}, [preview]);
|
}, [preview]);
|
||||||
|
|
||||||
|
const displayColumns = useMemo(() => getDisplayColumns(columnLabels), [columnLabels]);
|
||||||
|
|
||||||
|
const previewMetadata = useMemo(() => {
|
||||||
|
if (!preview) return null;
|
||||||
|
return getRundownMetadata(preview.rundown, null);
|
||||||
|
}, [preview]);
|
||||||
|
|
||||||
if (!preview) {
|
if (!preview) {
|
||||||
|
let emptyTitle = 'Preview not generated';
|
||||||
let emptyContent = 'Select the fields you want to import, then click Preview import.';
|
let emptyContent = 'Select the fields you want to import, then click Preview import.';
|
||||||
|
|
||||||
if (isLoadingMetadata) {
|
if (isLoadingMetadata) {
|
||||||
|
emptyTitle = 'Loading worksheet';
|
||||||
emptyContent = 'Loading worksheet metadata...';
|
emptyContent = 'Loading worksheet metadata...';
|
||||||
} else if (worksheetHeaders.length === 0) {
|
} else if (worksheetHeaders.length === 0) {
|
||||||
|
emptyTitle = 'No headers found';
|
||||||
emptyContent =
|
emptyContent =
|
||||||
'No column headers detected in this worksheet. Try a different worksheet or ensure the first row contains column headers.';
|
'No column headers detected in this worksheet. Try a different worksheet or ensure the first row contains column headers.';
|
||||||
|
} else if (needsPreviewRefresh) {
|
||||||
|
emptyTitle = 'Preview needs updating';
|
||||||
|
emptyContent = 'Your column mapping changed. Preview the import again to update this table.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.emptyState}>
|
<div className={style.emptyState}>
|
||||||
<div className={style.emptyTitle}>Preview not generated</div>
|
<div className={style.emptyMessage}>
|
||||||
<div className={style.emptyBody}>{emptyContent}</div>
|
<div className={style.emptyTitle}>{emptyTitle}</div>
|
||||||
|
<div className={style.emptyBody}>{emptyContent}</div>
|
||||||
|
{needsPreviewRefresh && (
|
||||||
|
<Button
|
||||||
|
className={style.emptyAction}
|
||||||
|
variant='primary'
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={!canRefresh}
|
||||||
|
loading={isRefreshing}
|
||||||
|
>
|
||||||
|
Refresh preview
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let eventIndex = 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table className={style.table}>
|
<table className={style.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className={style.rowNumber}>#</th>
|
<th className={style.rowNumber}>#</th>
|
||||||
<th className={style.rowType}>Type</th>
|
<th className={style.rowType}>Type</th>
|
||||||
{columnLabels.map((label, index) => (
|
{displayColumns.map((label, index) => (
|
||||||
<th key={`${label}-${index}`}>{label}</th>
|
<th key={`${label}-${index}`}>{label}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -59,16 +158,29 @@ export default function PreviewTable({
|
|||||||
{preview.rundown.flatOrder.map((entryId) => {
|
{preview.rundown.flatOrder.map((entryId) => {
|
||||||
const entry = preview.rundown.entries[entryId];
|
const entry = preview.rundown.entries[entryId];
|
||||||
const isEvent = isOntimeEvent(entry);
|
const isEvent = isOntimeEvent(entry);
|
||||||
if (isEvent) eventIndex++;
|
const entryMetadata = previewMetadata?.[entryId];
|
||||||
const hasType = isEvent || isOntimeGroup(entry) || isOntimeMilestone(entry);
|
const { rowClassName, entryColour, entryType } = getEntryDisplay(entry, entryMetadata?.groupColour);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={entryId}>
|
<tr key={entryId} className={rowClassName} style={{ '--entry-colour': entryColour } as CSSProperties}>
|
||||||
<td className={style.rowNumber}>{isEvent ? eventIndex : ''}</td>
|
<td className={style.rowNumber}>{isEvent ? entryMetadata?.eventIndex : ''}</td>
|
||||||
<td className={style.rowType}>{hasType ? entry.type : ''}</td>
|
<td className={style.rowType}>
|
||||||
{columnLabels.map((label, colIndex) => (
|
<Tag>{entryType}</Tag>
|
||||||
<td key={`${entryId}-${colIndex}`}>{getCellValue(label, entry, customFieldKeyByLabel)}</td>
|
</td>
|
||||||
))}
|
{displayColumns.map((label, colIndex) => {
|
||||||
|
const value = getCellValue(label, entry, customFieldKeyByLabel);
|
||||||
|
const cellClassName = getCellClassName(label, value);
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={`${entryId}-${colIndex}`}
|
||||||
|
className={cellClassName}
|
||||||
|
data-empty={value === ''}
|
||||||
|
title={value || undefined}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+70
-16
@@ -1,7 +1,13 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types';
|
import type {
|
||||||
import { millisToString } from 'ontime-utils';
|
ImportedFields,
|
||||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
RundownImportMode,
|
||||||
|
SpreadsheetPreviewResponse,
|
||||||
|
SpreadsheetWorksheetMetadata,
|
||||||
|
} from 'ontime-types';
|
||||||
|
import { isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||||
|
import { millisToString, removeTrailingZero } from 'ontime-utils';
|
||||||
|
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { maybeAxiosError } from '../../../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../../../common/api/utils';
|
||||||
@@ -11,8 +17,11 @@ import {
|
|||||||
builtInFieldDefs,
|
builtInFieldDefs,
|
||||||
convertToImportMap,
|
convertToImportMap,
|
||||||
getImportWarnings,
|
getImportWarnings,
|
||||||
|
getPersistedImportMode,
|
||||||
getPersistedImportState,
|
getPersistedImportState,
|
||||||
|
getProvidedImportFields,
|
||||||
getResolvedCustomFields,
|
getResolvedCustomFields,
|
||||||
|
persistImportMode,
|
||||||
persistImportState,
|
persistImportState,
|
||||||
} from './importMapUtils';
|
} from './importMapUtils';
|
||||||
import { deriveHeaderOptionsState } from './spreadsheetImportUtils';
|
import { deriveHeaderOptionsState } from './spreadsheetImportUtils';
|
||||||
@@ -24,7 +33,7 @@ type ImportAction =
|
|||||||
| { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse }
|
| { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse }
|
||||||
| { type: 'applySuccess' }
|
| { type: 'applySuccess' }
|
||||||
| { type: 'exportSuccess' }
|
| { type: 'exportSuccess' }
|
||||||
| { type: 'clearPreview'; error?: string }
|
| { type: 'clearPreview'; error?: string; needsRefresh?: boolean }
|
||||||
| { type: 'failure'; error: string }
|
| { type: 'failure'; error: string }
|
||||||
| { type: 'reset' };
|
| { type: 'reset' };
|
||||||
|
|
||||||
@@ -32,12 +41,14 @@ type ImportState = {
|
|||||||
loading: '' | 'preview' | 'apply' | 'export';
|
loading: '' | 'preview' | 'apply' | 'export';
|
||||||
error: string;
|
error: string;
|
||||||
preview: SpreadsheetPreviewResponse | null;
|
preview: SpreadsheetPreviewResponse | null;
|
||||||
|
needsPreviewRefresh: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialImportState: ImportState = {
|
const initialImportState: ImportState = {
|
||||||
loading: '',
|
loading: '',
|
||||||
error: '',
|
error: '',
|
||||||
preview: null,
|
preview: null,
|
||||||
|
needsPreviewRefresh: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function importReducer(state: ImportState, action: ImportAction): ImportState {
|
function importReducer(state: ImportState, action: ImportAction): ImportState {
|
||||||
@@ -49,15 +60,15 @@ function importReducer(state: ImportState, action: ImportAction): ImportState {
|
|||||||
case 'startExport':
|
case 'startExport':
|
||||||
return { ...state, loading: 'export', error: '' };
|
return { ...state, loading: 'export', error: '' };
|
||||||
case 'previewSuccess':
|
case 'previewSuccess':
|
||||||
return { loading: '', error: '', preview: action.preview };
|
return { loading: '', error: '', preview: action.preview, needsPreviewRefresh: false };
|
||||||
case 'applySuccess':
|
case 'applySuccess':
|
||||||
case 'exportSuccess':
|
case 'exportSuccess':
|
||||||
return { ...state, loading: '' };
|
return { ...state, loading: '' };
|
||||||
case 'clearPreview':
|
case 'clearPreview':
|
||||||
return { ...state, error: action.error ?? '', preview: null };
|
return { ...state, error: action.error ?? '', preview: null, needsPreviewRefresh: action.needsRefresh ?? false };
|
||||||
case 'failure': {
|
case 'failure': {
|
||||||
if (state.loading === 'preview') {
|
if (state.loading === 'preview') {
|
||||||
return { loading: '', error: action.error, preview: null };
|
return { loading: '', error: action.error, preview: null, needsPreviewRefresh: false };
|
||||||
}
|
}
|
||||||
return { ...state, loading: '', error: action.error };
|
return { ...state, loading: '', error: action.error };
|
||||||
}
|
}
|
||||||
@@ -100,16 +111,23 @@ function buildColumnLabels(values: ImportFormValues): string[] {
|
|||||||
|
|
||||||
interface UseSheetImportFormProps {
|
interface UseSheetImportFormProps {
|
||||||
sourceKey: string;
|
sourceKey: string;
|
||||||
|
defaultRundownName: string;
|
||||||
worksheetNames: string[];
|
worksheetNames: string[];
|
||||||
initialMetadata: SpreadsheetWorksheetMetadata | null;
|
initialMetadata: SpreadsheetWorksheetMetadata | null;
|
||||||
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
|
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
|
||||||
previewImport: (importMap: ReturnType<typeof convertToImportMap>) => Promise<SpreadsheetPreviewResponse>;
|
previewImport: (importMap: ReturnType<typeof convertToImportMap>) => Promise<SpreadsheetPreviewResponse>;
|
||||||
onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>;
|
onApply: (
|
||||||
|
preview: SpreadsheetPreviewResponse,
|
||||||
|
mode: RundownImportMode,
|
||||||
|
newRundownTitle: string,
|
||||||
|
providedFields: ImportedFields,
|
||||||
|
) => Promise<void>;
|
||||||
onExport?: (importMap: ReturnType<typeof convertToImportMap>) => Promise<void>;
|
onExport?: (importMap: ReturnType<typeof convertToImportMap>) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSheetImportForm({
|
export function useSheetImportForm({
|
||||||
sourceKey,
|
sourceKey,
|
||||||
|
defaultRundownName,
|
||||||
worksheetNames,
|
worksheetNames,
|
||||||
initialMetadata,
|
initialMetadata,
|
||||||
loadMetadata,
|
loadMetadata,
|
||||||
@@ -170,6 +188,8 @@ export function useSheetImportForm({
|
|||||||
const columnLabels = buildColumnLabels(values);
|
const columnLabels = buildColumnLabels(values);
|
||||||
|
|
||||||
const [state, dispatch] = useReducer(importReducer, initialImportState);
|
const [state, dispatch] = useReducer(importReducer, initialImportState);
|
||||||
|
const [importMode, setImportMode] = useState<RundownImportMode>(() => getPersistedImportMode(sourceKey));
|
||||||
|
const [newRundownTitle, setNewRundownTitle] = useState(defaultRundownName);
|
||||||
const warnings = getImportWarnings(values, headers);
|
const warnings = getImportWarnings(values, headers);
|
||||||
const warningCount = Object.values(warnings).filter(Boolean).length;
|
const warningCount = Object.values(warnings).filter(Boolean).length;
|
||||||
const previewRef = useRef<SpreadsheetPreviewResponse | null>(null);
|
const previewRef = useRef<SpreadsheetPreviewResponse | null>(null);
|
||||||
@@ -182,6 +202,12 @@ export function useSheetImportForm({
|
|||||||
dispatch({ type: 'reset' });
|
dispatch({ type: 'reset' });
|
||||||
}, [initialFormValues, reset]);
|
}, [initialFormValues, reset]);
|
||||||
|
|
||||||
|
// Update import mode and rundown name if the source changes
|
||||||
|
useEffect(() => {
|
||||||
|
setImportMode(getPersistedImportMode(sourceKey));
|
||||||
|
setNewRundownTitle(defaultRundownName);
|
||||||
|
}, [sourceKey, defaultRundownName]);
|
||||||
|
|
||||||
// Keep the worksheet selection valid if the available worksheets change underneath the form.
|
// Keep the worksheet selection valid if the available worksheets change underneath the form.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (worksheetNames.length === 0) return;
|
if (worksheetNames.length === 0) return;
|
||||||
@@ -198,7 +224,7 @@ export function useSheetImportForm({
|
|||||||
const sub = watch(() => {
|
const sub = watch(() => {
|
||||||
if (!previewRef.current) return;
|
if (!previewRef.current) return;
|
||||||
previewRef.current = null;
|
previewRef.current = null;
|
||||||
dispatch({ type: 'clearPreview' });
|
dispatch({ type: 'clearPreview', needsRefresh: true });
|
||||||
});
|
});
|
||||||
return () => sub.unsubscribe();
|
return () => sub.unsubscribe();
|
||||||
}, [watch]);
|
}, [watch]);
|
||||||
@@ -241,13 +267,15 @@ export function useSheetImportForm({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
dispatch({ type: 'startApply' });
|
dispatch({ type: 'startApply' });
|
||||||
await onApply(state.preview);
|
const providedFields = getProvidedImportFields(convertToImportMap(getValues()));
|
||||||
|
await onApply(state.preview, importMode, newRundownTitle, providedFields);
|
||||||
persistImportState(sourceKey, getValues());
|
persistImportState(sourceKey, getValues());
|
||||||
|
persistImportMode(sourceKey, importMode);
|
||||||
dispatch({ type: 'applySuccess' });
|
dispatch({ type: 'applySuccess' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dispatch({ type: 'failure', error: maybeAxiosError(error) });
|
dispatch({ type: 'failure', error: maybeAxiosError(error) });
|
||||||
}
|
}
|
||||||
}, [getValues, onApply, sourceKey, state.preview]);
|
}, [getValues, importMode, newRundownTitle, onApply, sourceKey, state.preview]);
|
||||||
|
|
||||||
const handleExport = useCallback(
|
const handleExport = useCallback(
|
||||||
async (formValues: ImportFormValues) => {
|
async (formValues: ImportFormValues) => {
|
||||||
@@ -272,15 +300,37 @@ export function useSheetImportForm({
|
|||||||
}, [append]);
|
}, [append]);
|
||||||
|
|
||||||
const toolbarStatus = (() => {
|
const toolbarStatus = (() => {
|
||||||
const warningText = warningCount > 0 ? ` | warnings: ${warningCount}` : '';
|
|
||||||
|
|
||||||
if (!state.preview) {
|
if (!state.preview) {
|
||||||
return `entries: – | start: – | end: – | duration: –${warningText}`;
|
return {
|
||||||
|
entries: '–',
|
||||||
|
groups: '–',
|
||||||
|
milestones: '–',
|
||||||
|
start: '–',
|
||||||
|
end: '–',
|
||||||
|
duration: '–',
|
||||||
|
warnings: warningCount,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { flatOrder } = state.preview.rundown;
|
const { entries, flatOrder } = state.preview.rundown;
|
||||||
const { start, end, duration } = state.preview.summary;
|
const { start, end, duration } = state.preview.summary;
|
||||||
return `entries: ${flatOrder.length} | start: ${millisToString(start)} | end: ${millisToString(end)} | duration: ${formatDuration(duration)}${warningText}`;
|
let groups = 0;
|
||||||
|
let milestones = 0;
|
||||||
|
for (const entryId of flatOrder) {
|
||||||
|
const entry = entries[entryId];
|
||||||
|
if (isOntimeGroup(entry)) groups++;
|
||||||
|
else if (isOntimeMilestone(entry)) milestones++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries: flatOrder.length,
|
||||||
|
groups,
|
||||||
|
milestones,
|
||||||
|
start: removeTrailingZero(millisToString(start)),
|
||||||
|
end: removeTrailingZero(millisToString(end)),
|
||||||
|
duration: formatDuration(duration),
|
||||||
|
warnings: warningCount,
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -300,6 +350,10 @@ export function useSheetImportForm({
|
|||||||
isBusy,
|
isBusy,
|
||||||
canPreview,
|
canPreview,
|
||||||
displayError,
|
displayError,
|
||||||
|
importMode,
|
||||||
|
setImportMode,
|
||||||
|
newRundownTitle,
|
||||||
|
setNewRundownTitle,
|
||||||
handlePreviewSubmit: handleSubmit(handlePreview),
|
handlePreviewSubmit: handleSubmit(handlePreview),
|
||||||
handleExportSubmit: handleSubmit(handleExport),
|
handleExportSubmit: handleSubmit(handleExport),
|
||||||
handleApply,
|
handleApply,
|
||||||
|
|||||||
+9
-22
@@ -1,30 +1,17 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { RundownImportPayload } from 'ontime-types';
|
||||||
import { CustomFields, ProjectRundowns } from 'ontime-types';
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../../common/api/constants';
|
import { importRundownWithOptions } from '../../../../../common/api/rundown';
|
||||||
import { patchData } from '../../../../../common/api/db';
|
|
||||||
|
|
||||||
export default function useSpreadsheetImport() {
|
export default function useSpreadsheetImport() {
|
||||||
const queryClient = useQueryClient();
|
/** applies a spreadsheet import: override or merge into the current rundown, or create a new one */
|
||||||
|
const applyImport = useCallback(async (payload: RundownImportPayload) => {
|
||||||
/** applies rundown and customFields to current project */
|
// the backend broadcasts a refetch once the rundown is parsed and applied, so the caches update
|
||||||
const importRundown = useCallback(
|
// through that single path rather than racing it with an optimistic write from here
|
||||||
async (rundowns: ProjectRundowns, customFields: CustomFields) => {
|
await importRundownWithOptions(payload);
|
||||||
await patchData({ rundowns, customFields });
|
}, []);
|
||||||
// we are unable to optimistically set the rundown since we need
|
|
||||||
// it to be normalised
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: RUNDOWN,
|
|
||||||
});
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: CUSTOM_FIELDS,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[queryClient],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
importRundown,
|
applyImport,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { Suspense, useState } from 'react';
|
||||||
import { IoArrowDown, IoArrowUp } from 'react-icons/io5';
|
import { IoArrowDown, IoArrowUp } from 'react-icons/io5';
|
||||||
|
|
||||||
import Info from '../../../../common/components/info/Info';
|
import Info from '../../../../common/components/info/Info';
|
||||||
@@ -11,11 +11,25 @@ import style from './ProjectPanel.module.scss';
|
|||||||
type SortParameter = 'alphabetical' | 'modified';
|
type SortParameter = 'alphabetical' | 'modified';
|
||||||
|
|
||||||
export default function ProjectList() {
|
export default function ProjectList() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className={style.empty}>
|
||||||
|
<Panel.Loader isLoading />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ProjectListSuspend />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectListSuspend() {
|
||||||
const [editingMode, setEditingMode] = useState<EditMode | null>(null);
|
const [editingMode, setEditingMode] = useState<EditMode | null>(null);
|
||||||
const [editingFilename, setEditingFilename] = useState<string | null>(null);
|
const [editingFilename, setEditingFilename] = useState<string | null>(null);
|
||||||
const [sortMode, setSortMode] = useState<ProjectSortMode>('modified-desc');
|
const [sortMode, setSortMode] = useState<ProjectSortMode>('modified-desc');
|
||||||
|
|
||||||
const { data, refetch, status } = useOrderedProjectList(sortMode);
|
const { data, refetch } = useOrderedProjectList(sortMode);
|
||||||
|
|
||||||
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
|
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
|
||||||
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
|
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
|
||||||
@@ -38,14 +52,6 @@ export default function ProjectList() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status === 'pending') {
|
|
||||||
return (
|
|
||||||
<div className={style.empty}>
|
|
||||||
<Panel.Loader isLoading />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const numProjects = data.reorderedProjectFiles.length;
|
const numProjects = data.reorderedProjectFiles.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -73,6 +73,10 @@
|
|||||||
margin-right: 0.25rem;
|
margin-right: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tagOvertime {
|
||||||
|
color: $playback-over;
|
||||||
|
}
|
||||||
|
|
||||||
.time {
|
.time {
|
||||||
color: $section-white;
|
color: $section-white;
|
||||||
font-size: $text-body-size;
|
font-size: $text-body-size;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
|
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
|
||||||
import { dayInMs, millisToString } from 'ontime-utils';
|
import { millisToString } from 'ontime-utils';
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import AppLink from '../../../../common/components/link/app-link/AppLink';
|
import AppLink from '../../../../common/components/link/app-link/AppLink';
|
||||||
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
||||||
import useReport from '../../../../common/hooks-query/useReport';
|
import useReport from '../../../../common/hooks-query/useReport';
|
||||||
import { useTimer } from '../../../../common/hooks/useSocket';
|
import { useTimerProgress } from '../../../../common/hooks/useSocket';
|
||||||
import { formatDuration } from '../../../../common/utils/time';
|
import { cx } from '../../../../common/utils/styleUtils';
|
||||||
|
import { formatDuration, normaliseWallClock } from '../../../../common/utils/time';
|
||||||
import TimerDisplay from '../timer-display/TimerDisplay';
|
import TimerDisplay from '../timer-display/TimerDisplay';
|
||||||
|
|
||||||
import style from './PlaybackTimer.module.scss';
|
import style from './PlaybackTimer.module.scss';
|
||||||
@@ -24,15 +25,14 @@ function resolveAddedTimeLabel(addedTime: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PlaybackTimer({ children }: PropsWithChildren) {
|
export default function PlaybackTimer({ children }: PropsWithChildren) {
|
||||||
const timer = useTimer();
|
'use memo';
|
||||||
|
const timer = useTimerProgress();
|
||||||
|
|
||||||
const isRolling = timer.playback === Playback.Roll;
|
const isRolling = timer.playback === Playback.Roll;
|
||||||
const isWaiting = timer.phase === TimerPhase.Pending;
|
const isWaiting = timer.phase === TimerPhase.Pending;
|
||||||
const isOvertime = timer.phase === TimerPhase.Overtime;
|
const isOvertime = timer.phase === TimerPhase.Overtime;
|
||||||
const hasAddedTime = Boolean(timer.addedTime);
|
const hasAddedTime = Boolean(timer.addedTime);
|
||||||
|
|
||||||
const rollLabel = isRolling ? 'Roll mode active' : '';
|
const rollLabel = isRolling ? 'Roll mode active' : '';
|
||||||
|
|
||||||
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
|
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -51,7 +51,13 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
|
|||||||
{isWaiting ? (
|
{isWaiting ? (
|
||||||
<span className={style.rolltag}>Roll: Countdown to start</span>
|
<span className={style.rolltag}>Roll: Countdown to start</span>
|
||||||
) : (
|
) : (
|
||||||
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={timer.playback} />
|
<RunningStatus
|
||||||
|
startedAt={timer.startedAt}
|
||||||
|
expectedFinish={timer.expectedFinish}
|
||||||
|
isStopped={timer.playback === Playback.Stop}
|
||||||
|
isCountToEnd={timer.isCountToEnd}
|
||||||
|
isOvertime={isOvertime}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
@@ -62,16 +68,18 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
|
|||||||
interface RunningStatusProps {
|
interface RunningStatusProps {
|
||||||
startedAt: MaybeNumber;
|
startedAt: MaybeNumber;
|
||||||
expectedFinish: MaybeNumber;
|
expectedFinish: MaybeNumber;
|
||||||
playback: Playback;
|
isStopped: boolean;
|
||||||
|
isCountToEnd: boolean;
|
||||||
|
isOvertime: boolean;
|
||||||
}
|
}
|
||||||
function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusProps) {
|
|
||||||
if (playback === Playback.Stop) {
|
function RunningStatus({ startedAt, expectedFinish, isStopped, isCountToEnd, isOvertime }: RunningStatusProps) {
|
||||||
|
if (isStopped) {
|
||||||
return <StoppedStatus />;
|
return <StoppedStatus />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const started = millisToString(startedAt);
|
const started = millisToString(startedAt);
|
||||||
const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null;
|
const finish = millisToString(expectedFinish === null ? null : normaliseWallClock(expectedFinish));
|
||||||
const finish = millisToString(finishedMs);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -80,7 +88,9 @@ function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusPro
|
|||||||
<span className={style.time}>{started}</span>
|
<span className={style.time}>{started}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className={style.finish}>
|
<span className={style.finish}>
|
||||||
<span className={style.tag}>Expect end</span>
|
<span className={cx([style.tag, isOvertime && style.tagOvertime])}>
|
||||||
|
{isCountToEnd ? 'Scheduled end' : 'Expected end'}
|
||||||
|
</span>
|
||||||
<span className={style.time}>{finish}</span>
|
<span className={style.time}>{finish}</span>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export interface OperatorData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useOperatorData(): ViewData<OperatorData> {
|
export function useOperatorData(): ViewData<OperatorData> {
|
||||||
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
|
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata(null);
|
||||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||||
const { data: settings, status: settingsStatus } = useSettings();
|
const { data: settings, status: settingsStatus } = useSettings();
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { ErrorBoundary } from '@sentry/react';
|
import { ErrorBoundary } from '@sentry/react';
|
||||||
import { PropsWithChildren, ReactNode } from 'react';
|
import { PropsWithChildren, ReactNode, Suspense } from 'react';
|
||||||
|
|
||||||
import ScrollArea from '../../common/components/scroll-area/ScrollArea';
|
import ScrollArea from '../../common/components/scroll-area/ScrollArea';
|
||||||
import { useIsOnline } from '../../common/hooks/useSocket';
|
import { useIsOnline } from '../../common/hooks/useSocket';
|
||||||
import { cx } from '../../common/utils/styleUtils';
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
|
import Loader from '../../views/common/loader/Loader';
|
||||||
|
|
||||||
import style from './Overview.module.scss';
|
import style from './Overview.module.scss';
|
||||||
|
|
||||||
@@ -15,18 +16,37 @@ export function OverviewWrapper({ navElements, children }: PropsWithChildren<Ove
|
|||||||
const isOnline = useIsOnline();
|
const isOnline = useIsOnline();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cx([style.overview, !isOnline && style.isOffline])}>
|
<Suspense fallback={<OverviewFallback navElements={navElements} />}>
|
||||||
<ErrorBoundary>
|
<div className={cx([style.overview, !isOnline && style.isOffline])}>
|
||||||
<div className={style.nav}>{navElements}</div>
|
<ErrorBoundary>
|
||||||
<ScrollArea
|
<div className={style.nav}>{navElements}</div>
|
||||||
className={style.infoScroll}
|
<ScrollArea
|
||||||
contentClassName={style.info}
|
className={style.infoScroll}
|
||||||
contentStyle={{ minWidth: '100%' }}
|
contentClassName={style.info}
|
||||||
orientation='horizontal'
|
contentStyle={{ minWidth: '100%' }}
|
||||||
>
|
orientation='horizontal'
|
||||||
{children}
|
>
|
||||||
</ScrollArea>
|
{children}
|
||||||
</ErrorBoundary>
|
</ScrollArea>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewFallback({ navElements }: OverviewWrapperProps) {
|
||||||
|
return (
|
||||||
|
<div className={style.overview}>
|
||||||
|
<div className={style.nav}>{navElements}</div>
|
||||||
|
<ScrollArea
|
||||||
|
className={style.infoScroll}
|
||||||
|
contentClassName={style.info}
|
||||||
|
contentStyle={{ minWidth: '100%' }}
|
||||||
|
orientation='horizontal'
|
||||||
|
>
|
||||||
|
{/* TODO: this could be alined in a nicer way */}
|
||||||
|
<Loader />
|
||||||
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export function MetadataTimes() {
|
|||||||
function GroupTimes() {
|
function GroupTimes() {
|
||||||
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
|
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
|
||||||
const currentGroupId = useCurrentGroupId();
|
const currentGroupId = useCurrentGroupId();
|
||||||
const group = useEntry(currentGroupId) as OntimeGroup | null;
|
const group = useEntry(null, currentGroupId) as OntimeGroup | null;
|
||||||
|
|
||||||
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
|
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
|
||||||
|
|
||||||
@@ -266,7 +266,7 @@ function GroupTimes() {
|
|||||||
function FlagTimes() {
|
function FlagTimes() {
|
||||||
const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView();
|
const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView();
|
||||||
const { id, expectedStart } = useNextFlag();
|
const { id, expectedStart } = useNextFlag();
|
||||||
const entry = useEntry(id) as OntimeEvent | null;
|
const entry = useEntry(null, id) as OntimeEvent | null;
|
||||||
|
|
||||||
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
|
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import style from './TitleOverview.module.scss';
|
|||||||
export default function TitleOverview() {
|
export default function TitleOverview() {
|
||||||
'use memo';
|
'use memo';
|
||||||
const { data: projectData } = useProjectData();
|
const { data: projectData } = useProjectData();
|
||||||
const { data: rundownData } = useRundownAuxData();
|
const { data: rundownData } = useRundownAuxData(null);
|
||||||
|
|
||||||
if (!projectData.title && !rundownData.title) {
|
if (!projectData.title && !rundownData.title) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -105,12 +105,29 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
|
|||||||
handleCollapseGroup,
|
handleCollapseGroup,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Jump to the current element on demand: the running event in Run mode, the edit cursor otherwise.
|
||||||
|
// Scrolls only (no selection change) so jumping to the running event does not hijack the edit cursor.
|
||||||
|
const jumpToCurrent = useCallback(() => {
|
||||||
|
const targetId = editorMode === AppMode.Run ? featureData?.selectedEventId : cursor;
|
||||||
|
if (!targetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open parent group if the target is inside a collapsed group
|
||||||
|
const entry = entries[targetId];
|
||||||
|
if (entry && 'parent' in entry) {
|
||||||
|
expandGroup(entry.parent);
|
||||||
|
}
|
||||||
|
scrollToEntry(targetId);
|
||||||
|
}, [editorMode, featureData?.selectedEventId, cursor, entries, expandGroup, scrollToEntry]);
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
useRundownKeyboard({
|
useRundownKeyboard({
|
||||||
cursor,
|
cursor,
|
||||||
commands,
|
commands,
|
||||||
clearSelectedEvents,
|
clearSelectedEvents,
|
||||||
setEntryCopyId,
|
setEntryCopyId,
|
||||||
|
jumpToCurrent,
|
||||||
});
|
});
|
||||||
|
|
||||||
// DND handlers
|
// DND handlers
|
||||||
@@ -322,7 +339,9 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
|
|||||||
increaseViewportBy={{ top: 200, bottom: 400 }}
|
increaseViewportBy={{ top: 200, bottom: 400 }}
|
||||||
style={{ height: '100%' }}
|
style={{ height: '100%' }}
|
||||||
components={{
|
components={{
|
||||||
Header: isEditMode ? () => <QuickAddButtons previousEventId={null} parentGroup={null} /> : undefined,
|
Header: isEditMode
|
||||||
|
? () => <QuickAddButtons previousEventId={null} nextEventId={order[0]} parentGroup={null} />
|
||||||
|
: undefined,
|
||||||
Footer: () => (
|
Footer: () => (
|
||||||
<>
|
<>
|
||||||
{isEditMode && (
|
{isEditMode && (
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
|
|||||||
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
|
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
|
||||||
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
||||||
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
|
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
|
||||||
import { useLoadedRundownSource } from '../../common/hooks-query/useScopedRundown';
|
import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext';
|
||||||
import { useEntryActions } from '../../common/hooks/useEntryAction';
|
|
||||||
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
|
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
|
||||||
import { handleLinks } from '../../common/utils/linkUtils';
|
import { handleLinks } from '../../common/utils/linkUtils';
|
||||||
import { cx } from '../../common/utils/styleUtils';
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
@@ -39,28 +38,29 @@ function RundownExport() {
|
|||||||
defaultValue: RundownViewMode.List,
|
defaultValue: RundownViewMode.List,
|
||||||
});
|
});
|
||||||
const isSmallDevice = useIsSmallDevice();
|
const isSmallDevice = useIsSmallDevice();
|
||||||
const entryActions = useEntryActions();
|
|
||||||
|
|
||||||
if (isSmallDevice && isExtracted) {
|
if (isSmallDevice && isExtracted) {
|
||||||
return (
|
return (
|
||||||
<EntryActionsProvider actions={entryActions}>
|
<RundownSelectionContextProvider>
|
||||||
<ProtectRoute permission='editor'>
|
<EntryActionsProvider>
|
||||||
<div
|
<ProtectRoute permission='editor'>
|
||||||
className={cx([style.rundownExport, style.extracted])}
|
<div
|
||||||
data-target='small-device'
|
className={cx([style.rundownExport, style.extracted])}
|
||||||
data-testid='panel-rundown'
|
data-target='small-device'
|
||||||
>
|
data-testid='panel-rundown'
|
||||||
<FinderPlacement />
|
>
|
||||||
<ViewNavigationMenu suppressSettings />
|
<FinderPlacement />
|
||||||
<div className={style.rundown}>
|
<ViewNavigationMenu suppressSettings />
|
||||||
<ErrorBoundary>
|
<div className={style.rundown}>
|
||||||
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
|
<ErrorBoundary>
|
||||||
<RundownContextMenu />
|
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
|
||||||
</ErrorBoundary>
|
<RundownContextMenu />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ProtectRoute>
|
||||||
</ProtectRoute>
|
</EntryActionsProvider>
|
||||||
</EntryActionsProvider>
|
</RundownSelectionContextProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,30 +70,32 @@ function RundownExport() {
|
|||||||
viewMode === RundownViewMode.Table;
|
viewMode === RundownViewMode.Table;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntryActionsProvider actions={entryActions}>
|
<RundownSelectionContextProvider>
|
||||||
<ProtectRoute permission='editor'>
|
<EntryActionsProvider>
|
||||||
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
|
<ProtectRoute permission='editor'>
|
||||||
<FinderPlacement />
|
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
|
||||||
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
|
<FinderPlacement />
|
||||||
<div className={style.rundown}>
|
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
|
||||||
<Editor.Panel className={style.list}>
|
<div className={style.rundown}>
|
||||||
<ErrorBoundary>
|
<Editor.Panel className={style.list}>
|
||||||
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
|
|
||||||
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
|
|
||||||
<RundownContextMenu />
|
|
||||||
</ErrorBoundary>
|
|
||||||
</Editor.Panel>
|
|
||||||
{!hideSideBar && (
|
|
||||||
<div className={style.side}>
|
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RundownEntryEditor />
|
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
|
||||||
|
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
|
||||||
|
<RundownContextMenu />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</Editor.Panel>
|
||||||
)}
|
{!hideSideBar && (
|
||||||
|
<div className={style.side}>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<RundownEntryEditor />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ProtectRoute>
|
||||||
</ProtectRoute>
|
</EntryActionsProvider>
|
||||||
</EntryActionsProvider>
|
</RundownSelectionContextProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,17 +107,16 @@ interface RundownRootProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
|
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
|
||||||
const source = useLoadedRundownSource();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.rundownRoot}>
|
<div className={style.rundownRoot}>
|
||||||
{isSmallDevice ? (
|
{isSmallDevice ? (
|
||||||
<RundownHeaderMobile viewMode={viewMode} setViewMode={setViewMode} />
|
<RundownHeaderMobile viewMode={viewMode} setViewMode={setViewMode} />
|
||||||
) : (
|
) : (
|
||||||
|
// TODO: add data-background-rundown={!isLoadedRundown} styling
|
||||||
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
|
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
|
||||||
)}
|
)}
|
||||||
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
|
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
|
||||||
{viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />}
|
{viewMode === RundownViewMode.Table && <EntryEditModal />}
|
||||||
<RenumberCuesDialog />
|
<RenumberCuesDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
|
import { Playback } from 'ontime-types';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import Empty from '../../common/components/state/Empty';
|
import Empty from '../../common/components/state/Empty';
|
||||||
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
|
import { useContextRundownList } from '../../common/hooks-query/useContextRundown';
|
||||||
import { useRundownEditor } from '../../common/hooks/useSocket';
|
import { useRundownEditor } from '../../common/hooks/useSocket';
|
||||||
import Rundown from './Rundown';
|
import Rundown from './Rundown';
|
||||||
|
|
||||||
|
const backgroundFeatureData = {
|
||||||
|
playback: Playback.Stop,
|
||||||
|
selectedEventId: null,
|
||||||
|
nextEventId: null,
|
||||||
|
};
|
||||||
|
|
||||||
export default memo(RundownList);
|
export default memo(RundownList);
|
||||||
function RundownList() {
|
function RundownList() {
|
||||||
const { data, status, rundownMetadata } = useRundownWithMetadata();
|
const { rundown, status, rundownMetadata, isLoadedRundown } = useContextRundownList();
|
||||||
const featureData = useRundownEditor();
|
const featureData = useRundownEditor();
|
||||||
|
|
||||||
const isLoading = status !== 'success' || !data || !rundownMetadata;
|
const isLoading = status !== 'success' || !rundown || !rundownMetadata;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <Empty text='Connecting to server' />;
|
return <Empty text='Connecting to server' />;
|
||||||
@@ -18,12 +25,12 @@ function RundownList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Rundown
|
<Rundown
|
||||||
order={data.order}
|
order={rundown.order}
|
||||||
flatOrder={data.flatOrder}
|
flatOrder={rundown.flatOrder}
|
||||||
entries={data.entries}
|
entries={rundown.entries}
|
||||||
id={data.id}
|
id={rundown.id}
|
||||||
rundownMetadata={rundownMetadata}
|
rundownMetadata={rundownMetadata}
|
||||||
featureData={featureData}
|
featureData={isLoadedRundown ? featureData : backgroundFeatureData}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.rundownSelect {
|
||||||
|
min-width: min(20rem, calc(100vw - 6rem));
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import Select from '../../../common/components/select/Select';
|
||||||
|
import { useRundownSelectionContext } from '../../../common/context/RundownSelectionContext';
|
||||||
|
import { AppMode } from '../../../ontimeConfig';
|
||||||
|
|
||||||
|
import styles from './RundownSelect.module.scss';
|
||||||
|
|
||||||
|
const FOLLOW = '___null___';
|
||||||
|
|
||||||
|
interface RundownSelectProps {
|
||||||
|
appMode: AppMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RundownSelect({ appMode }: RundownSelectProps) {
|
||||||
|
'use memo';
|
||||||
|
const { selectRundownId, rundowns, loadedRundownId, selectedRundownId } = useRundownSelectionContext();
|
||||||
|
|
||||||
|
const options = rundowns.map(({ id, title }) => ({
|
||||||
|
value: id,
|
||||||
|
label: loadedRundownId === id ? `${title} (loaded)` : title,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// add a follow option
|
||||||
|
options.unshift({
|
||||||
|
value: FOLLOW,
|
||||||
|
label: 'Follow loaded',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.rundownSelect}>
|
||||||
|
<Select
|
||||||
|
value={selectedRundownId ?? FOLLOW}
|
||||||
|
options={options}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === FOLLOW) selectRundownId(null);
|
||||||
|
else selectRundownId(value);
|
||||||
|
}}
|
||||||
|
disabled={appMode === AppMode.Run}
|
||||||
|
fluid
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,6 +44,9 @@ function EventEditorEmpty() {
|
|||||||
<Separator />
|
<Separator />
|
||||||
<Combo keys={['PgDn']} />
|
<Combo keys={['PgDn']} />
|
||||||
</Shortcut>
|
</Shortcut>
|
||||||
|
<Shortcut label='Jump to current entry'>
|
||||||
|
<Combo keys={[deviceAlt, 'L']} />
|
||||||
|
</Shortcut>
|
||||||
<Shortcut label='Deselect entry'>
|
<Shortcut label='Deselect entry'>
|
||||||
<Combo keys={['Esc']} />
|
<Combo keys={['Esc']} />
|
||||||
</Shortcut>
|
</Shortcut>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import useRundown from '../../../common/hooks-query/useRundown';
|
import { useRundown } from '../../../common/hooks-query/useRundown';
|
||||||
import { useEventSelection } from '../useEventSelection';
|
import { useEventSelection } from '../useEventSelection';
|
||||||
import EventEditorFooter from './composite/EventEditorFooter';
|
import EventEditorFooter from './composite/EventEditorFooter';
|
||||||
import EventEditor from './EventEditor';
|
import EventEditor from './EventEditor';
|
||||||
@@ -13,7 +13,7 @@ import style from './EntryEditor.module.scss';
|
|||||||
|
|
||||||
export default function RundownEntryEditor() {
|
export default function RundownEntryEditor() {
|
||||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||||
const { data } = useRundown();
|
const { data } = useRundown(null);
|
||||||
|
|
||||||
const entry = useMemo<OntimeEntry | null>(() => {
|
const entry = useMemo<OntimeEntry | null>(() => {
|
||||||
if (data.order.length === 0) {
|
if (data.order.length === 0) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import Switch from '../../../../common/components/switch/Switch';
|
|||||||
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
||||||
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
||||||
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
||||||
import { formatTime } from '../../../../common/utils/time';
|
import { formatTime, normaliseWallClock } from '../../../../common/utils/time';
|
||||||
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
|
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
|
||||||
|
|
||||||
import style from '../EntryEditor.module.scss';
|
import style from '../EntryEditor.module.scss';
|
||||||
@@ -68,8 +68,10 @@ function EventEditorTimes({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hasDelay = delay !== 0;
|
const hasDelay = delay !== 0;
|
||||||
|
const delayedStart = normaliseWallClock(timeStart + delay);
|
||||||
|
const delayedEnd = normaliseWallClock(timeEnd + delay);
|
||||||
const delayLabel = hasDelay
|
const delayLabel = hasDelay
|
||||||
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(timeStart + delay)} → ${formatTime(timeEnd + delay)}`
|
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(delayedStart)} → ${formatTime(delayedEnd)}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -86,7 +88,6 @@ function EventEditorTimes({
|
|||||||
timeStrategy={timeStrategy}
|
timeStrategy={timeStrategy}
|
||||||
linkStart={linkStart}
|
linkStart={linkStart}
|
||||||
delay={delay}
|
delay={delay}
|
||||||
countToEnd={countToEnd}
|
|
||||||
showLabels
|
showLabels
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+9
-26
@@ -11,13 +11,17 @@ import style from './QuickAddButtons.module.scss';
|
|||||||
|
|
||||||
interface QuickAddButtonsProps {
|
interface QuickAddButtonsProps {
|
||||||
previousEventId: MaybeString;
|
previousEventId: MaybeString;
|
||||||
|
nextEventId?: MaybeString;
|
||||||
parentGroup: MaybeString;
|
parentGroup: MaybeString;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(QuickAddButtons);
|
export default memo(QuickAddButtons);
|
||||||
function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) {
|
function QuickAddButtons({ previousEventId, nextEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) {
|
||||||
const { addEntry } = useEntryActionsContext();
|
const { addEntry } = useEntryActionsContext();
|
||||||
|
const appendOptions = previousEventId ? { after: previousEventId, lastEventId: previousEventId } : undefined;
|
||||||
|
const prependOptions = nextEventId ? { before: nextEventId } : undefined;
|
||||||
|
const insertionOptions = appendOptions ?? prependOptions;
|
||||||
|
|
||||||
const addEvent = () => {
|
const addEvent = () => {
|
||||||
addEntry(
|
addEntry(
|
||||||
@@ -25,44 +29,23 @@ function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: Quic
|
|||||||
type: SupportedEntry.Event,
|
type: SupportedEntry.Event,
|
||||||
parent: parentGroup,
|
parent: parentGroup,
|
||||||
},
|
},
|
||||||
{
|
insertionOptions,
|
||||||
after: previousEventId,
|
|
||||||
lastEventId: previousEventId,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addDelay = () => {
|
const addDelay = () => {
|
||||||
addEntry(
|
addEntry({ type: SupportedEntry.Delay, parent: parentGroup }, insertionOptions);
|
||||||
{ type: SupportedEntry.Delay, parent: parentGroup },
|
|
||||||
{
|
|
||||||
lastEventId: previousEventId,
|
|
||||||
after: previousEventId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const addMilestone = () => {
|
const addMilestone = () => {
|
||||||
addEntry(
|
addEntry({ type: SupportedEntry.Milestone, parent: parentGroup }, insertionOptions);
|
||||||
{ type: SupportedEntry.Milestone, parent: parentGroup },
|
|
||||||
{
|
|
||||||
lastEventId: previousEventId,
|
|
||||||
after: previousEventId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const addGroup = () => {
|
const addGroup = () => {
|
||||||
if (parentGroup !== null) {
|
if (parentGroup !== null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
addEntry(
|
addEntry({ type: SupportedEntry.Group }, insertionOptions);
|
||||||
{ type: SupportedEntry.Group },
|
|
||||||
{
|
|
||||||
lastEventId: previousEventId,
|
|
||||||
after: previousEventId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function useRundownCommands({
|
|||||||
addEntry(patch, {
|
addEntry(patch, {
|
||||||
after: id && !above ? id : undefined,
|
after: id && !above ? id : undefined,
|
||||||
before: id && above ? id : undefined,
|
before: id && above ? id : undefined,
|
||||||
lastEventId: !above && id ? id : undefined,
|
lastEventId: id && !above ? id : undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[addEntry],
|
[addEntry],
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface UseRundownKeyboardOptions {
|
|||||||
};
|
};
|
||||||
clearSelectedEvents: () => void;
|
clearSelectedEvents: () => void;
|
||||||
setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void;
|
setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void;
|
||||||
|
jumpToCurrent: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,6 +41,7 @@ export function useRundownKeyboard({
|
|||||||
commands,
|
commands,
|
||||||
clearSelectedEvents,
|
clearSelectedEvents,
|
||||||
setEntryCopyId,
|
setEntryCopyId,
|
||||||
|
jumpToCurrent,
|
||||||
}: UseRundownKeyboardOptions) {
|
}: UseRundownKeyboardOptions) {
|
||||||
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
|
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
|
||||||
|
|
||||||
@@ -127,6 +129,8 @@ export function useRundownKeyboard({
|
|||||||
{ preventDefault: true, usePhysicalKeys: true },
|
{ preventDefault: true, usePhysicalKeys: true },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
['alt + L', () => jumpToCurrent(), { preventDefault: true, usePhysicalKeys: true }],
|
||||||
|
|
||||||
[
|
[
|
||||||
'alt + mod + ArrowDown',
|
'alt + mod + ArrowDown',
|
||||||
() => commands.moveEntry(cursor, 'down'),
|
() => commands.moveEntry(cursor, 'down'),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Button from '../../../common/components/buttons/Button';
|
|||||||
import Dialog from '../../../common/components/dialog/Dialog';
|
import Dialog from '../../../common/components/dialog/Dialog';
|
||||||
import Input from '../../../common/components/input/input/Input';
|
import Input from '../../../common/components/input/input/Input';
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import useRundown from '../../../common/hooks-query/useRundown';
|
import { useContextRundownCueRenumberModal } from '../../../common/hooks-query/useContextRundown';
|
||||||
import { orderEntries } from '../rundown.utils';
|
import { orderEntries } from '../rundown.utils';
|
||||||
import { useEventSelection } from '../useEventSelection';
|
import { useEventSelection } from '../useEventSelection';
|
||||||
|
|
||||||
@@ -17,8 +17,7 @@ type RenumberCueData = Pick<RenumberCues, 'increment' | 'prefix' | 'start'>;
|
|||||||
|
|
||||||
export default function RenumberCuesDialog() {
|
export default function RenumberCuesDialog() {
|
||||||
'use memo';
|
'use memo';
|
||||||
const { data } = useRundown();
|
const { flatOrder } = useContextRundownCueRenumberModal();
|
||||||
const { flatOrder } = data;
|
|
||||||
const { onClose, isOpen } = useRenumberCuesDialogStore();
|
const { onClose, isOpen } = useRenumberCuesDialogStore();
|
||||||
const { renumberCues } = useEntryActionsContext();
|
const { renumberCues } = useEntryActionsContext();
|
||||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ $skip-opacity: 0.2;
|
|||||||
background-color: $block-bg;
|
background-color: $block-bg;
|
||||||
margin-block: 0.25rem;
|
margin-block: 0.25rem;
|
||||||
overflow: initial;
|
overflow: initial;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
@@ -16,7 +17,7 @@ $skip-opacity: 0.2;
|
|||||||
'binder pb-actions estatus estatus'
|
'binder pb-actions estatus estatus'
|
||||||
'binder ... ... ...';
|
'binder ... ... ...';
|
||||||
|
|
||||||
grid-template-columns: $block-binder-width 3rem 1fr 3rem;
|
grid-template-columns: $block-binder-width 3rem minmax(0, 1fr) 3rem;
|
||||||
grid-template-rows: 0.125rem 2rem 2rem auto 0.125rem;
|
grid-template-rows: 0.125rem 2rem 2rem auto 0.125rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-right: $block-clearance;
|
padding-right: $block-clearance;
|
||||||
@@ -136,6 +137,7 @@ $skip-opacity: 0.2;
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $block-clearance;
|
gap: $block-clearance;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eventTimers.editMode:hover {
|
.eventTimers.editMode:hover {
|
||||||
@@ -155,7 +157,7 @@ $skip-opacity: 0.2;
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
||||||
.nextTag {
|
.warningMeta {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: $orange-500;
|
color: $orange-500;
|
||||||
letter-spacing: 0.03px;
|
letter-spacing: 0.03px;
|
||||||
@@ -231,6 +233,10 @@ $skip-opacity: 0.2;
|
|||||||
color: var(--status-color-active-override, $active-indicator);
|
color: var(--status-color-active-override, $active-indicator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.statusIcon.countToEndStatus {
|
||||||
|
color: $orange-400;
|
||||||
|
}
|
||||||
|
|
||||||
.statusIcon.warning {
|
.statusIcon.warning {
|
||||||
color: $orange-500;
|
color: $orange-500;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,12 +115,12 @@ function RundownEventInner({
|
|||||||
delay={delay}
|
delay={delay}
|
||||||
timeStrategy={timeStrategy}
|
timeStrategy={timeStrategy}
|
||||||
linkStart={linkStart}
|
linkStart={linkStart}
|
||||||
countToEnd={countToEnd}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.titleSection}>
|
<div className={style.titleSection}>
|
||||||
<TitleEditor title={title} entryId={eventId} placeholder='Event title' className={style.eventTitle} />
|
<TitleEditor title={title} entryId={eventId} placeholder='Event title' className={style.eventTitle} />
|
||||||
{isNext && <span className={style.nextTag}>UP NEXT</span>}
|
{isNext && <span className={style.warningMeta}>UP NEXT</span>}
|
||||||
|
{!isNext && countToEnd && <span className={style.warningMeta}>COUNT TO END</span>}
|
||||||
</div>
|
</div>
|
||||||
<EventBlockPlayback
|
<EventBlockPlayback
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
@@ -157,7 +157,13 @@ function RundownEventInner({
|
|||||||
<EndActionIcon action={endAction} className={style.statusIcon} />
|
<EndActionIcon action={endAction} className={style.statusIcon} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip text={`${countToEnd ? 'Count to End' : 'Count duration'}`} render={<span />}>
|
<Tooltip text={`${countToEnd ? 'Count to End' : 'Count duration'}`} render={<span />}>
|
||||||
<LuArrowDownToLine className={`${style.statusIcon} ${countToEnd ? style.active : style.disabled}`} />
|
<LuArrowDownToLine
|
||||||
|
className={cx([
|
||||||
|
style.statusIcon,
|
||||||
|
countToEnd ? style.active : style.disabled,
|
||||||
|
countToEnd && style.countToEndStatus,
|
||||||
|
])}
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip text={automationTooltip} render={<span />}>
|
<Tooltip text={automationTooltip} render={<span />}>
|
||||||
<IoFlash className={automationIconClasses} />
|
<IoFlash className={automationIconClasses} />
|
||||||
|
|||||||
+25
-2
@@ -1,10 +1,33 @@
|
|||||||
|
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||||
|
|
||||||
import { formatDelay } from '../rundownEvent.utils';
|
import { formatDelay } from '../rundownEvent.utils';
|
||||||
|
|
||||||
describe('formatDelay()', () => {
|
describe('formatDelay()', () => {
|
||||||
it('adds a given delay to the start time', () => {
|
it('adds a given delay to the start time', () => {
|
||||||
const timeStart = 60000; // 1 min
|
const timeStart = 1 * MILLIS_PER_MINUTE; // 00:01
|
||||||
const delay = 60000; // 1 min
|
const delay = 1 * MILLIS_PER_MINUTE; // 00:01
|
||||||
const result = formatDelay(timeStart, delay);
|
const result = formatDelay(timeStart, delay);
|
||||||
expect(result).toEqual('New start 00:02');
|
expect(result).toEqual('New start 00:02');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('wraps negative delayed starts under midnight', () => {
|
||||||
|
const timeStart = 1 * MILLIS_PER_MINUTE; // 00:01
|
||||||
|
const delay = -2 * MILLIS_PER_MINUTE; // -00:02
|
||||||
|
const result = formatDelay(timeStart, delay);
|
||||||
|
expect(result).toEqual('New start 23:59');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps later-day negative delays using delay as the source of truth', () => {
|
||||||
|
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00
|
||||||
|
const delay = -(1 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE); // -01:30
|
||||||
|
const result = formatDelay(timeStart, delay);
|
||||||
|
expect(result).toEqual('New start 23:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays positive delays as wall-clock time', () => {
|
||||||
|
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00
|
||||||
|
const delay = 1 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE; // 01:30
|
||||||
|
const result = formatDelay(timeStart, delay);
|
||||||
|
expect(result).toEqual('New start 02:30');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { millisToString, removeTrailingZero } from 'ontime-utils';
|
import { millisToString, removeTrailingZero } from 'ontime-utils';
|
||||||
|
|
||||||
import { formatDuration } from '../../../common/utils/time';
|
import { formatDuration, normaliseWallClock } from '../../../common/utils/time';
|
||||||
|
|
||||||
export function formatDelay(timeStart: number, delay: number): string | undefined {
|
export function formatDelay(timeStart: number, delay: number): string | undefined {
|
||||||
if (!delay) return;
|
if (!delay) return;
|
||||||
|
|
||||||
const delayedStart = Math.max(0, timeStart + delay);
|
const delayedStart = normaliseWallClock(timeStart + delay);
|
||||||
|
|
||||||
const timeTag = removeTrailingZero(millisToString(delayedStart));
|
const timeTag = removeTrailingZero(millisToString(delayedStart));
|
||||||
return `New start ${timeTag}`;
|
return `New start ${timeTag}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatGap(gap: number, isNextDay: boolean) {
|
export function formatGap(gap: number, isNextDay: boolean) {
|
||||||
if (gap === 0) {
|
if (gap === 0) {
|
||||||
if (isNextDay) {
|
if (isNextDay) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Tooltip from '../../../common/components/tooltip/Tooltip';
|
|||||||
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
|
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
|
||||||
import { AppMode } from '../../../ontimeConfig';
|
import { AppMode } from '../../../ontimeConfig';
|
||||||
import { EditorLayoutMode, useEditorLayout } from '../../../views/editor/useEditorLayout';
|
import { EditorLayoutMode, useEditorLayout } from '../../../views/editor/useEditorLayout';
|
||||||
|
import { RundownSelect } from '../common/RundownSelect';
|
||||||
import { RundownViewMode } from '../rundown.options';
|
import { RundownViewMode } from '../rundown.options';
|
||||||
import { useEditorFollowMode } from '../useEditorFollowMode';
|
import { useEditorFollowMode } from '../useEditorFollowMode';
|
||||||
import RundownMenu from './RundownMenu';
|
import RundownMenu from './RundownMenu';
|
||||||
@@ -24,6 +25,7 @@ interface HeaderControlsConfig {
|
|||||||
showRunEditToggle: boolean;
|
showRunEditToggle: boolean;
|
||||||
showOffsetToggle: boolean;
|
showOffsetToggle: boolean;
|
||||||
showOverflowMenu: boolean;
|
showOverflowMenu: boolean;
|
||||||
|
showRundownSelect: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConfig> = {
|
export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConfig> = {
|
||||||
@@ -31,16 +33,19 @@ export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConf
|
|||||||
showRunEditToggle: true,
|
showRunEditToggle: true,
|
||||||
showOffsetToggle: true,
|
showOffsetToggle: true,
|
||||||
showOverflowMenu: true,
|
showOverflowMenu: true,
|
||||||
|
showRundownSelect: false,
|
||||||
},
|
},
|
||||||
[EditorLayoutMode.PLANNING]: {
|
[EditorLayoutMode.PLANNING]: {
|
||||||
showRunEditToggle: false,
|
showRunEditToggle: false,
|
||||||
showOffsetToggle: false,
|
showOffsetToggle: false,
|
||||||
showOverflowMenu: true,
|
showOverflowMenu: true,
|
||||||
|
showRundownSelect: true,
|
||||||
},
|
},
|
||||||
[EditorLayoutMode.TRACKING]: {
|
[EditorLayoutMode.TRACKING]: {
|
||||||
showRunEditToggle: false,
|
showRunEditToggle: false,
|
||||||
showOffsetToggle: true,
|
showOffsetToggle: true,
|
||||||
showOverflowMenu: false,
|
showOverflowMenu: false,
|
||||||
|
showRundownSelect: false,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -50,7 +55,8 @@ function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProp
|
|||||||
const offsetMode = useOffsetMode();
|
const offsetMode = useOffsetMode();
|
||||||
const { layoutMode } = useEditorLayout();
|
const { layoutMode } = useEditorLayout();
|
||||||
|
|
||||||
const { showRunEditToggle, showOffsetToggle, showOverflowMenu } = HEADER_CONTROLS_CONFIG[layoutMode];
|
const { showRunEditToggle, showOffsetToggle, showOverflowMenu, showRundownSelect } =
|
||||||
|
HEADER_CONTROLS_CONFIG[layoutMode];
|
||||||
|
|
||||||
const toggleAppMode = (mode: AppMode[]) => {
|
const toggleAppMode = (mode: AppMode[]) => {
|
||||||
// we need to stop user from deselecting a mode
|
// we need to stop user from deselecting a mode
|
||||||
@@ -122,6 +128,8 @@ function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProp
|
|||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showRundownSelect && <RundownSelect appMode={editorMode} />}
|
||||||
|
|
||||||
{showOverflowMenu && <RundownMenu allowNavigation={!isExtracted} />}
|
{showOverflowMenu && <RundownMenu allowNavigation={!isExtracted} />}
|
||||||
</Toolbar.Root>
|
</Toolbar.Root>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { memo, useEffect, useMemo } from 'react';
|
import { memo, useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import EmptyPage from '../../../common/components/state/EmptyPage';
|
import EmptyPage from '../../../common/components/state/EmptyPage';
|
||||||
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
|
|
||||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||||
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
|
|
||||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
|
||||||
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
|
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
|
||||||
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
|
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
|
||||||
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
|
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
|
||||||
@@ -16,8 +13,6 @@ function RundownTable() {
|
|||||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||||
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
||||||
const { editorMode } = useEditorFollowMode();
|
const { editorMode } = useEditorFollowMode();
|
||||||
const source = useLoadedRundownSource();
|
|
||||||
const actions = useEntryActions();
|
|
||||||
|
|
||||||
// Editor always has full permissions
|
// Editor always has full permissions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -35,14 +30,12 @@ function RundownTable() {
|
|||||||
const isLoading = !customFields || customFieldStatus === 'pending';
|
const isLoading = !customFields || customFieldStatus === 'pending';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntryActionsProvider actions={actions}>
|
<CuesheetDnd columns={columns} tableRoot='editor'>
|
||||||
<CuesheetDnd columns={columns} tableRoot='editor'>
|
{isLoading ? (
|
||||||
{isLoading ? (
|
<EmptyPage text='Loading...' />
|
||||||
<EmptyPage text='Loading...' />
|
) : (
|
||||||
) : (
|
<CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' />
|
||||||
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
|
)}
|
||||||
)}
|
</CuesheetDnd>
|
||||||
</CuesheetDnd>
|
|
||||||
</EntryActionsProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,15 @@
|
|||||||
|
|
||||||
.timerNote {
|
.timerNote {
|
||||||
width: 1.25em;
|
width: 1.25em;
|
||||||
|
flex: 0 0 1.25em;
|
||||||
color: $blue-500;
|
color: $blue-500;
|
||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timerNotePlaceholder {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.inactive {
|
.inactive {
|
||||||
color: $muted-gray;
|
color: $muted-gray;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import style from './TimeInputFlow.module.scss';
|
|||||||
|
|
||||||
interface TimeInputFlowProps {
|
interface TimeInputFlowProps {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
countToEnd: boolean;
|
|
||||||
timeStart: number;
|
timeStart: number;
|
||||||
timeEnd: number;
|
timeEnd: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
@@ -22,12 +21,12 @@ interface TimeInputFlowProps {
|
|||||||
linkStart: boolean;
|
linkStart: boolean;
|
||||||
delay: number;
|
delay: number;
|
||||||
showLabels?: boolean;
|
showLabels?: boolean;
|
||||||
|
showWarnings?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(TimeInputFlow);
|
export default memo(TimeInputFlow);
|
||||||
function TimeInputFlow({
|
function TimeInputFlow({
|
||||||
eventId,
|
eventId,
|
||||||
countToEnd,
|
|
||||||
timeStart,
|
timeStart,
|
||||||
timeEnd,
|
timeEnd,
|
||||||
duration,
|
duration,
|
||||||
@@ -35,6 +34,7 @@ function TimeInputFlow({
|
|||||||
linkStart,
|
linkStart,
|
||||||
delay,
|
delay,
|
||||||
showLabels,
|
showLabels,
|
||||||
|
showWarnings = true,
|
||||||
}: TimeInputFlowProps) {
|
}: TimeInputFlowProps) {
|
||||||
const { updateEntry, updateTimer } = useEntryActionsContext();
|
const { updateEntry, updateTimer } = useEntryActionsContext();
|
||||||
|
|
||||||
@@ -56,10 +56,6 @@ function TimeInputFlow({
|
|||||||
warnings.push('Over midnight');
|
warnings.push('Over midnight');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (countToEnd) {
|
|
||||||
warnings.push('Count to End');
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasDelay = delay !== 0;
|
const hasDelay = delay !== 0;
|
||||||
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
|
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
|
||||||
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
|
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
|
||||||
@@ -136,11 +132,19 @@ function TimeInputFlow({
|
|||||||
</TimeInputGroup>
|
</TimeInputGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{warnings.length > 0 && (
|
{showWarnings &&
|
||||||
<Tooltip text={warnings.join(' - ')} className={style.timerNote} data-testid='event-warning' render={<span />}>
|
(warnings.length > 0 ? (
|
||||||
<IoAlertCircleOutline />
|
<Tooltip
|
||||||
</Tooltip>
|
text={warnings.join(' - ')}
|
||||||
)}
|
className={style.timerNote}
|
||||||
|
data-testid='event-warning'
|
||||||
|
render={<span />}
|
||||||
|
>
|
||||||
|
<IoAlertCircleOutline />
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<span className={`${style.timerNote} ${style.timerNotePlaceholder}`} aria-hidden='true' />
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function useBackstageData(): ViewData<BackstageData> {
|
|||||||
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
||||||
|
|
||||||
// HTTP API data
|
// HTTP API data
|
||||||
const { data: rundownData, status: rundownStatus } = useFlatRundown();
|
const { data: rundownData, status: rundownStatus } = useFlatRundown(null);
|
||||||
const { data: projectData, status: projectDataStatus } = useProjectData();
|
const { data: projectData, status: projectDataStatus } = useProjectData();
|
||||||
const { data: settings, status: settingsStatus } = useSettings();
|
const { data: settings, status: settingsStatus } = useSettings();
|
||||||
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
|
|||||||
[filter],
|
[filter],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: events } = usePartialRundown(filterCallback);
|
const { data: events } = usePartialRundown(null, filterCallback);
|
||||||
|
|
||||||
const [firstIndex, setFirstIndex] = useState(-1);
|
const [firstIndex, setFirstIndex] = useState(-1);
|
||||||
const [numPages, setNumPages] = useState(0);
|
const [numPages, setNumPages] = useState(0);
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ type ScheduleTimeProps = {
|
|||||||
//TODO: consider relative mode
|
//TODO: consider relative mode
|
||||||
export function ScheduleTime(props: ScheduleTimeProps) {
|
export function ScheduleTime(props: ScheduleTimeProps) {
|
||||||
const { event, showExpected } = props;
|
const { event, showExpected } = props;
|
||||||
const { timeStart, duration, delay, expectedStart, countToEnd } = event;
|
const { timeStart, duration, delay, expectedStart, expectedEnd } = event;
|
||||||
|
|
||||||
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
|
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
|
||||||
|
|
||||||
@@ -164,7 +164,6 @@ export function ScheduleTime(props: ScheduleTimeProps) {
|
|||||||
|
|
||||||
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
|
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
|
||||||
const plannedEnd = plannedStart + duration + delay;
|
const plannedEnd = plannedStart + duration + delay;
|
||||||
const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
|
|
||||||
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
|
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
import { getExpectedEnd, getExpectedStart } from 'ontime-utils';
|
||||||
import { getExpectedStart } from 'ontime-utils';
|
|
||||||
import { IoPencil } from 'react-icons/io5';
|
import { IoPencil } from 'react-icons/io5';
|
||||||
|
|
||||||
import Button from '../../common/components/buttons/Button';
|
import Button from '../../common/components/buttons/Button';
|
||||||
import useReport from '../../common/hooks-query/useReport';
|
import useReport from '../../common/hooks-query/useReport';
|
||||||
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||||
import { useExpectedStartData } from '../../common/hooks/useSocket';
|
import { useExpectedStartData } from '../../common/hooks/useSocket';
|
||||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
|
||||||
import { cx } from '../../common/utils/styleUtils';
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||||
import { getPropertyValue } from '../common/viewUtils';
|
import { getPropertyValue } from '../common/viewUtils';
|
||||||
import { useCountdownOptions } from './countdown.options';
|
import { useCountdownOptions } from './countdown.options';
|
||||||
import { CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
|
import { CountdownEvent, CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
|
||||||
import { ScheduleTime } from './CountdownSubscriptions';
|
import { ScheduleTime } from './CountdownSubscriptions';
|
||||||
|
|
||||||
import './SingleEventCountdown.scss';
|
import './SingleEventCountdown.scss';
|
||||||
@@ -38,8 +36,10 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
|
|||||||
mode,
|
mode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const expectedEnd = getExpectedEnd(subscribedEvent, expectedStart, currentDay);
|
||||||
|
|
||||||
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
|
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
|
||||||
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
|
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt, expectedEnd };
|
||||||
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
|
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
|
||||||
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
|
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
|
||||||
// while a group is live, surface the running event's title as the secondary line
|
// while a group is live, surface the running event's title as the secondary line
|
||||||
@@ -64,7 +64,7 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SubscriptionStatusProps {
|
interface SubscriptionStatusProps {
|
||||||
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
|
event: CountdownEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
isOntimeGroup,
|
isOntimeGroup,
|
||||||
isPlayableEvent,
|
isPlayableEvent,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
|
import { MILLIS_PER_MINUTE, getExpectedEnd, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
|
||||||
|
|
||||||
import { useCountdownSocket } from '../../common/hooks/useSocket';
|
import { useCountdownSocket } from '../../common/hooks/useSocket';
|
||||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||||
@@ -197,7 +197,7 @@ export type CountdownTarget = ExtendedEntry<OntimeEvent> & {
|
|||||||
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
|
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber };
|
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber; expectedEnd: number };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a subscription (event or group) into an event-shaped countdown target.
|
* Resolves a subscription (event or group) into an event-shaped countdown target.
|
||||||
@@ -271,6 +271,7 @@ export function extendEventData(
|
|||||||
offset,
|
offset,
|
||||||
mode,
|
mode,
|
||||||
});
|
});
|
||||||
|
const expectedEnd = getExpectedEnd(event, expectedStart, currentDay);
|
||||||
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
|
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
|
||||||
return { ...event, expectedStart, endedAt };
|
return { ...event, expectedStart, endedAt, expectedEnd };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function useCountdownData(): ViewData<CountdownData> {
|
|||||||
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
||||||
|
|
||||||
// HTTP API data
|
// HTTP API data
|
||||||
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
|
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata(null);
|
||||||
const { data: projectData, status: projectDataStatus } = useProjectData();
|
const { data: projectData, status: projectDataStatus } = useProjectData();
|
||||||
const { data: settings, status: settingsStatus } = useSettings();
|
const { data: settings, status: settingsStatus } = useSettings();
|
||||||
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
||||||
|
|||||||
@@ -14,7 +14,3 @@
|
|||||||
'table';
|
'table';
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rundownSelect {
|
|
||||||
min-width: min(20rem, calc(100vw - 6rem));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,51 +4,46 @@ import { IoApps } from 'react-icons/io5';
|
|||||||
import IconButton from '../../common/components/buttons/IconButton';
|
import IconButton from '../../common/components/buttons/IconButton';
|
||||||
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
||||||
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
|
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
|
||||||
import { useScopedRundown } from '../../common/hooks-query/useScopedRundown';
|
import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext';
|
||||||
import { useScopedEntryActions } from '../../common/hooks/useEntryAction';
|
|
||||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||||
import { getIsNavigationLocked } from '../../externals';
|
import { getIsNavigationLocked } from '../../externals';
|
||||||
import CuesheetOverview from '../../features/overview/CuesheetOverview';
|
import CuesheetOverview from '../../features/overview/CuesheetOverview';
|
||||||
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
|
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
|
||||||
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
||||||
import CuesheetTableWrapper from './CuesheetTableWrapper';
|
import CuesheetTableWrapper from './CuesheetTableWrapper';
|
||||||
import { FOLLOW_LOADED_RUNDOWN_ID, useCuesheetRundownSelection } from './useCuesheetRundownSelection';
|
|
||||||
|
|
||||||
import styles from './CuesheetPage.module.scss';
|
import styles from './CuesheetPage.module.scss';
|
||||||
|
|
||||||
export default function CuesheetPage() {
|
export default function CuesheetPage() {
|
||||||
'use memo';
|
'use memo';
|
||||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||||
const { selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = useCuesheetRundownSelection();
|
|
||||||
const source = useScopedRundown(selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? loadedRundownId : selectedRundownId);
|
|
||||||
|
|
||||||
const actions = useScopedEntryActions(source.rundownId);
|
|
||||||
|
|
||||||
useWindowTitle('Cuesheet');
|
useWindowTitle('Cuesheet');
|
||||||
|
|
||||||
const isLocked = getIsNavigationLocked();
|
const isLocked = getIsNavigationLocked();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntryActionsProvider actions={actions}>
|
<RundownSelectionContextProvider>
|
||||||
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
<EntryActionsProvider>
|
||||||
<EntryEditModal rundown={source.rundown} />
|
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
||||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
<EntryEditModal />
|
||||||
<CuesheetOverview>
|
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||||
{!isLocked && (
|
<CuesheetOverview>
|
||||||
<IconButton aria-label='Toggle navigation' variant='subtle-white' size='xlarge' onClick={menuHandler.open}>
|
{!isLocked && (
|
||||||
<IoApps />
|
<IconButton
|
||||||
</IconButton>
|
aria-label='Toggle navigation'
|
||||||
)}
|
variant='subtle-white'
|
||||||
</CuesheetOverview>
|
size='xlarge'
|
||||||
<CuesheetProgress />
|
onClick={menuHandler.open}
|
||||||
<CuesheetTableWrapper
|
>
|
||||||
source={source}
|
<IoApps />
|
||||||
selectedRundownId={selectedRundownId}
|
</IconButton>
|
||||||
loadedRundownId={loadedRundownId}
|
)}
|
||||||
setSelectedRundownId={setSelectedRundownId}
|
</CuesheetOverview>
|
||||||
projectRundowns={projectRundowns}
|
<CuesheetProgress />
|
||||||
/>
|
<CuesheetTableWrapper />
|
||||||
</div>
|
</div>
|
||||||
</EntryActionsProvider>
|
</EntryActionsProvider>
|
||||||
|
</RundownSelectionContextProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,20 @@
|
|||||||
import { MaybeString, ProjectRundown } from 'ontime-types';
|
|
||||||
import { memo, use, useMemo } from 'react';
|
import { memo, use, useMemo } from 'react';
|
||||||
|
|
||||||
import Select from '../../common/components/select/Select';
|
|
||||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||||
import { PresetContext } from '../../common/context/PresetContext';
|
import { PresetContext } from '../../common/context/PresetContext';
|
||||||
|
import { useRundownSelectionContext } from '../../common/context/RundownSelectionContext';
|
||||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||||
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
|
|
||||||
import { AppMode } from '../../ontimeConfig';
|
|
||||||
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
|
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
|
||||||
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
|
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
|
||||||
import CuesheetTable from './cuesheet-table/CuesheetTable';
|
import CuesheetTable from './cuesheet-table/CuesheetTable';
|
||||||
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
|
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
|
||||||
import { FOLLOW_LOADED_RUNDOWN_ID } from './useCuesheetRundownSelection';
|
|
||||||
|
|
||||||
import styles from './CuesheetPage.module.scss';
|
|
||||||
|
|
||||||
interface CuesheetTableWrapperProps {
|
|
||||||
source: RundownSource;
|
|
||||||
selectedRundownId: MaybeString;
|
|
||||||
loadedRundownId: string;
|
|
||||||
setSelectedRundownId: (rundownId: string) => void;
|
|
||||||
projectRundowns: ProjectRundown[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(CuesheetTableWrapper);
|
export default memo(CuesheetTableWrapper);
|
||||||
function CuesheetTableWrapper({
|
function CuesheetTableWrapper() {
|
||||||
source,
|
|
||||||
selectedRundownId,
|
|
||||||
setSelectedRundownId,
|
|
||||||
loadedRundownId,
|
|
||||||
projectRundowns,
|
|
||||||
}: CuesheetTableWrapperProps) {
|
|
||||||
const preset = use(PresetContext);
|
const preset = use(PresetContext);
|
||||||
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
|
const { isLoadedRundown } = useRundownSelectionContext();
|
||||||
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
|
|
||||||
|
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isLoadedRundown });
|
||||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
@@ -50,66 +31,11 @@ function CuesheetTableWrapper({
|
|||||||
) : (
|
) : (
|
||||||
<CuesheetTable
|
<CuesheetTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
source={source}
|
|
||||||
cuesheetMode={cuesheetMode}
|
cuesheetMode={cuesheetMode}
|
||||||
tableRoot='cuesheet'
|
tableRoot='cuesheet'
|
||||||
setCuesheetMode={setCuesheetMode}
|
setCuesheetMode={setCuesheetMode}
|
||||||
isCurrentRundown={isCurrentRundown}
|
|
||||||
insertElement={
|
|
||||||
<>
|
|
||||||
<RundownSelect
|
|
||||||
cuesheetMode={cuesheetMode}
|
|
||||||
selectedRundownId={selectedRundownId}
|
|
||||||
loadedRundownId={loadedRundownId}
|
|
||||||
setSelectedRundownId={setSelectedRundownId}
|
|
||||||
projectRundowns={projectRundowns}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CuesheetDnd>
|
</CuesheetDnd>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RundownSelectProps {
|
|
||||||
cuesheetMode: AppMode;
|
|
||||||
selectedRundownId: MaybeString;
|
|
||||||
loadedRundownId: string;
|
|
||||||
setSelectedRundownId: (rundownId: string) => void;
|
|
||||||
projectRundowns: ProjectRundown[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function RundownSelect({
|
|
||||||
cuesheetMode,
|
|
||||||
projectRundowns,
|
|
||||||
loadedRundownId,
|
|
||||||
selectedRundownId,
|
|
||||||
setSelectedRundownId,
|
|
||||||
}: RundownSelectProps) {
|
|
||||||
'use memo';
|
|
||||||
const options = projectRundowns.map(({ id, title }) => ({
|
|
||||||
value: id,
|
|
||||||
label: loadedRundownId === id ? `${title} (loaded)` : title,
|
|
||||||
}));
|
|
||||||
options.unshift({
|
|
||||||
value: FOLLOW_LOADED_RUNDOWN_ID,
|
|
||||||
label: 'Follow loaded', // TODO: Better wording and maybe icon? and translation
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.rundownSelect}>
|
|
||||||
<Select
|
|
||||||
value={selectedRundownId ?? undefined}
|
|
||||||
options={options}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (value) {
|
|
||||||
setSelectedRundownId(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={cuesheetMode === AppMode.Run}
|
|
||||||
fluid
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import {
|
|
||||||
FOLLOW_LOADED_RUNDOWN_ID,
|
|
||||||
getCuesheetRundownStorageKey,
|
|
||||||
resolveSelectedRundownId,
|
|
||||||
} from '../useCuesheetRundownSelection';
|
|
||||||
|
|
||||||
describe('useCuesheetRundownSelection helpers', () => {
|
|
||||||
it('builds a project-scoped storage key', () => {
|
|
||||||
expect(getCuesheetRundownStorageKey('http://localhost:4001', 'My Project')).toBe(
|
|
||||||
'cuesheet-selected-rundown:http://localhost:4001:My Project',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to the follow loaded rundown when the stored selection is missing', () => {
|
|
||||||
expect(resolveSelectedRundownId('missing', new Set(['loaded', 'other']))).toBe(FOLLOW_LOADED_RUNDOWN_ID);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the stored selection when it still exists in the current project', () => {
|
|
||||||
expect(resolveSelectedRundownId('other', new Set(['loaded', 'other']))).toBe('other');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
import { Rundown } from 'ontime-types';
|
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import Modal from '../../../common/components/modal/Modal';
|
import Modal from '../../../common/components/modal/Modal';
|
||||||
|
import { useContextRundownEditModal } from '../../../common/hooks-query/useContextRundown';
|
||||||
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
|
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
|
||||||
import { useEditModal } from './useEditModal';
|
import { useEditModal } from './useEditModal';
|
||||||
|
|
||||||
interface EntryEditModalProps {
|
|
||||||
rundown: Rundown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(EntryEditModal);
|
export default memo(EntryEditModal);
|
||||||
function EntryEditModal({ rundown }: EntryEditModalProps) {
|
function EntryEditModal() {
|
||||||
|
const { rundown } = useContextRundownEditModal();
|
||||||
const entryId = useEditModal((state) => state.selectedEntryId);
|
const entryId = useEditModal((state) => state.selectedEntryId);
|
||||||
const closeModal = useEditModal((state) => state.clearSelection);
|
const closeModal = useEditModal((state) => state.clearSelection);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useTableNav } from '@table-nav/react';
|
import { useTableNav } from '@table-nav/react';
|
||||||
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||||
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
import { ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
ContextProp,
|
ContextProp,
|
||||||
ItemProps,
|
ItemProps,
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import EmptyPage from '../../../common/components/state/EmptyPage';
|
import EmptyPage from '../../../common/components/state/EmptyPage';
|
||||||
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
|
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
|
import { useContextRundownTable } from '../../../common/hooks-query/useContextRundown';
|
||||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||||
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
|
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
|
||||||
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
||||||
@@ -35,34 +35,22 @@ import style from './CuesheetTable.module.scss';
|
|||||||
type CuesheetTableBaseProps = {
|
type CuesheetTableBaseProps = {
|
||||||
columns: ColumnDef<ExtendedEntry>[];
|
columns: ColumnDef<ExtendedEntry>[];
|
||||||
cuesheetMode: AppMode;
|
cuesheetMode: AppMode;
|
||||||
source: RundownSource;
|
|
||||||
insertElement?: ReactNode;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditorCuesheetTableProps = CuesheetTableBaseProps & {
|
type EditorCuesheetTableProps = CuesheetTableBaseProps & {
|
||||||
tableRoot: 'editor';
|
tableRoot: 'editor';
|
||||||
setCuesheetMode?: undefined;
|
setCuesheetMode?: undefined;
|
||||||
isCurrentRundown?: undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ViewCuesheetTableProps = CuesheetTableBaseProps & {
|
type ViewCuesheetTableProps = CuesheetTableBaseProps & {
|
||||||
tableRoot: 'cuesheet';
|
tableRoot: 'cuesheet';
|
||||||
setCuesheetMode: (mode: AppMode) => void;
|
setCuesheetMode: (mode: AppMode) => void;
|
||||||
isCurrentRundown?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
|
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
|
||||||
|
|
||||||
export default function CuesheetTable({
|
export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCuesheetMode }: CuesheetTableProps) {
|
||||||
columns,
|
const { flatRundown, status, loadedEventId } = useContextRundownTable();
|
||||||
cuesheetMode,
|
|
||||||
source,
|
|
||||||
tableRoot,
|
|
||||||
setCuesheetMode,
|
|
||||||
isCurrentRundown,
|
|
||||||
insertElement,
|
|
||||||
}: CuesheetTableProps) {
|
|
||||||
const { flatRundown, status, selectedEventId } = source;
|
|
||||||
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
|
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
|
||||||
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
|
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
|
||||||
|
|
||||||
@@ -143,17 +131,17 @@ export default function CuesheetTable({
|
|||||||
|
|
||||||
// in Run mode, follow the current event
|
// in Run mode, follow the current event
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !selectedEventId) {
|
if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !loadedEventId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventIndex = flatRundown.findIndex((event) => event.id === selectedEventId);
|
const eventIndex = flatRundown.findIndex((event) => event.id === loadedEventId);
|
||||||
if (eventIndex === -1) {
|
if (eventIndex === -1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
|
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
|
||||||
}, [cuesheetMode, flatRundown, selectedEventId]);
|
}, [cuesheetMode, flatRundown, loadedEventId]);
|
||||||
|
|
||||||
// Provide an imperative scroll handler for explicit jumps (finder/keyboard)
|
// Provide an imperative scroll handler for explicit jumps (finder/keyboard)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -242,17 +230,9 @@ export default function CuesheetTable({
|
|||||||
handleResetResizing={resetColumnResizing}
|
handleResetResizing={resetColumnResizing}
|
||||||
handleResetReordering={resetColumnOrder}
|
handleResetReordering={resetColumnOrder}
|
||||||
handleClearToggles={setAllVisible}
|
handleClearToggles={setAllVisible}
|
||||||
insertElement={insertElement}
|
appMode={cuesheetMode}
|
||||||
modeControls={
|
tableRoot={tableRoot}
|
||||||
tableRoot === 'cuesheet'
|
setCuesheetMode={setCuesheetMode}
|
||||||
? {
|
|
||||||
cuesheetMode,
|
|
||||||
setCuesheetMode,
|
|
||||||
isCurrentRundown,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
showShare={tableRoot === 'cuesheet'}
|
|
||||||
/>
|
/>
|
||||||
<TableVirtuoso
|
<TableVirtuoso
|
||||||
ref={virtuosoRef}
|
ref={virtuosoRef}
|
||||||
|
|||||||
+23
-27
@@ -10,7 +10,9 @@ import Button from '../../../../common/components/buttons/Button';
|
|||||||
import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
||||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||||
import PopoverContents from '../../../../common/components/popover/Popover';
|
import PopoverContents from '../../../../common/components/popover/Popover';
|
||||||
|
import { useRundownSelectionContext } from '../../../../common/context/RundownSelectionContext';
|
||||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||||
|
import { RundownSelect } from '../../../../features/rundown/common/RundownSelect';
|
||||||
import { AppMode } from '../../../../ontimeConfig';
|
import { AppMode } from '../../../../ontimeConfig';
|
||||||
import { useCuesheetPermissions } from '../../useTablePermissions';
|
import { useCuesheetPermissions } from '../../useTablePermissions';
|
||||||
import CuesheetShareModal from './CuesheetShareModal';
|
import CuesheetShareModal from './CuesheetShareModal';
|
||||||
@@ -30,21 +32,15 @@ type TableHeaderOptionValues = Pick<
|
|||||||
'hideTableSeconds' | 'hideIndexColumn' | 'showDelayedTimes' | 'hideDelays'
|
'hideTableSeconds' | 'hideIndexColumn' | 'showDelayedTimes' | 'hideDelays'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type TableModeControls = {
|
|
||||||
cuesheetMode: AppMode;
|
|
||||||
setCuesheetMode: (mode: AppMode) => void;
|
|
||||||
isCurrentRundown?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CuesheetTableHeaderToolbarProps {
|
interface CuesheetTableHeaderToolbarProps {
|
||||||
columns: Column<ExtendedEntry, unknown>[];
|
columns: Column<ExtendedEntry, unknown>[];
|
||||||
optionsStore: TableHeaderOptionsStore;
|
optionsStore: TableHeaderOptionsStore;
|
||||||
handleResetResizing: () => void;
|
handleResetResizing: () => void;
|
||||||
handleResetReordering: () => void;
|
handleResetReordering: () => void;
|
||||||
handleClearToggles: () => void;
|
handleClearToggles: () => void;
|
||||||
insertElement?: ReactNode;
|
setCuesheetMode?: (mode: AppMode) => void;
|
||||||
modeControls?: TableModeControls;
|
appMode: AppMode;
|
||||||
showShare?: boolean;
|
tableRoot: 'editor' | 'cuesheet';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CuesheetTableHeaderToolbar({
|
export default function CuesheetTableHeaderToolbar({
|
||||||
@@ -53,23 +49,23 @@ export default function CuesheetTableHeaderToolbar({
|
|||||||
handleResetResizing,
|
handleResetResizing,
|
||||||
handleResetReordering,
|
handleResetReordering,
|
||||||
handleClearToggles,
|
handleClearToggles,
|
||||||
insertElement,
|
setCuesheetMode,
|
||||||
modeControls,
|
tableRoot,
|
||||||
showShare = false,
|
appMode,
|
||||||
}: CuesheetTableHeaderToolbarProps) {
|
}: CuesheetTableHeaderToolbarProps) {
|
||||||
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode);
|
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode) && tableRoot === 'cuesheet';
|
||||||
const canShare = useCuesheetPermissions((state) => state.canShare);
|
const canShare = useCuesheetPermissions((state) => state.canShare) && tableRoot === 'cuesheet';
|
||||||
|
const showRundownSelect = tableRoot === 'cuesheet';
|
||||||
|
const { isLoadedRundown } = useRundownSelectionContext();
|
||||||
|
|
||||||
const toggleCuesheetMode = (mode: AppMode[]) => {
|
const toggleCuesheetMode = (mode: AppMode[]) => {
|
||||||
const newValue = mode.at(0);
|
const newValue = mode.at(0);
|
||||||
if (!newValue || !modeControls) return;
|
if (!newValue || !setCuesheetMode) return;
|
||||||
modeControls.setCuesheetMode(newValue);
|
setCuesheetMode(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isBackground = !(modeControls?.isCurrentRundown ?? true);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Toolbar.Root className={style.tableSettings} data-background-rundown={isBackground}>
|
<Toolbar.Root className={style.tableSettings} data-background-rundown={!isLoadedRundown}>
|
||||||
<ViewSettings optionsStore={optionsStore} />
|
<ViewSettings optionsStore={optionsStore} />
|
||||||
<ColumnSettings
|
<ColumnSettings
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@@ -77,14 +73,14 @@ export default function CuesheetTableHeaderToolbar({
|
|||||||
handleResetReordering={handleResetReordering}
|
handleResetReordering={handleResetReordering}
|
||||||
handleClearToggles={handleClearToggles}
|
handleClearToggles={handleClearToggles}
|
||||||
/>
|
/>
|
||||||
{modeControls && canChangeMode && (
|
<div className={style.apart}>
|
||||||
<div className={style.apart}>
|
{showRundownSelect && <RundownSelect appMode={appMode} />}
|
||||||
{insertElement}
|
{canChangeMode && (
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
value={[modeControls.cuesheetMode]}
|
value={[appMode]}
|
||||||
onValueChange={toggleCuesheetMode}
|
onValueChange={toggleCuesheetMode}
|
||||||
className={style.group}
|
className={style.group}
|
||||||
disabled={!modeControls.isCurrentRundown}
|
disabled={!isLoadedRundown}
|
||||||
>
|
>
|
||||||
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
|
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
|
||||||
Run
|
Run
|
||||||
@@ -93,10 +89,10 @@ export default function CuesheetTableHeaderToolbar({
|
|||||||
Edit
|
Edit
|
||||||
</Toolbar.Button>
|
</Toolbar.Button>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{showShare && canShare && (
|
{canShare && (
|
||||||
<>
|
<>
|
||||||
<Editor.Separator orientation='vertical' />
|
<Editor.Separator orientation='vertical' />
|
||||||
<CuesheetShareModal />
|
<CuesheetShareModal />
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import { useSessionStorage } from '@mantine/hooks';
|
|
||||||
import { startTransition, useCallback, useMemo } from 'react';
|
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
|
|
||||||
import { useOrderedProjectList } from '../../common/hooks-query/useProjectList';
|
|
||||||
import { useProjectRundowns } from '../../common/hooks-query/useProjectRundowns';
|
|
||||||
import { serverURL } from '../../externals';
|
|
||||||
|
|
||||||
export const FOLLOW_LOADED_RUNDOWN_ID = '__follow-loaded__' as const;
|
|
||||||
|
|
||||||
export function getCuesheetRundownStorageKey(server: string, projectFilename: string) {
|
|
||||||
return `cuesheet-selected-rundown:${server}:${projectFilename}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveSelectedRundownId(storedSelectedRundownId: string | null, availableRundownIds: Set<string>) {
|
|
||||||
if (storedSelectedRundownId && availableRundownIds.has(storedSelectedRundownId)) return storedSelectedRundownId;
|
|
||||||
return FOLLOW_LOADED_RUNDOWN_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCuesheetRundownSelection() {
|
|
||||||
'use memo';
|
|
||||||
|
|
||||||
const { data: projectRundowns } = useProjectRundowns();
|
|
||||||
const {
|
|
||||||
data: { lastLoadedProject },
|
|
||||||
} = useOrderedProjectList();
|
|
||||||
const storageKey = useMemo(() => getCuesheetRundownStorageKey(serverURL, lastLoadedProject), [lastLoadedProject]);
|
|
||||||
const [storedSelectedRundownId, setStoredSelectedRundownId] = useSessionStorage<string | null>({
|
|
||||||
key: storageKey,
|
|
||||||
defaultValue: FOLLOW_LOADED_RUNDOWN_ID,
|
|
||||||
});
|
|
||||||
|
|
||||||
const availableRundownIds = new Set(projectRundowns.rundowns.map(({ id }) => id)).add(FOLLOW_LOADED_RUNDOWN_ID);
|
|
||||||
const { loaded: loadedRundownId } = projectRundowns;
|
|
||||||
|
|
||||||
const selectedRundownId = resolveSelectedRundownId(storedSelectedRundownId, availableRundownIds);
|
|
||||||
|
|
||||||
return {
|
|
||||||
loadedRundownId,
|
|
||||||
selectedRundownId,
|
|
||||||
projectRundowns: projectRundowns.rundowns,
|
|
||||||
setSelectedRundownId: (rundownId: string) => {
|
|
||||||
startTransition(() => {
|
|
||||||
setStoredSelectedRundownId(rundownId);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDirectLinkToBackgroundEdit() {
|
|
||||||
const {
|
|
||||||
data: { lastLoadedProject },
|
|
||||||
} = useOrderedProjectList();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const storageKey = getCuesheetRundownStorageKey(serverURL, lastLoadedProject);
|
|
||||||
const [_, setStoredSelectedRundownId] = useSessionStorage<string | null>({ key: storageKey, defaultValue: null });
|
|
||||||
|
|
||||||
return useCallback(
|
|
||||||
async (rundownId: string) => {
|
|
||||||
await navigate('/cuesheet');
|
|
||||||
startTransition(() => setStoredSelectedRundownId(rundownId));
|
|
||||||
},
|
|
||||||
[setStoredSelectedRundownId, navigate],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { lazy } from 'react';
|
import { lazy } from 'react';
|
||||||
|
|
||||||
|
import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext';
|
||||||
import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar';
|
import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar';
|
||||||
import { AppMode } from '../../ontimeConfig';
|
import { AppMode } from '../../ontimeConfig';
|
||||||
import TitleList from './title-list/TitleList';
|
import TitleList from './title-list/TitleList';
|
||||||
@@ -14,44 +15,51 @@ const MessageControl = lazy(() => import('../../features/control/message/Message
|
|||||||
export default function Editor() {
|
export default function Editor() {
|
||||||
const { layoutMode } = useEditorLayout();
|
const { layoutMode } = useEditorLayout();
|
||||||
|
|
||||||
if (layoutMode === EditorLayoutMode.CONTROL) {
|
switch (layoutMode) {
|
||||||
return (
|
case EditorLayoutMode.TRACKING: {
|
||||||
<div id='panels' className={styles.panelContainer}>
|
return (
|
||||||
<div className={styles.left}>
|
<div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}>
|
||||||
<TimerControl />
|
<div className={styles.rundownLayout}>
|
||||||
<MessageControl />
|
<div className={styles.titlesPanel}>
|
||||||
</div>
|
<RundownSelectionContextProvider>
|
||||||
<Rundown />
|
<TitleList mode={AppMode.Run} />
|
||||||
</div>
|
</RundownSelectionContextProvider>
|
||||||
);
|
</div>
|
||||||
}
|
<div className={styles.rundownPanel}>
|
||||||
|
<Rundown />
|
||||||
if (layoutMode === EditorLayoutMode.TRACKING) {
|
</div>
|
||||||
return (
|
|
||||||
<div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}>
|
|
||||||
<div className={styles.rundownLayout}>
|
|
||||||
<div className={styles.titlesPanel}>
|
|
||||||
<TitleList mode={AppMode.Run} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.rundownPanel}>
|
<TrackingPlaybackBar />
|
||||||
<Rundown />
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case EditorLayoutMode.PLANNING: {
|
||||||
|
return (
|
||||||
|
<div id='panels' className={styles.panelContainer}>
|
||||||
|
<div className={styles.rundownLayout}>
|
||||||
|
<div className={styles.titlesPanel}>
|
||||||
|
<RundownSelectionContextProvider>
|
||||||
|
<TitleList mode={AppMode.Edit} />
|
||||||
|
</RundownSelectionContextProvider>
|
||||||
|
</div>
|
||||||
|
<div className={styles.rundownPanel}>
|
||||||
|
<Rundown />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TrackingPlaybackBar />
|
);
|
||||||
</div>
|
}
|
||||||
);
|
case EditorLayoutMode.CONTROL:
|
||||||
}
|
default: {
|
||||||
|
return (
|
||||||
return (
|
<div id='panels' className={styles.panelContainer}>
|
||||||
<div id='panels' className={styles.panelContainer}>
|
<div className={styles.left}>
|
||||||
<div className={styles.rundownLayout}>
|
<TimerControl />
|
||||||
<div className={styles.titlesPanel}>
|
<MessageControl />
|
||||||
<TitleList mode={AppMode.Edit} />
|
</div>
|
||||||
</div>
|
|
||||||
<div className={styles.rundownPanel}>
|
|
||||||
<Rundown />
|
<Rundown />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
}
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ function EditorLayoutOptions() {
|
|||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Planning',
|
label: 'Planning',
|
||||||
description: 'Edit-focused list with planning stats',
|
description: 'Edit focused list with planning stats',
|
||||||
icon: layoutMode === EditorLayoutMode.PLANNING ? IoCheckmark : undefined,
|
icon: layoutMode === EditorLayoutMode.PLANNING ? IoCheckmark : undefined,
|
||||||
onClick: () => setLayoutMode(EditorLayoutMode.PLANNING),
|
onClick: () => setLayoutMode(EditorLayoutMode.PLANNING),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||||
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
|
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
|
import { useContextRundownFinder } from '../../../common/hooks-query/useContextRundown';
|
||||||
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
|
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
|
||||||
|
|
||||||
const maxResults = 12;
|
const maxResults = 12;
|
||||||
@@ -38,7 +38,7 @@ type FilterableMilestone = {
|
|||||||
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
|
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
|
||||||
|
|
||||||
export default function useFinder() {
|
export default function useFinder() {
|
||||||
const { data, rundownId } = useFlatRundown();
|
const { rundown: data, rundownId } = useContextRundownFinder();
|
||||||
const [results, setResults] = useState<FilterableEntry[]>([]);
|
const [results, setResults] = useState<FilterableEntry[]>([]);
|
||||||
const [error, setError] = useState<MaybeString>(null);
|
const [error, setError] = useState<MaybeString>(null);
|
||||||
const lastSearchString = useRef('');
|
const lastSearchString = useRef('');
|
||||||
|
|||||||
@@ -28,9 +28,9 @@
|
|||||||
|
|
||||||
.timer-container {
|
.timer-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
align-content: center;
|
display: flex;
|
||||||
justify-self: center;
|
flex-direction: column;
|
||||||
align-self: center;
|
justify-content: center;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|||||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||||
|
|
||||||
import ScrollArea from '../../../common/components/scroll-area/ScrollArea';
|
import ScrollArea from '../../../common/components/scroll-area/ScrollArea';
|
||||||
import useRundown from '../../../common/hooks-query/useRundown';
|
import { useContextRundownList } from '../../../common/hooks-query/useContextRundown';
|
||||||
import { useSelectedEventId } from '../../../common/hooks/useSocket';
|
import { useSelectedEventId } from '../../../common/hooks/useSocket';
|
||||||
import { ExtendedEntry, getFlatRundownMetadata } from '../../../common/utils/rundownMetadata';
|
import { ExtendedEntry, getFlatRundownMetadata } from '../../../common/utils/rundownMetadata';
|
||||||
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
||||||
@@ -20,7 +20,7 @@ interface TitleListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TitleList({ mode }: TitleListProps) {
|
export default function TitleList({ mode }: TitleListProps) {
|
||||||
const { data: rundown } = useRundown();
|
const { rundown } = useContextRundownList();
|
||||||
const selectedEventId = useSelectedEventId();
|
const selectedEventId = useSelectedEventId();
|
||||||
const cursor = useEventSelection((state) => state.cursor);
|
const cursor = useEventSelection((state) => state.cursor);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { isValueOfEnum } from 'ontime-utils';
|
import { isValueOfEnum } from 'ontime-utils';
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
import { setSelectRundownInParams } from '../../common/context/RundownSelectionContext';
|
||||||
|
|
||||||
const layoutParam = 'layout';
|
const layoutParam = 'layout';
|
||||||
|
|
||||||
export enum EditorLayoutMode {
|
export enum EditorLayoutMode {
|
||||||
@@ -20,14 +23,29 @@ function getEditorLayout(value: string | null): EditorLayoutMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useEditorLayout() {
|
export function useEditorLayout() {
|
||||||
|
'use memo';
|
||||||
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const layoutMode = getEditorLayout(searchParams.get(layoutParam));
|
const layoutMode = getEditorLayout(searchParams.get(layoutParam));
|
||||||
|
|
||||||
const setLayoutMode = (mode: EditorLayoutMode) => {
|
useEffect(() => {
|
||||||
const nextParams = new URLSearchParams(searchParams);
|
setSearchParams((searchParams) => {
|
||||||
nextParams.set(layoutParam, mode);
|
if (layoutMode !== EditorLayoutMode.PLANNING) setSelectRundownInParams(null, searchParams);
|
||||||
setSearchParams(nextParams, { replace: true });
|
return searchParams;
|
||||||
};
|
});
|
||||||
|
}, [setSearchParams, layoutMode]);
|
||||||
|
|
||||||
|
const setLayoutMode = useCallback(
|
||||||
|
(mode: EditorLayoutMode) => {
|
||||||
|
setSearchParams((searchParams) => {
|
||||||
|
searchParams.set(layoutParam, mode);
|
||||||
|
// Only the Planning layout is allowed to look at something other than the current rundown
|
||||||
|
if (mode !== EditorLayoutMode.PLANNING) setSelectRundownInParams(null, searchParams);
|
||||||
|
return searchParams;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
return { layoutMode, setLayoutMode };
|
return { layoutMode, setLayoutMode };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export interface TimelineData {
|
|||||||
|
|
||||||
export function useTimelineData(): ViewData<TimelineData> {
|
export function useTimelineData(): ViewData<TimelineData> {
|
||||||
// HTTP API data
|
// HTTP API data
|
||||||
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
|
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata(null);
|
||||||
const { data: projectData, status: projectDataStatus } = useProjectData();
|
const { data: projectData, status: projectDataStatus } = useProjectData();
|
||||||
const { data: settings, status: settingsStatus } = useSettings();
|
const { data: settings, status: settingsStatus } = useSettings();
|
||||||
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
||||||
|
|||||||
@@ -82,9 +82,9 @@
|
|||||||
|
|
||||||
.timer-container {
|
.timer-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
align-content: center;
|
display: flex;
|
||||||
justify-self: center;
|
flex-direction: column;
|
||||||
align-self: center;
|
justify-content: center;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { CustomFields, ProjectData, RundownEntries, Settings, ViewSettings } fro
|
|||||||
|
|
||||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||||
import useProjectData from '../../common/hooks-query/useProjectData';
|
import useProjectData from '../../common/hooks-query/useProjectData';
|
||||||
import useRundown from '../../common/hooks-query/useRundown';
|
import { useRundown } from '../../common/hooks-query/useRundown';
|
||||||
import useSettings from '../../common/hooks-query/useSettings';
|
import useSettings from '../../common/hooks-query/useSettings';
|
||||||
import useViewSettings from '../../common/hooks-query/useViewSettings';
|
import useViewSettings from '../../common/hooks-query/useViewSettings';
|
||||||
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
||||||
@@ -26,7 +26,7 @@ export function useTimerData(): ViewData<TimerData> {
|
|||||||
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
|
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
|
||||||
const { data: settings, status: settingsStatus } = useSettings();
|
const { data: settings, status: settingsStatus } = useSettings();
|
||||||
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
||||||
const { data: rundown, status: rundownStatus } = useRundown();
|
const { data: rundown, status: rundownStatus } = useRundown(null);
|
||||||
const { entries } = rundown;
|
const { entries } = rundown;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-electron",
|
"name": "ontime-electron",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
@@ -125,6 +125,13 @@
|
|||||||
"**/*",
|
"**/*",
|
||||||
"*{.ts}"
|
"*{.ts}"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "../server/src/html/",
|
||||||
|
"to": "extraResources/html/",
|
||||||
|
"filter": [
|
||||||
|
"**/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/resolver",
|
"name": "@getontime/resolver",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
"types": "./dist/main.d.ts",
|
"types": "./dist/main.d.ts",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
|
|||||||
@@ -432,6 +432,40 @@ describe('processRundown()', () => {
|
|||||||
expect(initResult.totalDuration).toBe(500 - 100);
|
expect(initResult.totalDuration).toBe(500 - 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('skipped events do not advance day offsets or affect gaps', () => {
|
||||||
|
const rundown = makeRundown({
|
||||||
|
order: ['1', 'skipped', '2'],
|
||||||
|
entries: {
|
||||||
|
'1': makeOntimeEvent({
|
||||||
|
id: '1',
|
||||||
|
timeStart: 22 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 22:30:00
|
||||||
|
timeEnd: 30 * MILLIS_PER_MINUTE, // 00:30:00
|
||||||
|
duration: 2 * MILLIS_PER_HOUR, // 02:00:00
|
||||||
|
}),
|
||||||
|
skipped: makeOntimeEvent({
|
||||||
|
id: 'skipped',
|
||||||
|
skip: true,
|
||||||
|
timeStart: 22 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 22:30:00
|
||||||
|
timeEnd: 30 * MILLIS_PER_MINUTE, // 00:30:00
|
||||||
|
duration: 2 * MILLIS_PER_HOUR, // 02:00:00
|
||||||
|
}),
|
||||||
|
'2': makeOntimeEvent({
|
||||||
|
id: '2',
|
||||||
|
timeStart: 30 * MILLIS_PER_MINUTE, // 00:30:00
|
||||||
|
timeEnd: 8 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 08:30:00
|
||||||
|
duration: 8 * MILLIS_PER_HOUR, // 08:00:00
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const initResult = processRundown(rundown, {});
|
||||||
|
|
||||||
|
expect((initResult.entries.skipped as OntimeEvent).dayOffset).toBe(0);
|
||||||
|
expect((initResult.entries.skipped as OntimeEvent).gap).toBe(0);
|
||||||
|
expect((initResult.entries['2'] as OntimeEvent).dayOffset).toBe(1);
|
||||||
|
expect((initResult.entries['2'] as OntimeEvent).gap).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('calculates total duration across days with gap', () => {
|
it('calculates total duration across days with gap', () => {
|
||||||
const rundown = makeRundown({
|
const rundown = makeRundown({
|
||||||
order: ['1', '2', '3'],
|
order: ['1', '2', '3'],
|
||||||
@@ -680,17 +714,17 @@ describe('processRundown()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('rundownMutation.add()', () => {
|
describe('rundownMutation.add()', () => {
|
||||||
test('adds an event an empty rundown', () => {
|
test('adds an event to an empty rundown', () => {
|
||||||
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
|
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
|
||||||
const rundown = makeRundown({});
|
const rundown = makeRundown({});
|
||||||
|
|
||||||
rundownMutation.add(rundown, mockEvent, null, null);
|
rundownMutation.add(rundown, mockEvent, null, null, null);
|
||||||
|
|
||||||
expect(rundown.order.length).toBe(1);
|
expect(rundown.order.length).toBe(1);
|
||||||
expect(rundown.entries['mock']).toMatchObject(mockEvent);
|
expect(rundown.entries['mock']).toMatchObject(mockEvent);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('adds an event at the top if no afterId is given', () => {
|
test('adds an event at the end if no afterId is given', () => {
|
||||||
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
|
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
|
||||||
const rundown = makeRundown({
|
const rundown = makeRundown({
|
||||||
flatOrder: ['1'],
|
flatOrder: ['1'],
|
||||||
@@ -700,28 +734,28 @@ describe('rundownMutation.add()', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
rundownMutation.add(rundown, mockEvent, null, null);
|
rundownMutation.add(rundown, mockEvent, null, null, null);
|
||||||
|
|
||||||
expect(rundown.order).toStrictEqual(['mock', '1']);
|
expect(rundown.order).toStrictEqual(['1', 'mock']);
|
||||||
expect(rundown.flatOrder).toStrictEqual(['mock', '1']);
|
expect(rundown.flatOrder).toStrictEqual(['1', 'mock']);
|
||||||
expect(rundown.entries['mock']).toMatchObject(mockEvent);
|
expect(rundown.entries['mock']).toMatchObject(mockEvent);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('adds an event at the top of the group if no after is given', () => {
|
test('adds an event at the end of the group if parent is provided but no after', () => {
|
||||||
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
|
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
|
||||||
const rundown = makeRundown({
|
const rundown = makeRundown({
|
||||||
flatOrder: ['1', '1a'],
|
flatOrder: ['1', '1a'],
|
||||||
order: ['1'],
|
order: ['1'],
|
||||||
entries: {
|
entries: {
|
||||||
'1': makeOntimeGroup({ id: '1' }),
|
'1': makeOntimeGroup({ id: '1', entries: ['1a'] }),
|
||||||
'1a': makeOntimeEvent({ id: '1a', parent: '1' }),
|
'1a': makeOntimeEvent({ id: '1a', parent: '1' }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
rundownMutation.add(rundown, mockEvent, null, rundown.entries['1'] as OntimeGroup);
|
rundownMutation.add(rundown, mockEvent, rundown.entries['1'] as OntimeGroup, null, null);
|
||||||
|
|
||||||
expect(rundown.order).toStrictEqual(['1']);
|
expect(rundown.order).toStrictEqual(['1']);
|
||||||
expect(rundown.flatOrder).toStrictEqual(['1', 'mock', '1a']);
|
expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']);
|
||||||
expect(rundown.entries['mock']).toMatchObject(mockEvent);
|
expect(rundown.entries['mock']).toMatchObject(mockEvent);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -731,12 +765,12 @@ describe('rundownMutation.add()', () => {
|
|||||||
flatOrder: ['1', '1a'],
|
flatOrder: ['1', '1a'],
|
||||||
order: ['1'],
|
order: ['1'],
|
||||||
entries: {
|
entries: {
|
||||||
'1': makeOntimeGroup({ id: '1' }),
|
'1': makeOntimeGroup({ id: '1', entries: ['1a'] }),
|
||||||
'1a': makeOntimeEvent({ id: '1a', parent: '1' }),
|
'1a': makeOntimeEvent({ id: '1a', parent: '1' }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
rundownMutation.add(rundown, mockEvent, '1a', rundown.entries['1'] as OntimeGroup);
|
rundownMutation.add(rundown, mockEvent, rundown.entries['1'] as OntimeGroup, '1a', null);
|
||||||
|
|
||||||
expect(rundown.order).toStrictEqual(['1']);
|
expect(rundown.order).toStrictEqual(['1']);
|
||||||
expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']);
|
expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']);
|
||||||
@@ -1805,6 +1839,49 @@ describe('rundownMutation.clone()', () => {
|
|||||||
parent: null,
|
parent: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clones an event and appends it with after true', () => {
|
||||||
|
const testRundown = makeRundown({
|
||||||
|
order: ['1', '2'],
|
||||||
|
entries: {
|
||||||
|
'1': makeOntimeEvent({ id: '1', cue: 'event1', parent: null }),
|
||||||
|
'2': makeOntimeEvent({ id: '2', cue: 'event2', parent: null }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['1'], { after: true });
|
||||||
|
|
||||||
|
expect(testRundown.order).toStrictEqual(['1', '2', newEntry.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clones an event and prepends it with before true', () => {
|
||||||
|
const testRundown = makeRundown({
|
||||||
|
order: ['1', '2'],
|
||||||
|
entries: {
|
||||||
|
'1': makeOntimeEvent({ id: '1', cue: 'event1', parent: null }),
|
||||||
|
'2': makeOntimeEvent({ id: '2', cue: 'event2', parent: null }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['2'], { before: true });
|
||||||
|
|
||||||
|
expect(testRundown.order).toStrictEqual([newEntry.id, '1', '2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clones a group and appends it with after true', () => {
|
||||||
|
const testRundown = makeRundown({
|
||||||
|
order: ['1', '2'],
|
||||||
|
entries: {
|
||||||
|
'1': makeOntimeGroup({ id: '1', title: 'top', entries: ['1a'] }),
|
||||||
|
'1a': makeOntimeEvent({ id: '1a', cue: 'nested', parent: '1' }),
|
||||||
|
'2': makeOntimeEvent({ id: '2', cue: 'event2', parent: null }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['1'], { after: true });
|
||||||
|
|
||||||
|
expect(testRundown.order).toStrictEqual(['1', '2', newEntry.id]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('rundownMutation.group()', () => {
|
describe('rundownMutation.group()', () => {
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import { EndAction, OntimeEvent, TimeStrategy, TimerType } from 'ontime-types';
|
import {
|
||||||
|
EndAction,
|
||||||
|
OntimeEvent,
|
||||||
|
OntimeGroup,
|
||||||
|
OntimeMilestone,
|
||||||
|
TimeStrategy,
|
||||||
|
TimerLifeCycle,
|
||||||
|
TimerType,
|
||||||
|
Trigger,
|
||||||
|
} from 'ontime-types';
|
||||||
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
|
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
|
||||||
import { assertType } from 'vitest';
|
import { assertType } from 'vitest';
|
||||||
|
|
||||||
import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js';
|
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||||
|
import { parseRundown } from '../rundown.parser.js';
|
||||||
import {
|
import {
|
||||||
calculateDayOffset,
|
calculateDayOffset,
|
||||||
deleteById,
|
deleteById,
|
||||||
@@ -10,6 +20,8 @@ import {
|
|||||||
getIntegerAndFraction,
|
getIntegerAndFraction,
|
||||||
hasChanges,
|
hasChanges,
|
||||||
makeDeepClone,
|
makeDeepClone,
|
||||||
|
mergeRundownPreservingFields,
|
||||||
|
isLoadedPlayable,
|
||||||
} from '../rundown.utils.js';
|
} from '../rundown.utils.js';
|
||||||
|
|
||||||
describe('test event validator', () => {
|
describe('test event validator', () => {
|
||||||
@@ -287,3 +299,314 @@ describe('getIntegerAndFraction()', () => {
|
|||||||
expect(getIntegerAndFraction('123.')).toStrictEqual({ integer: 123, faction: 0, precision: 0 });
|
expect(getIntegerAndFraction('123.')).toStrictEqual({ integer: 123, faction: 0, precision: 0 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The merge strategy takes the incoming (spreadsheet) rundown as the source of truth for structure
|
||||||
|
* and order. For a matched event it only applies the fields the spreadsheet mapped (providedFields);
|
||||||
|
* any field it does not provide (e.g. automations) is kept from the existing event.
|
||||||
|
*/
|
||||||
|
describe('mergeRundownPreservingFields()', () => {
|
||||||
|
const automation: Trigger = {
|
||||||
|
id: 'trigger-onair',
|
||||||
|
title: 'Go on air',
|
||||||
|
trigger: TimerLifeCycle.onStart,
|
||||||
|
automationId: 'automation-onair',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('keeps the current rundown identity but takes structure and order from the incoming rundown', () => {
|
||||||
|
const current = makeRundown({
|
||||||
|
id: 'show-rundown',
|
||||||
|
title: 'Main show',
|
||||||
|
revision: 3,
|
||||||
|
order: ['welcome', 'keynote'],
|
||||||
|
entries: {
|
||||||
|
welcome: makeOntimeEvent({ id: 'welcome', title: 'Welcome' }),
|
||||||
|
keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote' }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const incoming = makeRundown({
|
||||||
|
id: 'spreadsheet-rundown',
|
||||||
|
title: 'From spreadsheet',
|
||||||
|
revision: 0,
|
||||||
|
order: ['welcome', 'lunch'],
|
||||||
|
entries: {
|
||||||
|
welcome: makeOntimeEvent({ id: 'welcome', title: 'Welcome' }),
|
||||||
|
lunch: makeOntimeEvent({ id: 'lunch', title: 'Lunch' }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
||||||
|
|
||||||
|
// identity and revision come from the current rundown
|
||||||
|
expect(merged.id).toBe('show-rundown');
|
||||||
|
expect(merged.title).toBe('Main show');
|
||||||
|
expect(merged.revision).toBe(4);
|
||||||
|
// structure and order come from the incoming rundown
|
||||||
|
expect(merged.order).toEqual(['welcome', 'lunch']);
|
||||||
|
expect(merged.flatOrder).toEqual(incoming.flatOrder);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes current entries that are absent from the incoming rundown', () => {
|
||||||
|
const current = makeRundown({
|
||||||
|
id: 'show-rundown',
|
||||||
|
order: ['welcome', 'keynote'],
|
||||||
|
entries: {
|
||||||
|
welcome: makeOntimeEvent({ id: 'welcome' }),
|
||||||
|
keynote: makeOntimeEvent({ id: 'keynote' }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const incoming = makeRundown({
|
||||||
|
id: 'spreadsheet-rundown',
|
||||||
|
order: ['welcome'],
|
||||||
|
entries: { welcome: makeOntimeEvent({ id: 'welcome' }) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
||||||
|
|
||||||
|
expect(merged.entries.welcome).toBeDefined();
|
||||||
|
expect(merged.entries.keynote).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces an entry entirely with the incoming data when the id is kept but the type changes', () => {
|
||||||
|
const current = makeRundown({
|
||||||
|
id: 'show-rundown',
|
||||||
|
order: ['keynote'],
|
||||||
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', triggers: [automation] }) },
|
||||||
|
});
|
||||||
|
const incoming = makeRundown({
|
||||||
|
id: 'spreadsheet-rundown',
|
||||||
|
order: ['keynote'],
|
||||||
|
entries: { keynote: makeOntimeGroup({ id: 'keynote', title: 'Keynote group' }) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
||||||
|
|
||||||
|
// the incoming group fully replaces the previous event, no old data is carried over
|
||||||
|
expect(merged.entries.keynote).toEqual(incoming.entries.keynote);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the provided fields to a matched event, including when the incoming value is empty', () => {
|
||||||
|
const current = makeRundown({
|
||||||
|
id: 'show-rundown',
|
||||||
|
order: ['keynote'],
|
||||||
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', note: 'in the green room' }) },
|
||||||
|
});
|
||||||
|
const incoming = makeRundown({
|
||||||
|
id: 'spreadsheet-rundown',
|
||||||
|
order: ['keynote'],
|
||||||
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Opening keynote', note: '' }) },
|
||||||
|
});
|
||||||
|
|
||||||
|
// the sheet mapped title and note
|
||||||
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title', 'note'], custom: [] });
|
||||||
|
const keynote = merged.entries.keynote as OntimeEvent;
|
||||||
|
|
||||||
|
expect(keynote.title).toBe('Opening keynote');
|
||||||
|
// an empty provided value replaces the current one
|
||||||
|
expect(keynote.note).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps fields the sheet did not map on a matched event, regardless of the incoming values', () => {
|
||||||
|
const current = makeRundown({
|
||||||
|
id: 'show-rundown',
|
||||||
|
order: ['keynote'],
|
||||||
|
entries: {
|
||||||
|
keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', note: 'green room', triggers: [automation] }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// the preview always fully populates an entry, so the incoming carries a note and triggers; what
|
||||||
|
// the sheet actually supplied is providedFields, not the values that happen to be on the entry
|
||||||
|
const incoming = makeRundown({
|
||||||
|
id: 'spreadsheet-rundown',
|
||||||
|
order: ['keynote'],
|
||||||
|
entries: {
|
||||||
|
keynote: makeOntimeEvent({
|
||||||
|
id: 'keynote',
|
||||||
|
title: 'Opening keynote',
|
||||||
|
note: 'from a stale column',
|
||||||
|
triggers: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// only title is mapped, so note and automations keep the existing values, not the incoming ones
|
||||||
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
||||||
|
const keynote = merged.entries.keynote as OntimeEvent;
|
||||||
|
|
||||||
|
expect(keynote.title).toBe('Opening keynote');
|
||||||
|
expect(keynote.note).toBe('green room');
|
||||||
|
expect(keynote.triggers).toEqual([automation]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('patches the provided custom fields on a matched event and keeps the unmapped ones', () => {
|
||||||
|
const current = makeRundown({
|
||||||
|
id: 'show-rundown',
|
||||||
|
order: ['keynote'],
|
||||||
|
entries: {
|
||||||
|
keynote: makeOntimeEvent({
|
||||||
|
id: 'keynote',
|
||||||
|
title: 'Keynote',
|
||||||
|
custom: { lighting: 'warm', song: 'intro theme' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const incoming = makeRundown({
|
||||||
|
id: 'spreadsheet-rundown',
|
||||||
|
order: ['keynote'],
|
||||||
|
entries: {
|
||||||
|
keynote: makeOntimeEvent({ id: 'keynote', title: 'Opening keynote', custom: { lighting: 'cold' } }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// the sheet mapped the title and the lighting custom field, but not song
|
||||||
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: ['lighting'] });
|
||||||
|
const keynote = merged.entries.keynote as OntimeEvent;
|
||||||
|
|
||||||
|
expect(keynote.title).toBe('Opening keynote');
|
||||||
|
expect(keynote.custom.lighting).toBe('cold');
|
||||||
|
// an unmapped custom field is kept from the existing event
|
||||||
|
expect(keynote.custom.song).toBe('intro theme');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('infers the time strategy from the provided times when the sheet is unambiguous', () => {
|
||||||
|
const current = makeRundown({
|
||||||
|
id: 'show-rundown',
|
||||||
|
order: ['keynote'],
|
||||||
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeStrategy: TimeStrategy.LockEnd }) },
|
||||||
|
});
|
||||||
|
// the sheet provides only a duration, so the strategy is unambiguously LockDuration
|
||||||
|
const incoming = makeRundown({
|
||||||
|
id: 'spreadsheet-rundown',
|
||||||
|
order: ['keynote'],
|
||||||
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', duration: 60000 }) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['duration'], custom: [] });
|
||||||
|
|
||||||
|
expect((merged.entries.keynote as OntimeEvent).timeStrategy).toBe(TimeStrategy.LockDuration);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the existing time strategy when the provided times are ambiguous', () => {
|
||||||
|
const current = makeRundown({
|
||||||
|
id: 'show-rundown',
|
||||||
|
order: ['keynote'],
|
||||||
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeStrategy: TimeStrategy.LockEnd }) },
|
||||||
|
});
|
||||||
|
// the sheet provides both an end and a duration, so the strategy cannot be inferred from them
|
||||||
|
const incoming = makeRundown({
|
||||||
|
id: 'spreadsheet-rundown',
|
||||||
|
order: ['keynote'],
|
||||||
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeEnd: 60000, duration: 60000 }) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['timeEnd', 'duration'], custom: [] });
|
||||||
|
|
||||||
|
expect((merged.entries.keynote as OntimeEvent).timeStrategy).toBe(TimeStrategy.LockEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges a matched group, keeping the fields the sheet cannot express', () => {
|
||||||
|
const current = makeRundown({
|
||||||
|
id: 'show-rundown',
|
||||||
|
order: ['session'],
|
||||||
|
entries: { session: makeOntimeGroup({ id: 'session', title: 'Old session', targetDuration: 3_600_000 }) },
|
||||||
|
});
|
||||||
|
// the sheet cannot express a group's target duration, so the incoming group does not carry one
|
||||||
|
const incoming = makeRundown({
|
||||||
|
id: 'spreadsheet-rundown',
|
||||||
|
order: ['session'],
|
||||||
|
entries: { session: makeOntimeGroup({ id: 'session', title: 'New session', targetDuration: null }) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
||||||
|
const session = merged.entries.session as OntimeGroup;
|
||||||
|
|
||||||
|
expect(session.title).toBe('New session');
|
||||||
|
expect(session.targetDuration).toBe(3_600_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges a matched milestone, keeping the fields the sheet did not map', () => {
|
||||||
|
const current = makeRundown({
|
||||||
|
id: 'show-rundown',
|
||||||
|
order: ['reminder'],
|
||||||
|
entries: { reminder: makeOntimeMilestone({ id: 'reminder', title: 'Reminder', note: 'call talent' }) },
|
||||||
|
});
|
||||||
|
const incoming = makeRundown({
|
||||||
|
id: 'spreadsheet-rundown',
|
||||||
|
order: ['reminder'],
|
||||||
|
entries: { reminder: makeOntimeMilestone({ id: 'reminder', title: 'Green room reminder', note: 'ignored' }) },
|
||||||
|
});
|
||||||
|
|
||||||
|
// only title is mapped, so the milestone's note keeps the existing value
|
||||||
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
||||||
|
const reminder = merged.entries.reminder as OntimeMilestone;
|
||||||
|
|
||||||
|
expect(reminder.title).toBe('Green room reminder');
|
||||||
|
expect(reminder.note).toBe('call talent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mutate the current rundown and deep-clones the kept automations', () => {
|
||||||
|
const current = makeRundown({
|
||||||
|
id: 'show-rundown',
|
||||||
|
order: ['keynote'],
|
||||||
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', triggers: [automation] }) },
|
||||||
|
});
|
||||||
|
const incoming = makeRundown({
|
||||||
|
id: 'spreadsheet-rundown',
|
||||||
|
order: ['keynote'],
|
||||||
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', triggers: [] }) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
||||||
|
|
||||||
|
// kept automations are a copy, not a shared reference to the current rundown
|
||||||
|
(merged.entries.keynote as OntimeEvent).triggers.push({ ...automation, id: 'trigger-extra' });
|
||||||
|
expect((current.entries.keynote as OntimeEvent).triggers).toEqual([automation]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the not-provided fields through a parseRundown round-trip', () => {
|
||||||
|
const current = makeRundown({
|
||||||
|
id: 'show-rundown',
|
||||||
|
order: ['keynote'],
|
||||||
|
entries: {
|
||||||
|
keynote: makeOntimeEvent({ id: 'keynote', triggers: [automation], timeStrategy: TimeStrategy.LockEnd }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const incoming = makeRundown({
|
||||||
|
id: 'spreadsheet-rundown',
|
||||||
|
order: ['keynote'],
|
||||||
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', triggers: [] }) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
||||||
|
const parsed = parseRundown(merged, {});
|
||||||
|
const keynote = parsed.entries.keynote as OntimeEvent;
|
||||||
|
|
||||||
|
expect(keynote.triggers).toEqual([automation]);
|
||||||
|
expect(keynote.timeStrategy).toBe(TimeStrategy.LockEnd);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isLoadedPlayable()', () => {
|
||||||
|
it('returns true when the loaded event still exists and is playable', () => {
|
||||||
|
const rundown = makeRundown({ order: ['keynote'], entries: { keynote: makeOntimeEvent({ id: 'keynote' }) } });
|
||||||
|
expect(isLoadedPlayable('keynote', rundown)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when the loaded event was removed', () => {
|
||||||
|
const rundown = makeRundown({ order: ['welcome'], entries: { welcome: makeOntimeEvent({ id: 'welcome' }) } });
|
||||||
|
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when the loaded event is now skipped', () => {
|
||||||
|
const rundown = makeRundown({
|
||||||
|
order: ['keynote'],
|
||||||
|
entries: { keynote: makeOntimeEvent({ id: 'keynote', skip: true }) },
|
||||||
|
});
|
||||||
|
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when the matched entry is no longer an event', () => {
|
||||||
|
const rundown = makeRundown({ order: ['keynote'], entries: { keynote: makeOntimeGroup({ id: 'keynote' }) } });
|
||||||
|
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { OntimeGroup } from 'ontime-types';
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||||
|
import { assertInsertAnchorExists, assertInsertAnchorInOrder } from '../rundown.validation.js';
|
||||||
|
|
||||||
|
const rundown = makeRundown({
|
||||||
|
order: ['top', 'group'],
|
||||||
|
entries: {
|
||||||
|
top: makeOntimeEvent({ id: 'top', parent: null }),
|
||||||
|
group: makeOntimeGroup({ id: 'group', entries: ['nested'] }),
|
||||||
|
nested: makeOntimeEvent({ id: 'nested', parent: 'group' }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('insertion anchor validation', () => {
|
||||||
|
test('rejects a missing anchor', () => {
|
||||||
|
expect(() => assertInsertAnchorInOrder(rundown, null, { before: 'missing' })).toThrow(
|
||||||
|
'Insertion anchor with ID missing does not exist',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects an anchor from a different order', () => {
|
||||||
|
expect(() => assertInsertAnchorInOrder(rundown, null, { after: 'nested' })).toThrow(
|
||||||
|
'Insertion anchor with ID nested is not in the target order',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts a group-local anchor', () => {
|
||||||
|
const group = rundown.entries.group as OntimeGroup;
|
||||||
|
|
||||||
|
expect(() => assertInsertAnchorInOrder(rundown, group, { before: 'nested' })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checks clone anchors exist', () => {
|
||||||
|
expect(() => assertInsertAnchorExists(rundown, { after: 'missing' })).toThrow(
|
||||||
|
'Insertion anchor with ID missing does not exist',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -439,21 +439,27 @@ function clone(rundown: Rundown, entry: OntimeEntry, options?: InsertOptions): O
|
|||||||
rundown.entries[nestedEntry.id] = nestedEntry;
|
rundown.entries[nestedEntry.id] = nestedEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
// by default we insert after the cloned element
|
const position: { after: EntryId | null; before: EntryId | null } = (() => {
|
||||||
let atIndex = rundown.order.indexOf(entry.id) + 1;
|
if (options?.after === undefined && options?.before === undefined) {
|
||||||
|
return { after: entry.id, before: null };
|
||||||
const referenceId = options?.after ?? options?.before;
|
|
||||||
if (referenceId) {
|
|
||||||
// trying to insert relatively to another entry
|
|
||||||
const referenceEntry = rundown.entries[referenceId];
|
|
||||||
if (referenceEntry) {
|
|
||||||
if (options?.after) {
|
|
||||||
atIndex = rundown.order.indexOf(referenceId) + 1;
|
|
||||||
} else if (options?.before) {
|
|
||||||
atIndex = rundown.order.indexOf(referenceId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
const after = getInsertAfterId(rundown, null, options.after, options.before);
|
||||||
|
const before = options.before === true ? (after ?? null) : (options.before ?? null);
|
||||||
|
return { after, before };
|
||||||
|
})();
|
||||||
|
|
||||||
|
const atIndex = (() => {
|
||||||
|
if (position.before !== null) {
|
||||||
|
return rundown.order.indexOf(position.before);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (position.after !== null) {
|
||||||
|
return rundown.order.indexOf(position.after) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rundown.order.length;
|
||||||
|
})();
|
||||||
|
|
||||||
// we only need to insert the group, the nested entries will be resolved by the rundown engine
|
// we only need to insert the group, the nested entries will be resolved by the rundown engine
|
||||||
rundown.order = insertAtIndex(atIndex, newGroup.id, rundown.order);
|
rundown.order = insertAtIndex(atIndex, newGroup.id, rundown.order);
|
||||||
@@ -461,41 +467,33 @@ function clone(rundown: Rundown, entry: OntimeEntry, options?: InsertOptions): O
|
|||||||
return newGroup;
|
return newGroup;
|
||||||
} else {
|
} else {
|
||||||
const clonedEntry = cloneSimpleRundownEntry(entry, getUniqueId(rundown));
|
const clonedEntry = cloneSimpleRundownEntry(entry, getUniqueId(rundown));
|
||||||
|
const parent = (() => {
|
||||||
|
const referenceId = (() => {
|
||||||
|
if (typeof options?.after === 'string') return options.after;
|
||||||
|
if (typeof options?.before === 'string') return options.before;
|
||||||
|
return undefined;
|
||||||
|
})();
|
||||||
|
|
||||||
let parent: OntimeGroup | null = null;
|
const referenceEntry = referenceId ? rundown.entries[referenceId] : entry;
|
||||||
|
const parentId = referenceEntry && !isOntimeGroup(referenceEntry) ? referenceEntry.parent : null;
|
||||||
|
if (parentId === null) return null;
|
||||||
|
|
||||||
// trying to insert relatively to another entry, check that entries parent
|
const maybeParent = rundown.entries[parentId];
|
||||||
const referenceId = options?.after ?? options?.before;
|
return isOntimeGroup(maybeParent) ? maybeParent : null;
|
||||||
|
})();
|
||||||
|
|
||||||
/**
|
const position = (() => {
|
||||||
* if we have a positioning reference, and that reference has a parent
|
// if no position is given, we add immediately after the element
|
||||||
* we need to maintain the same parent for the cloned entry
|
if (options?.after === undefined && options?.before === undefined) {
|
||||||
*/
|
return { after: entry.id, before: null };
|
||||||
if (referenceId) {
|
|
||||||
const referenceEntry = rundown.entries[referenceId];
|
|
||||||
|
|
||||||
if (referenceEntry && !isOntimeGroup(referenceEntry)) {
|
|
||||||
if (referenceEntry.parent) {
|
|
||||||
const maybeParent = rundown.entries[referenceEntry.parent];
|
|
||||||
if (maybeParent && isOntimeGroup(maybeParent)) {
|
|
||||||
parent = maybeParent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (entry.parent) {
|
|
||||||
const maybeParent = rundown.entries[entry.parent];
|
|
||||||
if (maybeParent && isOntimeGroup(maybeParent)) {
|
|
||||||
parent = maybeParent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if we have resolved a parent, we add it to the cloned entry
|
const after = getInsertAfterId(rundown, parent, options.after, options.before);
|
||||||
let after = getInsertAfterId(rundown, parent, options?.after, options?.before);
|
const before = options?.before === true ? after : (options.before ?? null);
|
||||||
if (!after) {
|
return { after, before };
|
||||||
after = entry.id;
|
})();
|
||||||
}
|
|
||||||
|
|
||||||
return addToRundown(rundown, clonedEntry, after, parent);
|
return addToRundown(rundown, clonedEntry, parent, position.after, position.before);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -279,14 +279,22 @@ function processEntry<T extends OntimeEntry>(
|
|||||||
// 2. handle custom fields - mutates entry
|
// 2. handle custom fields - mutates entry
|
||||||
sanitiseCustomFields(customFields, entry);
|
sanitiseCustomFields(customFields, entry);
|
||||||
|
|
||||||
rundownMetadata.totalDays += calculateDayOffset(entry, rundownMetadata.previousEvent);
|
/*
|
||||||
entry.dayOffset = rundownMetadata.totalDays as Day;
|
* we initialise data so that is not calculated for skipped events
|
||||||
entry.delay = 0; // this means we dont calculate delays or gaps for skipped events
|
* consider especially the day offset, while skipped events have no
|
||||||
entry.gap = 0; // this means we dont calculate delays or gaps for skipped events
|
* day offset, we match it to the previous element to avoid
|
||||||
|
* pushing confusing data to the UI
|
||||||
|
*/
|
||||||
|
entry.dayOffset = (rundownMetadata.previousEvent?.dayOffset ?? 0) as Day;
|
||||||
|
entry.delay = 0;
|
||||||
|
entry.gap = 0;
|
||||||
|
|
||||||
entry.parent = childOfGroup;
|
entry.parent = childOfGroup;
|
||||||
|
|
||||||
// update rundown metadata, it only concerns playable events
|
// update rundown metadata, it only concerns playable events
|
||||||
if (isPlayableEvent(entry)) {
|
if (isPlayableEvent(entry)) {
|
||||||
|
rundownMetadata.totalDays += calculateDayOffset(entry, rundownMetadata.previousEvent);
|
||||||
|
entry.dayOffset = rundownMetadata.totalDays as Day;
|
||||||
rundownMetadata.playableEventOrder.push(entry.id);
|
rundownMetadata.playableEventOrder.push(entry.id);
|
||||||
|
|
||||||
// first start is always the first event
|
// first start is always the first event
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import type { Request, Response, Router } from 'express';
|
import type { Request, Response, Router } from 'express';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { matchedData } from 'express-validator';
|
import { matchedData } from 'express-validator';
|
||||||
import { ErrorResponse, OntimeEntry, ProjectRundownsList, RenumberCues, Rundown } from 'ontime-types';
|
import {
|
||||||
|
ErrorResponse,
|
||||||
|
OntimeEntry,
|
||||||
|
ProjectRundowns,
|
||||||
|
ProjectRundownsList,
|
||||||
|
RenumberCues,
|
||||||
|
Rundown,
|
||||||
|
RundownImportPayload,
|
||||||
|
} from 'ontime-types';
|
||||||
import { getErrorMessage } from 'ontime-utils';
|
import { getErrorMessage } from 'ontime-utils';
|
||||||
|
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
@@ -12,7 +20,9 @@ import {
|
|||||||
applyDelay,
|
applyDelay,
|
||||||
batchEditEntries,
|
batchEditEntries,
|
||||||
cloneEntry,
|
cloneEntry,
|
||||||
|
applyImportToRundown,
|
||||||
createNewRundown,
|
createNewRundown,
|
||||||
|
createRundownFromImport,
|
||||||
deleteAllEntries,
|
deleteAllEntries,
|
||||||
deleteEntries,
|
deleteEntries,
|
||||||
deleteRundown,
|
deleteRundown,
|
||||||
@@ -36,6 +46,7 @@ import {
|
|||||||
entryReorderValidator,
|
entryReorderValidator,
|
||||||
entrySwapValidator,
|
entrySwapValidator,
|
||||||
rundownArrayOfIds,
|
rundownArrayOfIds,
|
||||||
|
rundownImportValidator,
|
||||||
rundownPatchValidator,
|
rundownPatchValidator,
|
||||||
rundownPostValidator,
|
rundownPostValidator,
|
||||||
} from './rundown.validation.js';
|
} from './rundown.validation.js';
|
||||||
@@ -147,6 +158,39 @@ router.delete('/:id', paramsWithId, async (req: Request, res: Response<ProjectRu
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies an imported rundown: override or merge into an existing rundown, or create a new one.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/import',
|
||||||
|
rundownImportValidator,
|
||||||
|
async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const { mode, targetRundownId, rundown, customFields, providedFields } = matchedData<RundownImportPayload>(req);
|
||||||
|
let projectRundowns: ProjectRundowns;
|
||||||
|
if (mode === 'new') {
|
||||||
|
projectRundowns = await createRundownFromImport(rundown, customFields);
|
||||||
|
} else {
|
||||||
|
// the validator guarantees this for override/merge, the guard narrows the type and adds defence in depth
|
||||||
|
if (!targetRundownId) {
|
||||||
|
throw new Error('targetRundownId is required when mode is override or merge');
|
||||||
|
}
|
||||||
|
projectRundowns = await applyImportToRundown(
|
||||||
|
mode,
|
||||||
|
targetRundownId,
|
||||||
|
rundown,
|
||||||
|
customFields,
|
||||||
|
providedFields ?? { event: [], custom: [] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// #endregion operations on project rundowns ======================
|
// #endregion operations on project rundowns ======================
|
||||||
|
|
||||||
// #region operations on rundown entries ==========================
|
// #region operations on rundown entries ==========================
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
CustomFields,
|
CustomFields,
|
||||||
EntryId,
|
EntryId,
|
||||||
EventPostPayload,
|
EventPostPayload,
|
||||||
|
ImportedFields,
|
||||||
InsertOptions,
|
InsertOptions,
|
||||||
LogOrigin,
|
LogOrigin,
|
||||||
OntimeEntry,
|
OntimeEntry,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
ProjectRundowns,
|
ProjectRundowns,
|
||||||
RefetchKey,
|
RefetchKey,
|
||||||
Rundown,
|
Rundown,
|
||||||
|
RundownImportMergeStrategy,
|
||||||
isOntimeDelay,
|
isOntimeDelay,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
isOntimeGroup,
|
isOntimeGroup,
|
||||||
@@ -25,6 +27,7 @@ import { makeNewRundown } from '../../models/dataModel.js';
|
|||||||
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
|
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
|
||||||
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
|
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
|
||||||
import { updateRundownData } from '../../stores/runtimeState.js';
|
import { updateRundownData } from '../../stores/runtimeState.js';
|
||||||
|
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
|
||||||
import {
|
import {
|
||||||
createTransaction,
|
createTransaction,
|
||||||
customFieldMutation,
|
customFieldMutation,
|
||||||
@@ -33,8 +36,19 @@ import {
|
|||||||
rundownMutation,
|
rundownMutation,
|
||||||
updateBackgroundRundown,
|
updateBackgroundRundown,
|
||||||
} from './rundown.dao.js';
|
} from './rundown.dao.js';
|
||||||
|
import { parseRundown } from './rundown.parser.js';
|
||||||
import type { RundownMetadata } from './rundown.types.js';
|
import type { RundownMetadata } from './rundown.types.js';
|
||||||
import { generateEvent, getIntegerAndFraction, hasChanges } from './rundown.utils.js';
|
import {
|
||||||
|
generateEvent,
|
||||||
|
getFirstInsertId,
|
||||||
|
getIntegerAndFraction,
|
||||||
|
getLastInsertId,
|
||||||
|
getPreviousInsertId,
|
||||||
|
hasChanges,
|
||||||
|
mergeRundownPreservingFields,
|
||||||
|
isLoadedPlayable,
|
||||||
|
} from './rundown.utils.js';
|
||||||
|
import { assertInsertAnchorExists, assertInsertAnchorInOrder, assertSingleInsertAnchor } from './rundown.validation.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* creates a new entry with given data
|
* creates a new entry with given data
|
||||||
@@ -42,6 +56,8 @@ import { generateEvent, getIntegerAndFraction, hasChanges } from './rundown.util
|
|||||||
export async function addEntry(rundownId: string, eventData: EventPostPayload): Promise<OntimeEntry> {
|
export async function addEntry(rundownId: string, eventData: EventPostPayload): Promise<OntimeEntry> {
|
||||||
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
||||||
|
|
||||||
|
assertSingleInsertAnchor(eventData);
|
||||||
|
|
||||||
// we allow the user to provide an ID, but make sure it is unique
|
// we allow the user to provide an ID, but make sure it is unique
|
||||||
if (eventData?.id && Object.hasOwn(rundown.entries, eventData.id)) {
|
if (eventData?.id && Object.hasOwn(rundown.entries, eventData.id)) {
|
||||||
throw new Error(`Event with ID ${eventData.id} already exists`);
|
throw new Error(`Event with ID ${eventData.id} already exists`);
|
||||||
@@ -58,14 +74,29 @@ export async function addEntry(rundownId: string, eventData: EventPostPayload):
|
|||||||
parent = maybeParent;
|
parent = maybeParent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertInsertAnchorInOrder(rundown, parent, eventData);
|
||||||
|
|
||||||
// normalise the position of the event in the rundown order
|
// normalise the position of the event in the rundown order
|
||||||
const afterId = getInsertAfterId(rundown, parent, eventData?.after, eventData?.before);
|
const insertPosition = (() => {
|
||||||
|
if (eventData.before !== undefined) {
|
||||||
|
const beforeId = getFirstInsertId(rundown, parent, eventData.before);
|
||||||
|
return { afterId: null, beforeId, cueAfterId: getPreviousInsertId(rundown, parent, beforeId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// if after is an ID, we will try and find the entry
|
||||||
|
if (eventData.after !== undefined && eventData.after !== true) {
|
||||||
|
const afterId = getInsertAfterId(rundown, parent, eventData.after);
|
||||||
|
return { afterId, beforeId: null, cueAfterId: afterId };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { afterId: null, beforeId: null, cueAfterId: getLastInsertId(rundown, parent) };
|
||||||
|
})();
|
||||||
|
|
||||||
// generate a fully formed entry from the patch
|
// generate a fully formed entry from the patch
|
||||||
const newEntry = generateEvent(rundown, eventData, afterId, parent?.id);
|
const newEntry = generateEvent(rundown, eventData, insertPosition.cueAfterId, parent?.id);
|
||||||
|
|
||||||
// make mutations to rundown
|
// make mutations to rundown
|
||||||
rundownMutation.add(rundown, newEntry, afterId, parent);
|
rundownMutation.add(rundown, newEntry, parent, insertPosition.afterId, insertPosition.beforeId);
|
||||||
|
|
||||||
const { rundown: responseRundown, rundownMetadata, revision } = await commit();
|
const { rundown: responseRundown, rundownMetadata, revision } = await commit();
|
||||||
|
|
||||||
@@ -385,7 +416,10 @@ export async function swapEvents(rundownId: string, fromId: EntryId, toId: Entry
|
|||||||
* @throws if the entry to clone does not exist
|
* @throws if the entry to clone does not exist
|
||||||
*/
|
*/
|
||||||
export async function cloneEntry(rundownId: string, entryId: EntryId, options: InsertOptions): Promise<Rundown> {
|
export async function cloneEntry(rundownId: string, entryId: EntryId, options: InsertOptions): Promise<Rundown> {
|
||||||
|
assertSingleInsertAnchor(options);
|
||||||
|
|
||||||
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
||||||
|
assertInsertAnchorExists(rundown, options);
|
||||||
const originalEntry = rundown.entries[entryId];
|
const originalEntry = rundown.entries[entryId];
|
||||||
|
|
||||||
if (!originalEntry) {
|
if (!originalEntry) {
|
||||||
@@ -656,8 +690,8 @@ export async function loadRundown(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets a new rundown in the cache
|
* Sets a new rundown in the cache and marks it as the currently loaded one.
|
||||||
* and marks it as the currently loaded one
|
* Switching to a rundown always stops playback.
|
||||||
*/
|
*/
|
||||||
export async function initRundown(
|
export async function initRundown(
|
||||||
rundown: Readonly<Rundown>,
|
rundown: Readonly<Rundown>,
|
||||||
@@ -679,6 +713,25 @@ export async function initRundown(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a rebuilt version of the currently loaded rundown in place.
|
||||||
|
* Unlike switching rundowns, this maintains playback when possible
|
||||||
|
*/
|
||||||
|
function applyChangeToCurrentRundown(rundown: Readonly<Rundown>, customFields: Readonly<CustomFields>) {
|
||||||
|
const loadedEvent = runtimeService.getLoadedEventId();
|
||||||
|
if (loadedEvent && !isLoadedPlayable(loadedEvent, rundown)) {
|
||||||
|
runtimeService.stop();
|
||||||
|
}
|
||||||
|
const { rundownMetadata, revision } = rundownCache.init(rundown, customFields);
|
||||||
|
updateRuntimeOnChange(rundownMetadata);
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
// notifying the timer hot-reloads the playing event and keeps playback
|
||||||
|
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true, reload: true });
|
||||||
|
sendRefetch(RefetchKey.ProjectRundowns);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function createNewRundown(title: string) {
|
export async function createNewRundown(title: string) {
|
||||||
const emptyRundown = makeNewRundown();
|
const emptyRundown = makeNewRundown();
|
||||||
emptyRundown.title = title;
|
emptyRundown.title = title;
|
||||||
@@ -743,6 +796,86 @@ export async function duplicateExistingRundown(id: string) {
|
|||||||
return dataProvider.getProjectRundowns();
|
return dataProvider.getProjectRundowns();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an imported rundown against the resulting custom fields, then commits those custom
|
||||||
|
* fields. The rundown is validated before the custom-field mutation, so a payload that fails
|
||||||
|
* validation cannot leave a partial custom-field write behind.
|
||||||
|
* @throws if the rundown or custom fields fail validation
|
||||||
|
*/
|
||||||
|
async function parseImportAndCommitCustomFields(
|
||||||
|
source: Readonly<Rundown>,
|
||||||
|
incomingCustomFields: CustomFields,
|
||||||
|
): Promise<Rundown> {
|
||||||
|
const dataProvider = getDataProvider();
|
||||||
|
const parsedCustomFields = parseCustomFields({ customFields: incomingCustomFields });
|
||||||
|
const mergedCustomFields = { ...dataProvider.getCustomFields(), ...parsedCustomFields };
|
||||||
|
const parsed = parseRundown(source, mergedCustomFields);
|
||||||
|
await dataProvider.mergeIntoData({ customFields: parsedCustomFields });
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies an imported rundown onto an existing rundown, keeping the existing identity while
|
||||||
|
* taking structure and order from the incoming data.
|
||||||
|
* - 'override' replaces all content with the incoming data
|
||||||
|
* - 'merge' updates matched entries with only the fields the spreadsheet provided, keeping the rest
|
||||||
|
* (e.g. automations) from the existing entry
|
||||||
|
*
|
||||||
|
* When targeting the loaded rundown this is treated as a change, not a switch, so playback is
|
||||||
|
* maintained when the playing event survives.
|
||||||
|
* @throws if the target rundown does not exist
|
||||||
|
*/
|
||||||
|
export async function applyImportToRundown(
|
||||||
|
strategy: RundownImportMergeStrategy,
|
||||||
|
targetRundownId: string,
|
||||||
|
incomingRundown: Rundown,
|
||||||
|
incomingCustomFields: CustomFields,
|
||||||
|
providedFields: ImportedFields,
|
||||||
|
): Promise<ProjectRundowns> {
|
||||||
|
const dataProvider = getDataProvider();
|
||||||
|
// throws if the rundown was deleted between preview and apply
|
||||||
|
const existing = dataProvider.getRundown(targetRundownId);
|
||||||
|
|
||||||
|
const source =
|
||||||
|
strategy === 'merge'
|
||||||
|
? mergeRundownPreservingFields(incomingRundown, existing, providedFields)
|
||||||
|
: { ...incomingRundown, id: existing.id, title: existing.title, revision: existing.revision + 1 };
|
||||||
|
const parsed = await parseImportAndCommitCustomFields(source, incomingCustomFields);
|
||||||
|
|
||||||
|
if (isCurrentRundown(targetRundownId)) {
|
||||||
|
// applying to the loaded rundown is a change, not a switch: maintain playback when possible
|
||||||
|
applyChangeToCurrentRundown(parsed, dataProvider.getCustomFields());
|
||||||
|
} else {
|
||||||
|
await dataProvider.setRundown(parsed.id, parsed);
|
||||||
|
setImmediate(() => {
|
||||||
|
sendRefetch(RefetchKey.ProjectRundowns);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataProvider.getProjectRundowns();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new rundown from an imported rundown and loads it,
|
||||||
|
* so the user immediately sees the imported data.
|
||||||
|
* Loading a rundown stops playback (as with any rundown switch).
|
||||||
|
*/
|
||||||
|
export async function createRundownFromImport(
|
||||||
|
incomingRundown: Rundown,
|
||||||
|
incomingCustomFields: CustomFields,
|
||||||
|
): Promise<ProjectRundowns> {
|
||||||
|
const dataProvider = getDataProvider();
|
||||||
|
|
||||||
|
// assign a fresh id so we never collide with an existing rundown
|
||||||
|
const parsed = await parseImportAndCommitCustomFields({ ...incomingRundown, id: generateId() }, incomingCustomFields);
|
||||||
|
parsed.revision = 0;
|
||||||
|
|
||||||
|
// initRundown persists the new rundown, makes it the loaded rundown and notifies clients
|
||||||
|
await initRundown(parsed, dataProvider.getCustomFields(), true);
|
||||||
|
|
||||||
|
return dataProvider.getProjectRundowns();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a rundown
|
* Deletes a rundown
|
||||||
* @throws if attempting to delete the loaded rundown or the last rundown in the project
|
* @throws if attempting to delete the loaded rundown or the last rundown in the project
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
CustomFields,
|
CustomFields,
|
||||||
EntryCustomFields,
|
EntryCustomFields,
|
||||||
EntryId,
|
EntryId,
|
||||||
|
ImportedFields,
|
||||||
OntimeBaseEvent,
|
OntimeBaseEvent,
|
||||||
OntimeDelay,
|
OntimeDelay,
|
||||||
OntimeEntry,
|
OntimeEntry,
|
||||||
@@ -11,12 +12,14 @@ import {
|
|||||||
ProjectRundown,
|
ProjectRundown,
|
||||||
ProjectRundowns,
|
ProjectRundowns,
|
||||||
Rundown,
|
Rundown,
|
||||||
|
RundownEntries,
|
||||||
SupportedEntry,
|
SupportedEntry,
|
||||||
TimeStrategy,
|
TimeStrategy,
|
||||||
isOntimeDelay,
|
isOntimeDelay,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
isOntimeGroup,
|
isOntimeGroup,
|
||||||
isOntimeMilestone,
|
isOntimeMilestone,
|
||||||
|
isPlayableEvent,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import {
|
import {
|
||||||
createDelay,
|
createDelay,
|
||||||
@@ -67,6 +70,50 @@ export function generateEvent(
|
|||||||
throw new Error('Invalid event type');
|
throw new Error('Invalid event type');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the last valid insertion reference for a top-level rundown or group order.
|
||||||
|
* Used when appending entries and when generating cues from the preceding entry.
|
||||||
|
*/
|
||||||
|
export function getLastInsertId(rundown: Rundown, parent: OntimeGroup | null): EntryId | null {
|
||||||
|
const insertionList = parent ? parent.entries : rundown.order;
|
||||||
|
return insertionList[insertionList.length - 1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a `before` insertion option to the first entry in the relevant order when `true` is provided.
|
||||||
|
* String values are already explicit anchors and are returned unchanged.
|
||||||
|
*/
|
||||||
|
export function getFirstInsertId(rundown: Rundown, parent: OntimeGroup | null, before: EntryId | true): EntryId | null {
|
||||||
|
if (before !== true) {
|
||||||
|
return before;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertionList = parent ? parent.entries : rundown.order;
|
||||||
|
return insertionList[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the sibling before a `before` insertion anchor in the top-level or group order.
|
||||||
|
* Returns `null` when the new entry will be inserted at the start.
|
||||||
|
*/
|
||||||
|
export function getPreviousInsertId(
|
||||||
|
rundown: Rundown,
|
||||||
|
parent: OntimeGroup | null,
|
||||||
|
beforeId: EntryId | null,
|
||||||
|
): EntryId | null {
|
||||||
|
if (beforeId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertionList = parent ? parent.entries : rundown.order;
|
||||||
|
const beforeIndex = insertionList.indexOf(beforeId);
|
||||||
|
if (beforeIndex < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return insertionList[beforeIndex - 1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function createEventPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
|
export function createEventPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
|
||||||
if (Object.keys(patchEvent).length === 0) {
|
if (Object.keys(patchEvent).length === 0) {
|
||||||
return originalEvent;
|
return originalEvent;
|
||||||
@@ -225,6 +272,83 @@ export function getUniqueId(rundown: Rundown): EntryId {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an entry patch containing exactly the fields the spreadsheet supplied, for both built-in
|
||||||
|
* and custom fields. Everything the sheet did not map is left out, so applying the patch keeps the
|
||||||
|
* existing value for those fields.
|
||||||
|
*/
|
||||||
|
function buildImportPatch(entry: OntimeEntry, providedFields: ImportedFields): Partial<OntimeEntry> {
|
||||||
|
const source = entry as Record<string, unknown>;
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
for (const field of providedFields.event) {
|
||||||
|
patch[field] = source[field];
|
||||||
|
}
|
||||||
|
if (providedFields.custom.length > 0) {
|
||||||
|
const entryCustom = (source.custom ?? {}) as EntryCustomFields;
|
||||||
|
const custom: EntryCustomFields = {};
|
||||||
|
for (const key of providedFields.custom) {
|
||||||
|
custom[key] = entryCustom[key] ?? '';
|
||||||
|
}
|
||||||
|
patch.custom = custom;
|
||||||
|
}
|
||||||
|
return patch as Partial<OntimeEntry>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges an imported rundown into an existing one
|
||||||
|
* - the incoming rundown is the source of truth for entry identity and structure (order + grouping)
|
||||||
|
* - a matched entry of the same type is merged field-by-field: a field the sheet provided overwrites
|
||||||
|
* (even when empty), a field the sheet cannot express (an event's automations, a group's target
|
||||||
|
* duration, an unmapped custom field) is kept from the existing entry
|
||||||
|
* - a new id, or an id whose type changed, takes the incoming entry wholesale
|
||||||
|
* - existing entries absent from the incoming rundown are dropped
|
||||||
|
*/
|
||||||
|
export function mergeRundownPreservingFields(
|
||||||
|
incoming: Readonly<Rundown>,
|
||||||
|
existing: Readonly<Rundown>,
|
||||||
|
providedFields: ImportedFields,
|
||||||
|
): Rundown {
|
||||||
|
const entries: RundownEntries = {};
|
||||||
|
|
||||||
|
for (const [id, incomingEntry] of Object.entries(incoming.entries)) {
|
||||||
|
const existingEntry = existing.entries[id];
|
||||||
|
|
||||||
|
// a new id, or one whose type changed, is not compatible for a merge: take the incoming data
|
||||||
|
if (existingEntry === undefined || existingEntry.type !== incomingEntry.type) {
|
||||||
|
entries[id] = incomingEntry;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge the sheet's data onto the existing entry through the canonical patch function, which
|
||||||
|
// keeps every unmapped field and infers an event's time strategy from the provided times
|
||||||
|
const merged = applyPatchToEntry(existingEntry, buildImportPatch(incomingEntry, providedFields));
|
||||||
|
// grouping comes from the sheet structure, not a data column: a group owns its children, every
|
||||||
|
// other entry knows its parent
|
||||||
|
const structure = isOntimeGroup(incomingEntry)
|
||||||
|
? { entries: incomingEntry.entries }
|
||||||
|
: { parent: incomingEntry.parent };
|
||||||
|
entries[id] = structuredClone({ ...merged, ...structure });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: existing.id,
|
||||||
|
title: existing.title,
|
||||||
|
order: [...incoming.order],
|
||||||
|
flatOrder: [...incoming.flatOrder],
|
||||||
|
revision: existing.revision + 1,
|
||||||
|
entries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the currently playing event survives a change to its rundown,
|
||||||
|
* i.e. it still exists and is playable in the new version.
|
||||||
|
*/
|
||||||
|
export function isLoadedPlayable(loadedEventId: EntryId, rundown: Readonly<Rundown>): boolean {
|
||||||
|
const entry = rundown.entries[loadedEventId];
|
||||||
|
return entry !== undefined && isOntimeEvent(entry) && isPlayableEvent(entry);
|
||||||
|
}
|
||||||
|
|
||||||
/** List of event properties which do not need the rundown to be regenerated */
|
/** List of event properties which do not need the rundown to be regenerated */
|
||||||
enum RegenerateWhitelist {
|
enum RegenerateWhitelist {
|
||||||
'id', // adding it for completeness, users cannot change ID
|
'id', // adding it for completeness, users cannot change ID
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { body, param } from 'express-validator';
|
import { body, param } from 'express-validator';
|
||||||
|
import type { EntryId, InsertOptions, OntimeGroup, Rundown } from 'ontime-types';
|
||||||
|
|
||||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||||
|
|
||||||
@@ -11,20 +12,53 @@ export const rundownPatchValidator = [
|
|||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const rundownImportValidator = [
|
||||||
|
body('mode').isString().isIn(['override', 'merge', 'new']),
|
||||||
|
body('targetRundownId')
|
||||||
|
.if(body('mode').isIn(['override', 'merge']))
|
||||||
|
.isString()
|
||||||
|
.trim()
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('targetRundownId is required when mode is override or merge'),
|
||||||
|
body('rundown').isObject(),
|
||||||
|
body('rundown.entries').isObject(),
|
||||||
|
body('rundown.order').isArray(),
|
||||||
|
body('rundown.flatOrder').isArray(),
|
||||||
|
body('customFields').isObject(),
|
||||||
|
body('providedFields').optional().isObject(),
|
||||||
|
body('providedFields.event').optional().isArray(),
|
||||||
|
body('providedFields.event.*').isString(),
|
||||||
|
body('providedFields.custom').optional().isArray(),
|
||||||
|
body('providedFields.custom.*').isString(),
|
||||||
|
requestValidationFunction,
|
||||||
|
];
|
||||||
|
|
||||||
// #endregion operations on project rundowns ======================
|
// #endregion operations on project rundowns ======================
|
||||||
// #region operations on rundown entries ==========================
|
// #region operations on rundown entries ==========================
|
||||||
|
|
||||||
export const entryPostValidator = [
|
export const entryPostValidator = [
|
||||||
body('type').isString().isIn(['event', 'delay', 'group', 'milestone']),
|
body('type').isString().isIn(['event', 'delay', 'group', 'milestone']),
|
||||||
body('after').optional().isString(),
|
body('after')
|
||||||
body('before').optional().isString(),
|
.optional()
|
||||||
|
.custom((value) => value === true || typeof value === 'string')
|
||||||
|
.withMessage('Allowed values for after are an ID or true.'),
|
||||||
|
body('before')
|
||||||
|
.optional()
|
||||||
|
.custom((value) => value === true || typeof value === 'string')
|
||||||
|
.withMessage('Allowed values for before are an ID or true.'),
|
||||||
|
|
||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const clonePostValidator = [
|
export const clonePostValidator = [
|
||||||
body('after').optional().isString(),
|
body('after')
|
||||||
body('before').optional().isString(),
|
.optional()
|
||||||
|
.custom((value) => value === true || typeof value === 'string')
|
||||||
|
.withMessage('Allowed values for after are an ID or true.'),
|
||||||
|
body('before')
|
||||||
|
.optional()
|
||||||
|
.custom((value) => value === true || typeof value === 'string')
|
||||||
|
.withMessage('Allowed values for before are an ID or true.'),
|
||||||
|
|
||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
@@ -72,4 +106,44 @@ export const entryRenumberValidator = [
|
|||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures callers use a single insertion anchor so placement is unambiguous.
|
||||||
|
*/
|
||||||
|
export function assertSingleInsertAnchor(options: InsertOptions) {
|
||||||
|
if (options.after !== undefined && options.before !== undefined) {
|
||||||
|
throw new Error('Use only one insertion anchor: after or before');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures an ID anchor exists and belongs to the order it is intended to position within.
|
||||||
|
*/
|
||||||
|
export function assertInsertAnchorInOrder(rundown: Rundown, parent: OntimeGroup | null, options: InsertOptions) {
|
||||||
|
const anchor = getStringInsertAnchor(options);
|
||||||
|
if (anchor === undefined) return;
|
||||||
|
|
||||||
|
if (!Object.hasOwn(rundown.entries, anchor)) {
|
||||||
|
throw new Error(`Insertion anchor with ID ${anchor} does not exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertionList = parent ? parent.entries : rundown.order;
|
||||||
|
if (!insertionList.includes(anchor)) {
|
||||||
|
throw new Error(`Insertion anchor with ID ${anchor} is not in the target order`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensures an ID anchor refers to an entry in the rundown. */
|
||||||
|
export function assertInsertAnchorExists(rundown: Rundown, options: InsertOptions) {
|
||||||
|
const anchor = getStringInsertAnchor(options);
|
||||||
|
if (anchor !== undefined && !Object.hasOwn(rundown.entries, anchor)) {
|
||||||
|
throw new Error(`Insertion anchor with ID ${anchor} does not exist`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStringInsertAnchor(options: InsertOptions): EntryId | undefined {
|
||||||
|
if (typeof options.after === 'string') return options.after;
|
||||||
|
if (typeof options.before === 'string') return options.before;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// #endregion operations on rundown entries =======================
|
// #endregion operations on rundown entries =======================
|
||||||
|
|||||||
@@ -153,6 +153,41 @@ describe('mcp.service', () => {
|
|||||||
expect(result.created.map((entry) => entry.id)).toEqual(['entry-1', 'entry-2', 'entry-3', 'entry-4']);
|
expect(result.created.map((entry) => entry.id)).toEqual(['entry-1', 'entry-2', 'entry-3', 'entry-4']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('omits insert anchors when creating an entry without a position', async () => {
|
||||||
|
await createEntryForMcp({
|
||||||
|
type: SupportedEntry.Milestone,
|
||||||
|
title: 'End marker',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(addEntryMock).toHaveBeenCalledWith(
|
||||||
|
'loaded-rundown',
|
||||||
|
expect.objectContaining({ type: SupportedEntry.Milestone, title: 'End marker' }),
|
||||||
|
);
|
||||||
|
expect(addEntryMock.mock.calls[0][1]).not.toHaveProperty('after');
|
||||||
|
expect(addEntryMock.mock.calls[0][1]).not.toHaveProperty('before');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses before true for the first batch entry and chains the rest', async () => {
|
||||||
|
await batchCreateEntriesForMcp({
|
||||||
|
before: true,
|
||||||
|
entries: [
|
||||||
|
{ type: SupportedEntry.Event, title: 'First' },
|
||||||
|
{ type: SupportedEntry.Event, title: 'Second' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(addEntryMock).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
'loaded-rundown',
|
||||||
|
expect.objectContaining({ type: SupportedEntry.Event, title: 'First', before: true }),
|
||||||
|
);
|
||||||
|
expect(addEntryMock).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'loaded-rundown',
|
||||||
|
expect.objectContaining({ type: SupportedEntry.Event, title: 'Second', after: 'entry-1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects nested groups before creating entries', async () => {
|
it('rejects nested groups before creating entries', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
batchCreateEntriesForMcp({
|
batchCreateEntriesForMcp({
|
||||||
|
|||||||
@@ -71,10 +71,10 @@ Read the ontime://schema resource if you need a data model reference.
|
|||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. Call ontime_list_rundowns and identify the target rundown. If the user wants a background rundown, pass its \`rundownId\` in all entry read/write calls instead of loading it.
|
1. Call ontime_list_rundowns and identify the target rundown. If the user wants a background rundown, pass its \`rundownId\` in all entry read/write calls instead of loading it.
|
||||||
2. Call ontime_get_rundown with the chosen \`rundownId\` to see current state and identify an \`after\` anchor if appending.
|
2. Call ontime_get_rundown with the chosen \`rundownId\` to see current state and avoid duplicating existing entries.
|
||||||
3. Call ontime_get_timer_state. If playback is not \`stop\` and the target is the loaded rundown, explain that MCP edits affect the live rundown and ask the user to confirm before changing it. If the target is a background rundown, it can be edited without interrupting playback.
|
3. Call ontime_get_timer_state. If playback is not \`stop\` and the target is the loaded rundown, explain that MCP edits affect the live rundown and ask the user to confirm before changing it. If the target is a background rundown, it can be edited without interrupting playback.
|
||||||
4. Build an array of entries in order and call ontime_batch_create_entries ONCE with all of them. This is much faster than calling ontime_create_entry per item.
|
4. Build an array of entries in order and call ontime_batch_create_entries ONCE with all of them. This is much faster than calling ontime_create_entry per item.
|
||||||
5. If the rundown already has events, pass \`after: <last event id>\` on the batch call so new events chain from the end.
|
5. Omit \`after\` and \`before\` on the batch call to append the new entries to the end of the rundown. Use \`after: true\` for an explicit append request, or \`before: true\` for an explicit prepend request.
|
||||||
|
|
||||||
Entry type guidance:
|
Entry type guidance:
|
||||||
- Use \`event\` for anything with a scheduled time and duration (talks, panels, breaks, meals).
|
- Use \`event\` for anything with a scheduled time and duration (talks, panels, breaks, meals).
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ export type BatchCreateEntryArgs = CreateEntryArgs & { children?: BatchCreateEnt
|
|||||||
export type UpdateEntryArgs = EntryFieldArgs & TargetRundownArgs & { id: EntryId };
|
export type UpdateEntryArgs = EntryFieldArgs & TargetRundownArgs & { id: EntryId };
|
||||||
export type GroupEntriesArgs = GroupFieldArgs & TargetRundownArgs & { ids: EntryId[] };
|
export type GroupEntriesArgs = GroupFieldArgs & TargetRundownArgs & { ids: EntryId[] };
|
||||||
export type UngroupEntryArgs = TargetRundownArgs & { id: EntryId };
|
export type UngroupEntryArgs = TargetRundownArgs & { id: EntryId };
|
||||||
|
type BatchCreateEntriesArgs = TargetRundownArgs & {
|
||||||
|
entries: BatchCreateEntryArgs[];
|
||||||
|
after?: EntryId | true;
|
||||||
|
before?: EntryId | true;
|
||||||
|
};
|
||||||
|
|
||||||
export function resolveTargetRundownId(args: TargetRundownArgs): string {
|
export function resolveTargetRundownId(args: TargetRundownArgs): string {
|
||||||
return args.rundownId ?? getCurrentRundownId();
|
return args.rundownId ?? getCurrentRundownId();
|
||||||
@@ -133,20 +138,24 @@ export function assertKnownCustomFields(...customValues: Array<EntryFieldArgs['c
|
|||||||
/** Translates tool arguments into the payload consumed by rundown.service addEntry */
|
/** Translates tool arguments into the payload consumed by rundown.service addEntry */
|
||||||
export function toEntryPayload(args: CreateEntryArgs): EventPostPayload {
|
export function toEntryPayload(args: CreateEntryArgs): EventPostPayload {
|
||||||
const { type = SupportedEntry.Event, after, before } = args;
|
const { type = SupportedEntry.Event, after, before } = args;
|
||||||
|
const insertOptions = {
|
||||||
|
...(after !== undefined ? { after } : {}),
|
||||||
|
...(before !== undefined ? { before } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case SupportedEntry.Delay:
|
case SupportedEntry.Delay:
|
||||||
return { type: SupportedEntry.Delay, duration: args.duration, after, before };
|
return { type: SupportedEntry.Delay, duration: args.duration, ...insertOptions };
|
||||||
case SupportedEntry.Milestone: {
|
case SupportedEntry.Milestone: {
|
||||||
const { cue, title, note, colour, custom } = args;
|
const { cue, title, note, colour, custom } = args;
|
||||||
return { type: SupportedEntry.Milestone, cue, title, note, colour, custom, after, before };
|
return { type: SupportedEntry.Milestone, cue, title, note, colour, custom, ...insertOptions };
|
||||||
}
|
}
|
||||||
case SupportedEntry.Group:
|
case SupportedEntry.Group:
|
||||||
// group creation currently only accepts a title, see generateEvent in rundown.utils.ts
|
// group creation currently only accepts a title, see generateEvent in rundown.utils.ts
|
||||||
return { type: SupportedEntry.Group, title: args.title, after, before };
|
return { type: SupportedEntry.Group, title: args.title, ...insertOptions };
|
||||||
case SupportedEntry.Event: {
|
case SupportedEntry.Event: {
|
||||||
const { type: _type, rundownId: _rundownId, ...eventFields } = args;
|
const { type: _type, rundownId: _rundownId, after: _after, before: _before, ...eventFields } = args;
|
||||||
return { type: SupportedEntry.Event, ...eventFields };
|
return { type: SupportedEntry.Event, ...eventFields, ...insertOptions };
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
throw new Error(`Invalid entry type: ${String(type)}`);
|
throw new Error(`Invalid entry type: ${String(type)}`);
|
||||||
@@ -259,20 +268,25 @@ export async function ungroupEntryForMcp(args: UngroupEntryArgs) {
|
|||||||
return { target: getTargetMeta(rundownId), ungrouped: args.id, order: updatedRundown.order };
|
return { target: getTargetMeta(rundownId), ungrouped: args.id, order: updatedRundown.order };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function batchCreateEntriesForMcp(
|
export async function batchCreateEntriesForMcp(args: BatchCreateEntriesArgs) {
|
||||||
args: TargetRundownArgs & { entries: BatchCreateEntryArgs[]; after?: EntryId },
|
const { entries = [], after, before } = args;
|
||||||
) {
|
|
||||||
const { entries = [], after } = args;
|
|
||||||
validateBatchCreateEntries(entries);
|
validateBatchCreateEntries(entries);
|
||||||
const allEntries = flattenBatchCreateEntries(entries);
|
const allEntries = flattenBatchCreateEntries(entries);
|
||||||
assertKnownCustomFields(...allEntries.map((entry) => entry.custom));
|
assertKnownCustomFields(...allEntries.map((entry) => entry.custom));
|
||||||
const rundownId = resolveTargetRundownId(args);
|
const rundownId = resolveTargetRundownId(args);
|
||||||
let previousId = after;
|
let previousId: EntryId | undefined;
|
||||||
const created: OntimeEntry[] = [];
|
const created: OntimeEntry[] = [];
|
||||||
|
|
||||||
for (const entryArgs of entries) {
|
for (const entryArgs of entries) {
|
||||||
|
const firstInsertOptions =
|
||||||
|
created.length === 0
|
||||||
|
? {
|
||||||
|
...(after !== undefined ? { after } : {}),
|
||||||
|
...(before !== undefined ? { before } : {}),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
// eslint-disable-next-line no-await-in-loop -- top-level entries chain after the previously created one
|
// eslint-disable-next-line no-await-in-loop -- top-level entries chain after the previously created one
|
||||||
const entry = await createBatchEntry(rundownId, entryArgs, previousId);
|
const entry = await createBatchEntry(rundownId, entryArgs, previousId, undefined, firstInsertOptions);
|
||||||
created.push(...entry.created);
|
created.push(...entry.created);
|
||||||
previousId = entry.entry.id;
|
previousId = entry.entry.id;
|
||||||
}
|
}
|
||||||
@@ -303,6 +317,7 @@ async function createBatchEntry(
|
|||||||
entryArgs: BatchCreateEntryArgs,
|
entryArgs: BatchCreateEntryArgs,
|
||||||
previousId?: EntryId,
|
previousId?: EntryId,
|
||||||
parentId?: EntryId,
|
parentId?: EntryId,
|
||||||
|
firstInsertOptions?: InsertOptions,
|
||||||
): Promise<{ entry: OntimeEntry; created: OntimeEntry[] }> {
|
): Promise<{ entry: OntimeEntry; created: OntimeEntry[] }> {
|
||||||
if (parentId && entryArgs.type === SupportedEntry.Group) {
|
if (parentId && entryArgs.type === SupportedEntry.Group) {
|
||||||
throw new Error('Cannot create a group inside another group.');
|
throw new Error('Cannot create a group inside another group.');
|
||||||
@@ -311,8 +326,9 @@ async function createBatchEntry(
|
|||||||
const { children: _children, ...createArgs } = entryArgs;
|
const { children: _children, ...createArgs } = entryArgs;
|
||||||
const payload = toEntryPayload(createArgs);
|
const payload = toEntryPayload(createArgs);
|
||||||
const insertOptions = {
|
const insertOptions = {
|
||||||
...(previousId ? { after: previousId } : {}),
|
...(previousId !== undefined ? { after: previousId } : {}),
|
||||||
...(parentId ? { parent: parentId } : {}),
|
...(parentId !== undefined ? { parent: parentId } : {}),
|
||||||
|
...(previousId === undefined ? firstInsertOptions : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const createdEntry = await addEntry(rundownId, { ...payload, ...insertOptions } as EventPostPayload);
|
const createdEntry = await addEntry(rundownId, { ...payload, ...insertOptions } as EventPostPayload);
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export const TOOL_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
name: 'ontime_create_entry',
|
name: 'ontime_create_entry',
|
||||||
description:
|
description:
|
||||||
'Create a new entry. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Omit after/before to append at the end. For type "event" provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration. For "milestone" provide cue/title/note/colour and optional custom values using existing project custom field keys. For "delay" provide duration. For "group" provide title plus optional note/colour/custom/targetDuration.',
|
'Create a new entry. Omit after/before to append at the end, use after: true to explicitly append, use before: true to prepend, or use before/after with an entry ID to position the entry. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. For type "event" provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration. For "milestone" provide cue/title/note/colour and optional custom values using existing project custom field keys. For "delay" provide duration. For "group" provide title plus optional note/colour/custom/targetDuration.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -114,8 +114,14 @@ export const TOOL_DEFINITIONS = [
|
|||||||
description: 'Duration in ms (events: should equal timeEnd - timeStart; delays: the schedule shift)',
|
description: 'Duration in ms (events: should equal timeEnd - timeStart; delays: the schedule shift)',
|
||||||
},
|
},
|
||||||
targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' },
|
targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' },
|
||||||
after: { type: 'string', description: 'Insert after this entry ID' },
|
after: {
|
||||||
before: { type: 'string', description: 'Insert before this entry ID' },
|
type: ['string', 'boolean'],
|
||||||
|
description: 'Insert after this entry ID, or true to append',
|
||||||
|
},
|
||||||
|
before: {
|
||||||
|
type: ['string', 'boolean'],
|
||||||
|
description: 'Insert before this entry ID, or true to prepend',
|
||||||
|
},
|
||||||
...EVENT_WRITABLE_FIELDS,
|
...EVENT_WRITABLE_FIELDS,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -218,13 +224,20 @@ export const TOOL_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
name: 'ontime_batch_create_entries',
|
name: 'ontime_batch_create_entries',
|
||||||
description:
|
description:
|
||||||
'Create multiple entries, including groups with nested children. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Use this for "build from agenda" flows to avoid many round trips. Entries are inserted in array order; if `after` is provided it positions the first top-level entry, subsequent top-level entries chain from the previous. A group entry may include `children`; those entries are created inside the group in array order. Groups cannot be nested. For events, provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration.',
|
'Create multiple entries, including groups with nested children. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Use this for "build from agenda" flows to avoid many round trips. Entries are inserted in array order; omit after/before to append the first entry at the end, use after: true to explicitly append, use before: true to prepend, or use after/before with an entry ID to position the first top-level entry. Subsequent top-level entries chain from the previous. A group entry may include `children`; those entries are created inside the group in array order. Groups cannot be nested. For events, provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['entries'],
|
required: ['entries'],
|
||||||
properties: {
|
properties: {
|
||||||
...RUNDOWN_TARGET_FIELD,
|
...RUNDOWN_TARGET_FIELD,
|
||||||
after: { type: 'string', description: 'Insert the first entry after this entry ID' },
|
after: {
|
||||||
|
type: ['string', 'boolean'],
|
||||||
|
description: 'Insert the first entry after this entry ID, or true to append',
|
||||||
|
},
|
||||||
|
before: {
|
||||||
|
type: ['string', 'boolean'],
|
||||||
|
description: 'Insert the first entry before this entry ID, or true to prepend',
|
||||||
|
},
|
||||||
entries: {
|
entries: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
description: 'Array of entries to create, in desired order',
|
description: 'Array of entries to create, in desired order',
|
||||||
@@ -588,7 +601,13 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
|
|||||||
|
|
||||||
ontime_batch_create_entries: async (args) => {
|
ontime_batch_create_entries: async (args) => {
|
||||||
return ok(
|
return ok(
|
||||||
await batchCreateEntriesForMcp(args as TargetRundownArgs & { entries: BatchCreateEntryArgs[]; after?: EntryId }),
|
await batchCreateEntriesForMcp(
|
||||||
|
args as TargetRundownArgs & {
|
||||||
|
entries: BatchCreateEntryArgs[];
|
||||||
|
after?: EntryId | true;
|
||||||
|
before?: EntryId | true;
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -117,8 +117,9 @@ app.use(`${prefix}/external`, (req, res) => {
|
|||||||
app.use(`${prefix}/user`, express.static(publicDir.userDir, { etag: false, lastModified: true }));
|
app.use(`${prefix}/user`, express.static(publicDir.userDir, { etag: false, lastModified: true }));
|
||||||
|
|
||||||
// Serve legacy timer for old browsers that don't support modern JS
|
// Serve legacy timer for old browsers that don't support modern JS
|
||||||
|
// dotfiles must be allowed since the install path can contain dot directories (eg AppImage mounts in /tmp/.mount_*)
|
||||||
app.get(`${prefix}/timer-legacy`, authenticateAndRedirect, (_req, res) => {
|
app.get(`${prefix}/timer-legacy`, authenticateAndRedirect, (_req, res) => {
|
||||||
res.sendFile(srcFiles.timerLegacy);
|
res.sendFile(srcFiles.timerLegacy, { dotfiles: 'allow' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Base route for static files
|
// Base route for static files
|
||||||
|
|||||||
@@ -1126,7 +1126,7 @@ describe('getRuntimeOffset()', () => {
|
|||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
const { absolute } = getRuntimeOffset(state);
|
const { absolute } = getRuntimeOffset(state);
|
||||||
expect(absolute).toBe(400000); // <--- offset is always the overtime
|
expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles time-to-end started after the end time', () => {
|
it('handles time-to-end started after the end time', () => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user