Compare commits

...

31 Commits

Author SHA1 Message Date
alex-arc 99a2f58502 fix isLoadedRundown the now rundown id is provided 2026-07-26 16:33:05 +02:00
alex-arc 535717fa90 the rundown title list should also folow the runown selection 2026-07-26 16:07:13 +02:00
alex-arc 477777552c fix rebase 2026-07-26 15:43:20 +02:00
alex-arc df2d083a16 chore: lint 2026-07-26 15:41:51 +02:00
alex-arc 079a2ea96d feat: select rundown in list view 2026-07-26 15:41:51 +02:00
alex-arc c50c84f50b refactor: use context rundown in finder 2026-07-26 15:41:51 +02:00
alex-arc 58dce0a18c refactor: react state for cuesheet v editor layout 2026-07-26 15:41:51 +02:00
alex-arc b2f9b812bd refactor: rundown id context 2026-07-26 15:41:51 +02:00
alex-arc 35d2474797 fixup! refactor: useSuspenseQuery for rundown list 2026-07-26 15:41:41 +02:00
alex-arc b9f5a0a82b refactor: useSuspenseQuery for rundown list 2026-07-26 15:41:41 +02:00
alex-arc 26a58a9241 fixup! refactor: useSuspenseQuery for project list 2026-07-26 15:41:41 +02:00
alex-arc 588b04c46b refactor: useSuspenseQuery for project list 2026-07-26 15:41:41 +02:00
Carlos Valente 5cf36f049a refactor(modal): allow wider modals 2026-07-25 14:28:45 +02:00
Carlos Valente c6248b0c72 refactor(import): improve preview UI 2026-07-25 14:28:45 +02:00
Alex Christoffer Rasmussen 2a890cf2b3 fix: issue where a countToEnd would lead to incorrect expected times (#2149)
* fix: issue where a count-to-end would lead to incorrect expected times

* fix: include add time in overtime when countToEnd

* fix: ui and server use same calculation for expected end
2026-07-25 11:22:24 +02:00
Carlos Valente a8c611911d refactor(rundown): new entries are appended to rundown 2026-07-22 07:17:39 +02:00
Carlos Valente 8363f06a5c chore(rundown): improve documentation of insert logic 2026-07-22 07:17:39 +02:00
will@t1d.dev 5f040092cb fix: timer-legacy view not served in desktop distributions
The server serves html/timer-legacy.html and html/login.html from disk
at runtime, relative to the bundled server. The Docker image copies
these files but the electron packaging did not, so the view 404ed in
all desktop distributions.

Additionally, AppImages mount at /tmp/.mount_*, a hidden directory.
Express sendFile refuses paths containing dot-segments by default
(returns 404 without touching disk), so the view failed on Linux even
with the file packaged. Allow dotfiles for this route; the request
path is fixed so no user input is affected.
2026-07-19 10:02:45 +02:00
Carlos Valente 347c748dd9 feat(rundown): add shortcut to jump to current element 2026-07-18 17:03:02 +02:00
Carlos Valente f234a1f892 fix(rundown): prevent skipped events from influencing day calculations 2026-07-18 16:52:36 +02:00
Carlos Valente 0c5e87b7e7 fix(ui): prevent warning indicator from overflowing container 2026-07-18 16:52:36 +02:00
Carlos Valente 64082ceac0 refactor(rundown): improve visibility of count to end 2026-07-18 16:45:55 +02:00
Carlos Valente f598e8dab1 refactor(time control): improve handling of overtime and count to end 2026-07-18 16:45:55 +02:00
Carlos Valente 0c84a3ff9e fix: delays account for midnight 2026-07-18 16:45:55 +02:00
Carlos Valente c8760b5e9c bump version to 4.11.0 2026-07-18 15:24:25 +02:00
Claude 34360a5b8f feat(import): add merge strategy and new-rundown destination to spreadsheet import
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2026-07-17 22:26:19 +02:00
Claude 5e6debcce0 fix(views): center timer digits with flexbox for embedded browser compatibility
The Stage Timer and PiP Timer views relied on `align-content: center` to
vertically center the timer digits inside `.timer-container`, which is a
plain block element. Aligning children of a block container via
`align-content` only works in Chromium 123+ (Firefox 125+, Safari 17.4+).

Embedded browsers such as vMix Browser Input (CEF V115) and other older
CEF/Chromium-based production tools ignore the property, so the digits
fall back to the top of the container while every other element renders
correctly.

Make `.timer-container` a real flex column and center with
`justify-content: center`, which is universally supported and matches the
centering approach used elsewhere in the codebase. The removed
`justify-self`/`align-self` were no-ops on a full-width flex item.

Fixes #2126

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T6ENZ3r6JXZb1fpYw2oNY
2026-07-17 20:59:49 +02:00
Carlos Valente c191786e50 refactor(navigation): clear params from editor drawer 2026-07-17 15:50:42 +02:00
Carlos Valente 6911e37c18 refactor(automation): improve automation UX 2026-07-17 10:53:45 +02:00
Claude 86b2132cdc feat(automation): expose group title and allow setting secondary text
Surface {{groupNow.*}} template variables (title, note, colour, times,
custom fields) in the automation template autocomplete so events inside a
group can reference their group. The runtime store already carries
groupNow, so substitution and filters worked already; this makes it
discoverable.

Extend the message-secondary action with an optional text field so an
automation can set the secondary message content, not just its source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZYPZVdZLWowU7DyzGkWyy
2026-07-17 10:53:45 +02:00
Carlos Valente 25f0dba83a chore: add codegraph to gitignore 2026-07-16 10:46:10 +02:00
134 changed files with 3890 additions and 1120 deletions
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.10.0",
"version": "4.11.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.10.0",
"version": "4.11.0",
"private": true,
"type": "module",
"dependencies": {
+10
View File
@@ -6,6 +6,7 @@ import {
ProjectRundownsList,
RenumberCues,
Rundown,
RundownImportPayload,
TransientEventPayload,
} from 'ontime-types';
@@ -82,6 +83,15 @@ export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse
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 ======================
// #region operations on rundown entries ==========================
@@ -20,7 +20,7 @@
.wide {
top: 4vh;
min-width: min(1280px, 96vw);
max-width: min(1600px, 96vw);
max-width: min(1800px, 98vw);
height: 88vh;
max-height: 88vh;
display: flex;
@@ -11,6 +11,7 @@ import {
} from '../../../stores/savedViewParams';
import { handleLinks } from '../../../utils/linkUtils';
import IconButton from '../../buttons/IconButton';
import Tooltip from '../../tooltip/Tooltip';
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
import style from './ClientLink.module.scss';
@@ -105,16 +106,20 @@ function BrowserNavigationItem({ current, to, postAction, children }: PropsWithC
{isCustomised && (
<span className={style.trailing}>
<span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' />
<IconButton
variant='ghosted-white'
size='small'
className={style.clear}
aria-label='Clear saved view settings'
title='Clear saved view settings'
onClick={clearViewSettings}
<Tooltip
text='Reset to default'
render={
<IconButton
variant='ghosted-white'
size='small'
className={style.clear}
aria-label='Reset to default'
onClick={clearViewSettings}
/>
}
>
<IoCloseOutline />
</IconButton>
</Tooltip>
</span>
)}
</NavigationMenuItem>
@@ -6,6 +6,7 @@ import { useSearchParams } from 'react-router';
import useViewSettings from '../../hooks-query/useViewSettings';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useSavedViewParams } from '../../stores/savedViewParams';
import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton';
import Info from '../info/Info';
@@ -27,6 +28,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore();
const clearSavedParams = useSavedViewParams((store) => store.clear);
const isSmallScreen = useIsSmallScreen();
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
@@ -36,6 +38,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
};
const resetParams = () => {
clearSavedParams(target);
setSearchParams(getPreservedParams());
};
@@ -1,15 +1,15 @@
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>;
const EntryActionsContext = createContext<EntryActionsContextValue | null>(null);
interface EntryActionsProviderProps extends PropsWithChildren {
actions: EntryActionsContextValue;
}
export function EntryActionsProvider({ children }: PropsWithChildren) {
const { effectiveRundownId } = useRundownSelectionContext();
const actions = useScopedEntryActions(effectiveRundownId);
export function EntryActionsProvider({ children, actions }: EntryActionsProviderProps) {
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 { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types';
import { useSuspenseQuery } from '@tanstack/react-query';
import { ProjectFile, ProjectFileList } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_LIST } from '../api/constants';
import { getProjects } from '../api/db';
const placeholderProjectList: ProjectFileListResponse = {
files: [],
lastLoadedProject: '',
};
function useProjectList() {
const { data, status, refetch } = useQuery({
export function useProjectList() {
const { data, status, refetch } = useSuspenseQuery({
queryKey: PROJECT_LIST,
queryFn: ({ signal }) => getProjects({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
staleTime: MILLIS_PER_HOUR,
});
return { data: data ?? placeholderProjectList, status, refetch };
return { data, status, refetch };
}
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 { MILLIS_PER_HOUR } from 'ontime-utils';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_RUNDOWNS } from '../api/constants';
import {
createRundown,
@@ -11,24 +11,21 @@ import {
loadRundown,
renameRundown,
} from '../api/rundown';
import { ontimeQueryClient } from '../queryClient';
//TODO: make suspends so we don't have to deal with no value all over
/**
* Project rundowns
*/
export function useProjectRundowns() {
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
const { data, status, isError, refetch, isFetching } = useSuspenseQuery<ProjectRundownsList>({
queryKey: PROJECT_RUNDOWNS,
queryFn: ({ signal }) => fetchProjectRundownList({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
staleTime: MILLIS_PER_HOUR,
});
return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching };
return { data, status, isError, refetch, isFetching };
}
export function useMutateProjectRundowns() {
const ontimeQueryClient = useQueryClient();
const { mutateAsync: create } = useMutation({
mutationFn: createRundown,
onMutate: () => {
@@ -1,10 +1,10 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { EntryId, Maybe, OntimeEntry, Rundown } from 'ontime-types';
import { useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
import { getRundownQueryKey } from '../api/constants';
import { fetchRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
@@ -20,43 +20,36 @@ const cachedRundownPlaceholder: Rundown = {
};
/**
* Normalised rundown data for the currently loaded rundown.
*
* 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`.
* Provides access to a specific rundown by ID.
* When rundownId is not provided the loaded rundown is provided
*/
export default function useRundown() {
const queryClient = useQueryClient();
export function useRundown(rundownId: Maybe<string>) {
'use memo';
const {
data: { loaded: loadedRundownId },
} = useProjectRundowns();
const effectiveRundownId = rundownId !== null ? rundownId : loadedRundownId;
const isLoadedRundown = effectiveRundownId === loadedRundownId;
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
queryKey: getRundownQueryKey(effectiveRundownId),
queryFn: ({ signal }) => fetchRundown(effectiveRundownId, { signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
// Seed the id-keyed cache when fetching via the bootstrap alias
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 };
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching, isLoadedRundown };
}
export function useRundownWithMetadata() {
const { data, status } = useRundown();
export function useRundownWithMetadata(rundownId: Maybe<string>) {
'use memo';
const { data, status, isLoadedRundown } = useRundown(rundownId);
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 };
}
@@ -64,8 +57,8 @@ export function useRundownWithMetadata() {
* Provides access to a flat rundown
* built from the order and rundown fields
*/
export function useFlatRundown() {
const { data, status } = useRundown();
export function useFlatRundown(rundownId: Maybe<string>) {
const { data, status } = useRundown(rundownId);
const flatRundown = useMemo(() => {
if (data.revision === -1) {
@@ -77,11 +70,13 @@ export function useFlatRundown() {
return { data: flatRundown, rundownId: data.id, status };
}
export function useFlatRundownWithMetadata() {
const { data, status } = useRundown();
const selectedEventId = useSelectedEventId();
export function useFlatRundownWithMetadata(rundownId: Maybe<string>) {
'use memo';
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 };
}
@@ -92,8 +87,8 @@ export function useFlatRundownWithMetadata() {
* re-filtering on every render.
*
*/
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata();
export function usePartialRundown(rundownId: Maybe<string>, cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata(rundownId);
const filteredData = useMemo(() => {
return data.filter(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
* @deprecated
*/
export function useEntry(entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useRundown();
export function useEntry(rundownId: Maybe<string>, entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useRundown(rundownId);
if (entryId === null) return null;
return rundown.entries[entryId] ?? null;
}
export function useRundownAuxData() {
const { data, status } = useRundown();
export function useRundownAuxData(rundownId: Maybe<string>) {
const { data, status } = useRundown(rundownId);
const filteredData = useMemo(() => {
const { title, id } = data;
return { title, id };
}, [data]);
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],
);
}
+13 -7
View File
@@ -1,7 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
EntryId,
InsertOptions,
MaybeString,
OntimeDelay,
OntimeEntry,
@@ -60,9 +59,15 @@ export type EventOptions = Partial<{
before: MaybeString;
// options of entries of type OntimeEvent
linkPrevious: boolean;
/** the timing reference for the new entry, usually the same as after */
lastEventId: MaybeString;
}>;
type ClientInsertOptions = {
after?: EntryId;
before?: EntryId;
};
/**
* Gather utilities for actions on entries in the loaded rundown.
*/
@@ -120,7 +125,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* @private
*/
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]) => {
const queryKey = resolveCurrentRundownQueryKey();
await queryClient.cancelQueries({ queryKey });
@@ -149,8 +154,9 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
addToRundown(
newRundown,
optimisticEntry,
afterId,
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
afterId,
entry.before ?? null,
);
queryClient.setQueryData<Rundown>(queryKey, newRundown);
@@ -193,7 +199,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
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
if (options?.after) {
@@ -205,8 +211,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) {
// last event id is the timing reference of the previous OntimeEvent in rundown
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];
if (isOntimeEvent(previousEvent)) {
newEntry.timeStart = previousEvent.timeEnd;
@@ -284,7 +290,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* Clone an entry
*/
const clone = useCallback(
async (entryId: EntryId, options?: InsertOptions) => {
async (entryId: EntryId, options?: ClientInsertOptions) => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
@@ -1057,7 +1063,7 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
/**
* 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 id = entryData.id;
let parent: EntryId | null = null;
+11
View File
@@ -139,6 +139,17 @@ export const setEventPlayback = {
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) => ({
...state.timer,
}));
@@ -90,6 +90,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 0,
isPast: true,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -107,6 +108,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 1, // UI indexes are 1 based
isPast: true,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -124,6 +126,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 1,
isPast: true,
isNextDay: false,
isParentToLoaded: true,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -140,6 +143,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2,
isPast: true,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -156,6 +160,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2,
isPast: true,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -172,6 +177,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 3,
isPast: false,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: true,
isLoaded: true,
@@ -188,6 +194,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 4,
isPast: false,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: true,
isLoaded: false,
@@ -204,6 +211,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 5,
isPast: false,
isNextDay: false,
isParentToLoaded: false,
totalGap: 7,
isLinkedToLoaded: false,
isLoaded: false,
@@ -256,6 +264,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 0,
isPast: false,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -273,6 +282,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 1,
isPast: false,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -290,6 +300,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2,
isPast: false,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: 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;
groupEntries: number | undefined;
isFirstAfterGroup: boolean;
isParentToLoaded: boolean; // if the group contains the loaded event
};
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata;
@@ -94,6 +95,7 @@ export function initRundownMetadata(selectedEventId: MaybeString) {
groupColour: undefined,
groupEntries: undefined,
isFirstAfterGroup: false,
isParentToLoaded: false,
};
function process(entry: OntimeEntry): Readonly<RundownMetadata> {
@@ -117,6 +119,7 @@ function processEntry(
// initialise data to be overridden below
processedData.isNextDay = false;
processedData.isLoaded = false;
processedData.isParentToLoaded = false;
processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration
processedData.thisId = entry.id; // we reassign thisId
@@ -132,6 +135,7 @@ function processEntry(
processedData.groupId = entry.id;
processedData.groupColour = entry.colour;
processedData.groupEntries = entry.entries.length;
processedData.isParentToLoaded = selectedEventId ? entry.entries.includes(selectedEventId) : false;
} else {
// for delays and groups, we insert the group metadata
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) {
+4 -8
View File
@@ -14,7 +14,6 @@ import {
APP_SETTINGS,
CLIENT_LIST,
CSS_OVERRIDE,
CURRENT_RUNDOWN_QUERY_KEY,
CUSTOM_FIELDS,
PROJECT_DATA,
REPORT,
@@ -25,6 +24,7 @@ import {
VIEW_SETTINGS,
getRundownQueryKey,
PROJECT_RUNDOWNS,
PROJECT_LIST,
} from '../api/constants';
import { invalidateAllCaches } from '../api/utils';
import { ontimeQueryClient } from '../queryClient';
@@ -216,6 +216,9 @@ export const connectSocket = () => {
case RefetchKey.ProjectRundowns:
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS });
break;
case RefetchKey.ProjectFiles:
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_LIST });
break;
default: {
target satisfies never;
break;
@@ -238,7 +241,6 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
if (!rundownId) {
// we omit rundownId to signify invalidate all rundowns
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
return;
}
@@ -252,12 +254,6 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
}
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>(
+9 -4
View File
@@ -3,7 +3,9 @@ import {
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
MILLIS_PER_SECOND,
dayInMs,
formatFromMillis,
getExpectedEnd,
getExpectedStart,
} from 'ontime-utils';
@@ -29,6 +31,11 @@ export function nowInMillis(): number {
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
* @return {string|null} A format string like "hh:mm:ss a" or null
@@ -180,15 +187,13 @@ export function getExpectedTimesFromExtendedEvent(
...state,
},
);
const expectedEnd = getExpectedEnd(event, expectedStart, state.currentDay);
const plannedEnd = event.timeStart + event.duration + event.delay;
return {
expectedStart,
timeToStart: expectedStart - state.clock,
expectedEnd: event.countToEnd
? Math.max(expectedStart + event.duration, plannedEnd)
: expectedStart + event.duration,
expectedEnd,
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) {
return file?.name.endsWith('.xlsx');
}
@@ -293,8 +293,9 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<h3>Outputs</h3>
<Info>
Automation outputs can be used to send data from Ontime to external software <br />
or to change properties of Ontime itself.
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
or to change properties of Ontime itself. <br /> <br />
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
</Info>
{fieldOutputs.map((output, index) => {
@@ -341,12 +342,17 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
</label>
<label>
Address
<Input {...register(`outputs.${index}.address`)} fluid placeholder='/cue/start' />
<TemplateInput
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label>
Arguments
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} placeholder='1' />
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
<div>
@@ -376,7 +382,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<div className={style.httpSection}>
<label>
Target URL
<Input
<TemplateInput
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: {
@@ -384,6 +390,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
message: 'HTTP messages should target http:// or https://',
},
})}
value={output.url}
fluid
placeholder='http://127.0.0.1/start/1'
/>
@@ -5,6 +5,7 @@ import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form'
import Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select';
import * as Panel from '../../panel-utils/PanelUtils';
import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss';
@@ -71,8 +72,8 @@ export default function OntimeActionForm({
{ value: 'playback-pause', label: 'Playback: pause' },
{ value: 'playback-roll', label: 'Playback: roll' },
{ value: 'message-set', label: 'Primary Message: set' },
{ value: 'message-secondary', label: 'Secondary Message: source' },
{ value: 'message-set', label: 'Primary Message' },
{ value: 'message-secondary', label: 'Secondary Message' },
]}
/>
<Panel.Error>{rowErrors?.action?.message}</Panel.Error>
@@ -96,7 +97,12 @@ export default function OntimeActionForm({
<>
<label>
Text (leave empty for no change)
<Input {...register(`outputs.${index}.text`)} fluid placeholder='eg: Timer is finished' />
<TemplateInput
{...register(`outputs.${index}.text`)}
value={watch(`outputs.${index}.text`) ?? ''}
fluid
placeholder='eg: Timer is finished'
/>
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label>
<label>
@@ -120,31 +126,48 @@ export default function OntimeActionForm({
)}
{selectedAction === 'message-secondary' && (
<label>
Timer secondary source
<Select<SecondarySource | 'null' | null>
onValueChange={(value) => {
// null -> no selection
if (value === null) return;
// 'null' -> clear the secondary source
if (value === 'null') {
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
return;
}
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
}}
value={watch(`outputs.${index}.secondarySource`)}
options={[
{ value: null, label: 'Select secondary source' },
{ value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
]}
/>
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label>
<>
<label>
Text (leave empty for no change)
<TemplateInput
{...register(`outputs.${index}.text`)}
value={watch(`outputs.${index}.text`) ?? ''}
fluid
placeholder='eg: Next up: keynote'
/>
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label>
<label>
Timer secondary source
<Select<SecondarySource | 'no-change' | 'null' | null>
onValueChange={(value) => {
// null -> no selection
if (value === null) return;
// no-change -> leave the current secondary source untouched
if (value === 'no-change') {
setValue(`outputs.${index}.secondarySource`, undefined, { shouldDirty: true });
return;
}
// 'null' -> clear the secondary source
if (value === 'null') {
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
return;
}
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
}}
value={watch(`outputs.${index}.secondarySource`) ?? 'no-change'}
options={[
{ value: 'no-change', label: 'No change' },
{ value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
]}
/>
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label>
</>
)}
<div className={style.test}>{children}</div>
@@ -79,7 +79,7 @@ export default function TriggersList(props: TriggersListProps) {
<tbody>
{!showForm && triggers.length === 0 && (
<Panel.TableEmpty
label='Create an automation to attach triggers to'
label='Create a trigger to run an automation'
handleClick={canAdd ? () => setShowForm(true) : undefined}
/>
)}
@@ -1,29 +1,67 @@
.wrapper {
.inputShell {
position: relative;
}
.suggestions {
background: $gray-1250;
color: $ui-white;
position: absolute;
top: 100%;
left: 0;
.fluid {
width: 100%;
margin: 0;
z-index: $zindex-floating;
padding-block: 0.25rem;
}
max-height: 200px;
overflow-y: auto;
.input {
padding-right: 2rem;
}
.expandButton {
position: absolute;
top: 50%;
right: 0.25rem;
transform: translateY(-50%);
}
.positioner {
outline: 0;
}
.popup {
box-sizing: border-box;
min-width: var(--anchor-width);
max-width: var(--available-width);
border: 1px solid $gray-1000;
border-radius: $component-border-radius-md;
background: $gray-1250;
color: $label-gray;
}
li {
padding: 0.25rem;
}
.list {
box-sizing: border-box;
max-height: min(20rem, var(--available-height));
overflow-y: auto;
overscroll-behavior: contain;
padding-block: 0.25rem;
outline: 0;
}
li:hover {
.item {
box-sizing: border-box;
padding: 0.25rem 0.5rem;
outline: 0;
cursor: default;
user-select: none;
overflow-wrap: anywhere;
&[data-highlighted] {
color: $ui-white;
background: $blue-700;
}
}
.expandedEditor {
min-height: min(18rem, 45vh);
font-family: monospace;
line-height: 1.4;
}
.footerHint {
margin-right: auto;
color: $label-gray;
font-size: calc(1rem - 2px);
}
@@ -1,69 +1,258 @@
import { mergeRefs, useClickOutside } from '@mantine/hooks';
import { forwardRef, useMemo, useState } from 'react';
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
import type { ChangeEvent, ReactNode, Ref } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { IoExpandOutline } from 'react-icons/io5';
import Input, { type InputProps } from '../../../../../common/components/input/input/Input';
import Button from '../../../../../common/components/buttons/Button';
import IconButton from '../../../../../common/components/buttons/IconButton';
import { type InputProps } from '../../../../../common/components/input/input/Input';
import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Modal from '../../../../../common/components/modal/Modal';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils';
import { cx } from '../../../../../common/utils/styleUtils';
import { makeAutoCompleteList } from './templateInput.utils';
import { useTemplateAutocomplete } from './useTemplateAutocomplete';
import inputStyle from '../../../../../common/components/input/input/Input.module.scss';
import style from './TemplateInput.module.scss';
interface TemplateInputProps extends InputProps {}
interface TemplateInputProps extends Omit<InputProps, 'value'> {
ref?: Ref<HTMLInputElement>;
value?: string;
}
const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) {
const { value, onChange, ...rest } = props;
interface TemplateEditorModalProps {
autocompleteList: string[];
isOpen: boolean;
onClose: () => void;
onSave: (value: string) => void;
value: string;
}
type TemplateAutocompleteState = ReturnType<typeof useTemplateAutocomplete<HTMLInputElement>>;
interface TemplateAutocompleteRootProps {
autocomplete: TemplateAutocompleteState;
children: ReactNode;
value: string;
}
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
if (typeof ref === 'function') {
ref(value);
} else if (ref) {
ref.current = value;
}
}
function emitInputChange(name: string | undefined, value: string, onChange: InputProps['onChange']) {
onChange?.({
target: { name, value },
currentTarget: { name, value },
} as ChangeEvent<HTMLInputElement>);
}
export default function TemplateInput({
className,
disabled,
fluid,
height = 'medium',
onChange,
readOnly,
ref,
value,
variant = 'subtle',
...rest
}: TemplateInputProps) {
const { data } = useCustomFields();
const localRef = useClickOutside(() => setShowSuggestions(false));
const inputRef = useRef<HTMLInputElement | null>(null);
const [inputValue, setInputValue] = useState(value || '');
const [isExpanded, setIsExpanded] = useState(false);
const autocompleteList = useMemo(() => {
return makeAutoCompleteList(data);
}, [data]);
const [inputValue, setInputValue] = useState(value || '');
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const updateInputValue = useCallback(
(nextValue: string) => {
setInputValue(nextValue);
emitInputChange(rest.name, nextValue, onChange);
},
[onChange, rest.name],
);
const updateSuggestions = (value: string) => {
const template = selectFromLastTemplate(value);
return autocompleteList.filter((suggestion) => suggestion.startsWith(template));
const autocomplete = useTemplateAutocomplete(inputValue, autocompleteList, inputRef, updateInputValue);
const { setCursorForValue } = autocomplete;
// Keep the local autocomplete input in sync when react-hook-form resets or swaps field-array values.
useEffect(() => {
const nextValue = value || '';
setInputValue(nextValue);
setCursorForValue(nextValue, nextValue.length);
}, [setCursorForValue, value]);
const setInputRef = useCallback(
(element: HTMLInputElement | null) => {
inputRef.current = element;
assignRef(ref, element);
},
[ref],
);
const openExpandedEditor = () => {
autocomplete.setShowSuggestions(false);
setIsExpanded(true);
};
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
if (event.target.value.endsWith('{')) {
setShowSuggestions(true);
setSuggestions(updateSuggestions(event.target.value));
} else if (event.target.value === '' || event.target.value.endsWith('}}')) {
setShowSuggestions(false);
} else if (showSuggestions) {
setSuggestions(updateSuggestions(event.target.value));
}
onChange?.(event);
const closeExpandedEditor = () => {
setIsExpanded(false);
};
const handleSuggestion = (value: string) => {
setInputValue((prev) => {
const remaining = matchRemaining(prev as string, value);
return prev + remaining;
});
setShowSuggestions(false);
const saveExpandedEditor = (nextValue: string) => {
updateInputValue(nextValue);
autocomplete.setCursorForValue(nextValue, nextValue.length);
setIsExpanded(false);
};
return (
<div className={style.wrapper} ref={mergeRefs(localRef, ref)}>
<Input value={inputValue} {...rest} onChange={handleInputChange} fluid />
{showSuggestions && suggestions.length > 0 && (
<ul className={style.suggestions}>
{suggestions.map((suggestion) => (
<li key={suggestion} onClick={() => handleSuggestion(suggestion)}>
{suggestion}
</li>
))}
</ul>
)}
</div>
<>
<TemplateAutocompleteRoot autocomplete={autocomplete} value={inputValue}>
<div className={cx([style.inputShell, fluid && style.fluid])}>
<BaseAutocomplete.Input
ref={setInputRef}
className={cx([
inputStyle.input,
inputStyle[variant],
inputStyle[height],
fluid && inputStyle.fluid,
style.input,
className,
])}
{...rest}
disabled={disabled}
onClick={autocomplete.updateCursor}
onFocus={autocomplete.updateCursor}
onKeyUp={autocomplete.updateCursor}
onSelect={autocomplete.updateCursor}
readOnly={readOnly}
/>
<IconButton
aria-label='Expand template editor'
className={style.expandButton}
disabled={disabled || readOnly}
onClick={openExpandedEditor}
size='small'
title='Expand template editor'
variant='ghosted-white'
>
<IoExpandOutline />
</IconButton>
</div>
</TemplateAutocompleteRoot>
<TemplateEditorModal
autocompleteList={autocompleteList}
isOpen={isExpanded}
onClose={closeExpandedEditor}
onSave={saveExpandedEditor}
value={inputValue}
/>
</>
);
});
}
export default TemplateInput;
function TemplateAutocompleteRoot({ autocomplete, children, value }: TemplateAutocompleteRootProps) {
return (
<BaseAutocomplete.Root
items={autocomplete.suggestions}
autoHighlight
highlightItemOnHover
mode='none'
open={autocomplete.open}
value={value}
onOpenChange={autocomplete.setShowSuggestions}
onValueChange={autocomplete.handleValueChange}
>
{children}
<TemplateSuggestionPopup />
</BaseAutocomplete.Root>
);
}
function TemplateEditorModal({ autocompleteList, isOpen, onClose, onSave, value }: TemplateEditorModalProps) {
const expandedInputRef = useRef<HTMLTextAreaElement | null>(null);
const [draftValue, setDraftValue] = useState(value);
const autocomplete = useTemplateAutocomplete(draftValue, autocompleteList, expandedInputRef, setDraftValue);
const { setShowSuggestions } = autocomplete;
// Reset the draft whenever the modal opens so cancel never leaks unsaved changes.
useEffect(() => {
if (!isOpen) {
return;
}
setDraftValue(value);
setShowSuggestions(false);
}, [isOpen, setShowSuggestions, value]);
const handleClose = () => {
setShowSuggestions(false);
onClose();
};
const handleSave = () => {
setShowSuggestions(false);
onSave(draftValue);
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
showBackdrop
showCloseButton
title='Edit template'
bodyElements={
<TemplateAutocompleteRoot autocomplete={autocomplete} value={draftValue}>
<BaseAutocomplete.Input
autoFocus
className={style.expandedEditor}
onClick={autocomplete.updateCursor}
onFocus={autocomplete.updateCursor}
onKeyUp={autocomplete.updateCursor}
onSelect={autocomplete.updateCursor}
render={<Textarea ref={expandedInputRef} fluid resize='none' rows={8} />}
/>
</TemplateAutocompleteRoot>
}
footerElements={
<>
<span className={style.footerHint}>Start a template with {'{{'} to see autocomplete.</span>
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleSave} variant='primary'>
Save
</Button>
</>
}
/>
);
}
function TemplateSuggestionPopup() {
return (
<BaseAutocomplete.Portal>
<BaseAutocomplete.Positioner side='bottom' align='start' className={style.positioner}>
<BaseAutocomplete.Popup className={style.popup}>
<BaseAutocomplete.List className={style.list}>
<BaseAutocomplete.Collection>
{(suggestion: string) => (
<BaseAutocomplete.Item key={suggestion} value={suggestion} className={style.item}>
{suggestion}
</BaseAutocomplete.Item>
)}
</BaseAutocomplete.Collection>
</BaseAutocomplete.List>
</BaseAutocomplete.Popup>
</BaseAutocomplete.Positioner>
</BaseAutocomplete.Portal>
);
}
@@ -1,4 +1,4 @@
import { matchRemaining } from '../templateInput.utils';
import { completeTemplateAtCursor, matchRemaining, selectActiveTemplate } from '../templateInput.utils';
describe('matchRemaining()', () => {
it('should return a partial string needed for autocomplete', () => {
@@ -15,3 +15,54 @@ describe('matchRemaining()', () => {
expect(matchRemaining('test', 'test')).toBe('');
});
});
describe('selectActiveTemplate()', () => {
it('returns the last unclosed template fragment', () => {
expect(selectActiveTemplate('send {{event')).toBe('{{event');
expect(selectActiveTemplate('send {{eventNow.title}} and {{event')).toBe('{{event');
});
it('ignores single braces and closed templates', () => {
expect(selectActiveTemplate('send {')).toBe('');
expect(selectActiveTemplate('send {{eventNow.title}}')).toBe('');
});
it('only considers templates before the cursor', () => {
expect(selectActiveTemplate('send {{event}} then {{timer', 14)).toBe('');
expect(selectActiveTemplate('send {{event}} then {{timer', 27)).toBe('{{timer');
});
it('selects a partial template when the cursor is inside a completed template', () => {
expect(selectActiveTemplate('send {{timer.current}} after', 12)).toBe('{{timer');
});
});
describe('completeTemplateAtCursor()', () => {
it('completes the active template before the cursor', () => {
expect(completeTemplateAtCursor('send {{timer after', '{{timer.current}}', 12)).toEqual({
value: 'send {{timer.current}} after',
cursorIndex: 22,
});
});
it('preserves text before and after the cursor', () => {
expect(completeTemplateAtCursor('before {{event after', '{{eventNow.title}}', 14)).toEqual({
value: 'before {{eventNow.title}} after',
cursorIndex: 25,
});
});
it('preserves a following template when completing between templates', () => {
expect(completeTemplateAtCursor('{{clock}} and {{timer then {{eventNow.title}}', '{{timer.current}}', 21)).toEqual({
value: '{{clock}} and {{timer.current}} then {{eventNow.title}}',
cursorIndex: 31,
});
});
it('replaces the whole template when the cursor is inside a completed template', () => {
expect(completeTemplateAtCursor('before {{timer.current}} after', '{{timer.duration}}', 15)).toEqual({
value: 'before {{timer.duration}} after',
cursorIndex: 25,
});
});
});
@@ -54,6 +54,16 @@ const eventStaticPropertiesNext = [
'{{eventNext.delay}}',
];
const groupStaticPropertiesNow = [
'{{groupNow.id}}',
'{{groupNow.title}}',
'{{groupNow.note}}',
'{{groupNow.colour}}',
'{{groupNow.timeStart}}',
'{{groupNow.timeEnd}}',
'{{groupNow.duration}}',
];
const staticAuxProperties = (index: 1 | 2 | 3) => [
`{{auxtimer${index}.current}}`,
`{{auxtimer${index}.duration}}`,
@@ -75,12 +85,19 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
...eventStaticPropertiesNext,
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
...groupStaticPropertiesNow,
...Object.entries(customFields).map(([key]) => `{{groupNow.custom.${key}}}`),
...staticAuxProperties(1),
...staticAuxProperties(2),
...staticAuxProperties(3),
];
}
interface TemplateCompletion {
cursorIndex: number;
value: string;
}
/**
* Returns the partial string b needed to autocomplete string a
* @example matchRemaining('te', 'test') -> 'st'
@@ -111,13 +128,54 @@ export function matchRemaining(a: string, b: string) {
return '';
}
/**
* Selects the last starting template in a string
*/
export function selectFromLastTemplate(text: string) {
const lastBraceIndex = text.lastIndexOf('{{');
if (lastBraceIndex !== -1) {
return text.slice(lastBraceIndex);
function getActiveTemplateRange(text: string, cursorIndex = text.length) {
const textBeforeCursor = text.slice(0, cursorIndex);
const start = textBeforeCursor.lastIndexOf('{{');
if (start === -1) {
return null;
}
return '';
const closeBeforeCursor = textBeforeCursor.lastIndexOf('}}');
if (closeBeforeCursor > start) {
return null;
}
const closeAfterStart = text.indexOf('}}', start);
const nextStartAfterCursor = text.indexOf('{{', cursorIndex);
const closesBeforeNextTemplate = nextStartAfterCursor === -1 || closeAfterStart < nextStartAfterCursor;
const end = closeAfterStart !== -1 && closesBeforeNextTemplate ? closeAfterStart + 2 : cursorIndex;
return {
end,
start,
template: text.slice(start, cursorIndex),
};
}
/**
* Selects the last unclosed starting template before the cursor.
*/
export function selectActiveTemplate(text: string, cursorIndex = text.length) {
return getActiveTemplateRange(text, cursorIndex)?.template ?? '';
}
/**
* Replaces the active template fragment before the cursor with the selected suggestion.
*/
export function completeTemplateAtCursor(
text: string,
suggestion: string,
cursorIndex = text.length,
): TemplateCompletion {
const activeTemplateRange = getActiveTemplateRange(text, cursorIndex);
if (!activeTemplateRange) {
const value = text + matchRemaining(text, suggestion);
return { value, cursorIndex: value.length };
}
const value = `${text.slice(0, activeTemplateRange.start)}${suggestion}${text.slice(activeTemplateRange.end)}`;
return {
value,
cursorIndex: activeTemplateRange.start + suggestion.length,
};
}
@@ -0,0 +1,72 @@
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
import { useCallback, useMemo, useState, type RefObject } from 'react';
import { completeTemplateAtCursor, selectActiveTemplate } from './templateInput.utils';
type TemplateElement = HTMLInputElement | HTMLTextAreaElement;
function getCursorIndex(element: TemplateElement | null, fallback: string) {
return element?.selectionStart ?? fallback.length;
}
function focusCursor(element: TemplateElement | null, cursorIndex: number) {
requestAnimationFrame(() => {
element?.focus();
element?.setSelectionRange(cursorIndex, cursorIndex);
});
}
export function useTemplateAutocomplete<T extends TemplateElement>(
value: string,
autocompleteList: string[],
elementRef: RefObject<T | null>,
onValueChange: (value: string) => void,
) {
const [showSuggestions, setShowSuggestions] = useState(false);
const [cursor, setCursor] = useState(value.length);
const activeTemplate = selectActiveTemplate(value, cursor);
const suggestions = useMemo(() => {
if (!activeTemplate) {
return [];
}
return autocompleteList.filter((suggestion) => suggestion.startsWith(activeTemplate));
}, [activeTemplate, autocompleteList]);
const setCursorForValue = useCallback((nextValue: string, cursorIndex: number) => {
setCursor(cursorIndex);
setShowSuggestions(Boolean(selectActiveTemplate(nextValue, cursorIndex)));
}, []);
const updateCursor = useCallback(() => {
const cursorIndex = getCursorIndex(elementRef.current, value);
setCursorForValue(value, cursorIndex);
}, [elementRef, setCursorForValue, value]);
const handleValueChange = useCallback(
(nextValue: string, eventDetails: BaseAutocomplete.Root.ChangeEventDetails) => {
if (eventDetails.reason === 'item-press') {
eventDetails.cancel();
const completed = completeTemplateAtCursor(value, nextValue, cursor);
setCursorForValue(completed.value, completed.cursorIndex);
onValueChange(completed.value);
focusCursor(elementRef.current, completed.cursorIndex);
return;
}
const cursorIndex = getCursorIndex(elementRef.current, nextValue);
setCursorForValue(nextValue, cursorIndex);
onValueChange(nextValue);
},
[cursor, elementRef, onValueChange, setCursorForValue, value],
);
return {
handleValueChange,
open: showSuggestions && suggestions.length > 0,
setCursorForValue,
setShowSuggestions,
suggestions,
updateCursor,
};
}
@@ -5,7 +5,7 @@ import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
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 { formatTime } from '../../../../common/utils/time';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -15,7 +15,7 @@ import style from './ReportSettings.module.scss';
export default function ReportSettings() {
const { data: reportData } = useReport();
const { data } = useRundown();
const { data } = useRundown(null);
const clearReport = async () => await deleteAllReport();
const downloadCSV = (combinedReport: CombinedReport[]) => {
@@ -1,5 +1,5 @@
import { useDisclosure } from '@mantine/hooks';
import { useState } from 'react';
import { Suspense, useState } from 'react';
import {
IoAdd,
IoDocumentOutline,
@@ -18,9 +18,9 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Tag from '../../../../common/components/tag/Tag';
import { useDirectLinkToBackgroundEdit } from '../../../../common/context/RundownSelectionContext';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { cx } from '../../../../common/utils/styleUtils';
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm';
@@ -28,6 +28,20 @@ import { ManageRundownForm } from './ManageRundownForm';
import style from './ManagePanel.module.scss';
export default function ManageRundowns() {
return (
<Suspense
fallback={
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
}
>
<ManageRundownsSuspense />
</Suspense>
);
}
function ManageRundownsSuspense() {
const { data } = useProjectRundowns();
const { duplicate, remove, load, rename } = useMutateProjectRundowns();
const [isOpenDelete, deleteHandlers] = useDisclosure();
@@ -25,6 +25,13 @@
margin-top: 1rem;
}
.finishActions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.sourceGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1,4 +1,6 @@
import type {
ImportedFields,
RundownImportMode,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
SpreadsheetWorksheetOptions,
@@ -6,6 +8,7 @@ import type {
import { getErrorMessage, ImportMap } from 'ontime-utils';
import { ChangeEvent, useCallback, useRef, useState } from 'react';
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router';
import {
getWorksheetMetadata as getExcelWorksheetMetadata,
@@ -21,8 +24,8 @@ import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../../common/components/modal/Modal';
import useRundown from '../../../../../common/hooks-query/useRundown';
import { validateExcelImport } from '../../../../../common/utils/uploadUtils';
import { useRundown } from '../../../../../common/hooks-query/useRundown';
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import GSheetSetup from './GSheetSetup';
import SheetImportEditor from './sheet-import/SheetImportEditor';
@@ -35,6 +38,7 @@ const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadshee
type ActiveSource =
| {
kind: 'excel';
fileName: string;
worksheetNames: string[];
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
closedByUser: boolean;
@@ -53,9 +57,11 @@ export default function SourcesPanel() {
const [error, setError] = useState('');
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
const { data: currentRundown } = useRundown();
const { importRundown } = useSpreadsheetImport();
const { data: currentRundown } = useRundown(null);
const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -74,6 +80,7 @@ export default function SourcesPanel() {
const worksheetOptions = await uploadExcel(fileToUpload);
setActiveSource({
kind: 'excel',
fileName: fileToUpload.name,
worksheetNames: worksheetOptions.worksheets,
initialWorksheetMetadata: worksheetOptions.metadata,
closedByUser: false,
@@ -97,6 +104,7 @@ export default function SourcesPanel() {
setHasFile('none');
setActiveSource(null);
setError('');
setCompletedRundownTitle('');
};
const openGSheetFlow = () => {
@@ -119,29 +127,41 @@ export default function SourcesPanel() {
}
};
const handleFinished = () => {
const handleFinished = (rundownTitle: string) => {
setImportFlow('finished');
setHasFile('none');
setActiveSource(null);
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) {
throw new Error('No current rundown loaded');
}
await importRundown(
{
[currentRundown.id]: {
...preview.rundown,
id: currentRundown.id,
title: currentRundown.title,
},
},
preview.customFields,
);
handleFinished();
// 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)
await applyImport({
mode,
targetRundownId: currentRundown.id,
rundown: preview.rundown,
customFields: preview.customFields,
providedFields,
});
handleFinished(currentRundown.title);
};
const loadWorksheetMetadata = useCallback(
@@ -208,6 +228,13 @@ export default function SourcesPanel() {
if (activeSource.kind === 'excel') return 'excel';
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 (
<Panel.Section>
@@ -267,11 +294,20 @@ export default function SourcesPanel() {
{showCompleted && (
<div className={style.finishSection}>
<span className={style.finishBadge}>Import complete</span>
<div className={style.finishTitle}>Spreadsheet data applied.</div>
<div className={style.finishDescription}>You can close this flow or start another import.</div>
<Button variant='subtle-white' onClick={resetFlow}>
Reset flow
</Button>
<div className={style.finishTitle}>
Spreadsheet data applied to {completedRundownTitle || 'your rundown'}.
</div>
<div className={style.finishDescription}>
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>
)}
{isGSheetFlow && (
@@ -287,6 +323,7 @@ export default function SourcesPanel() {
bodyElements={
<SheetImportEditor
sourceKey={sourceKey ?? 'spreadsheet'}
defaultRundownName={spreadsheetName}
worksheetNames={activeSource?.worksheetNames ?? []}
initialMetadata={activeSource?.initialWorksheetMetadata ?? null}
loadMetadata={loadWorksheetMetadata}
@@ -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>
);
}
@@ -33,6 +33,11 @@
white-space: nowrap;
}
.importModeTrigger {
min-width: 12rem;
justify-content: space-between;
}
.addColumnTrigger {
justify-content: center;
white-space: nowrap;
@@ -87,6 +92,10 @@
font-weight: 600;
}
.toolbarWarning {
color: $orange-400;
}
.mappingPaneTitle {
align-self: center;
}
@@ -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 { 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 { DropdownMenu, DropdownMenuOption } from '../../../../../../common/components/dropdown-menu/DropdownMenu';
import Input from '../../../../../../common/components/input/input/Input';
import Select from '../../../../../../common/components/select/Select';
import * as Panel from '../../../../panel-utils/PanelUtils';
import ApplyImportButton from './ApplyImportButton';
import { isIdColumnMapped } from './importMapUtils';
import PreviewTable from './preview/PreviewTable';
import SheetImportMappingPane from './SheetImportMappingPane';
import { useSheetImportForm } from './useSheetImportForm';
@@ -13,17 +23,46 @@ import style from './SheetImportEditor.module.scss';
interface SheetImportEditorProps {
sourceKey: string;
defaultRundownName: string;
worksheetNames: string[];
initialMetadata: SpreadsheetWorksheetMetadata | null;
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
previewImport: (importMap: ImportMap) => Promise<SpreadsheetPreviewResponse>;
onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>;
onApply: (
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => Promise<void>;
onCancel: () => 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({
sourceKey,
defaultRundownName,
worksheetNames,
initialMetadata,
loadMetadata,
@@ -49,11 +88,16 @@ export default function SheetImportEditor({
isBusy,
canPreview,
displayError,
importMode,
setImportMode,
newRundownTitle,
setNewRundownTitle,
handlePreviewSubmit,
handleExportSubmit,
handleApply,
} = useSheetImportForm({
sourceKey,
defaultRundownName,
worksheetNames,
initialMetadata,
loadMetadata,
@@ -62,6 +106,19 @@ export default function SheetImportEditor({
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 (
<Panel.Section as='form' id='spreadsheet-import-workspace' className={style.editor} onSubmit={handlePreviewSubmit}>
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorToolbar}>
@@ -75,7 +132,16 @@ export default function SheetImportEditor({
}
/>
</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>
<div className={style.editorBody}>
@@ -93,13 +159,19 @@ export default function SheetImportEditor({
<section className={style.previewPane}>
<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 className={style.tableShell}>
<PreviewTable
preview={state.preview}
columnLabels={columnLabels}
canRefresh={canPreview}
isLoadingMetadata={isLoadingMetadata}
isRefreshing={state.loading === 'preview'}
needsPreviewRefresh={state.needsPreviewRefresh}
onRefresh={handlePreviewSubmit}
worksheetHeaders={worksheetHeaders}
/>
</div>
@@ -107,33 +179,63 @@ export default function SheetImportEditor({
</div>
{displayError && <Panel.Error>{displayError}</Panel.Error>}
<Panel.InlineElements align='end' wrap='wrap' className={style.editorFooter}>
<Button onClick={onCancel} disabled={isBusy}>
Cancel
</Button>
{onExport && (
<Button onClick={handleExportSubmit} disabled={!canPreview} loading={state.loading === 'export'}>
<IoArrowUpOutline />
Export
{importMode === 'merge' && !isIdColumnMapped(values) && (
<Panel.Description tone='warning'>
<IoWarningOutline /> No ID column mapped merge matches entries by ID, so it will behave like Replace. Export
your rundown to a spreadsheet first to keep its IDs.
</Panel.Description>
)}
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorFooter}>
<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
variant={state.preview ? undefined : 'primary'}
onClick={handlePreviewSubmit}
disabled={!canPreview}
loading={state.loading === 'preview'}
>
<IoEye />
Preview import
</Button>
<Button
variant='primary'
onClick={handleApply}
disabled={!state.preview || isBusy}
loading={state.loading === 'apply'}
>
Apply import
</Button>
{onExport && (
<Button onClick={handleExportSubmit} disabled={!canPreview} loading={state.loading === 'export'}>
<IoArrowUpOutline />
Export
</Button>
)}
<Button
variant={state.preview ? undefined : 'primary'}
onClick={handlePreviewSubmit}
disabled={!canPreview}
loading={state.loading === 'preview'}
>
<IoEye />
Preview import
</Button>
<ApplyImportButton
preview={state.preview}
mode={importMode}
disabled={!state.preview || isBusy}
loading={state.loading === 'apply'}
onApply={handleApply}
/>
</Panel.InlineElements>
</Panel.InlineElements>
</Panel.Section>
);
@@ -1,15 +1,21 @@
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';
import {
builtInFieldDefs,
convertToImportMap,
createDefaultFormValues,
defaultImportMode,
getImportWarnings,
getPersistedImportMode,
getProvidedImportFields,
getResolvedCustomFields,
isIdColumnMapped,
persistImportMode,
} from '../importMapUtils';
const cueIndex = builtInFieldDefs.findIndex((field) => field.label === 'Cue');
const titleIndex = builtInFieldDefs.findIndex((field) => field.label === 'Title');
const idIndex = builtInFieldDefs.findIndex((field) => field.importKey === 'id');
describe('getImportWarnings()', () => {
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');
});
});
@@ -1,3 +1,4 @@
import type { ImportedFields, RundownImportMode } from 'ontime-types';
import type { ImportMap } from 'ontime-utils';
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 {
// Replace punctuation with spaces, then collapse repeated whitespace into single spaces.
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 {
const custom = getResolvedCustomFields(values.custom).reduce<Record<string, string>>(
(accumulator, { ontimeName, importName }) => {
@@ -130,12 +159,12 @@ function isPersistedFormValues(obj: unknown): obj is ImportFormValues {
export function getPersistedImportState(sourceKey: string): { values: ImportFormValues; isPersisted: boolean } {
const storageKey = getImportMapKey(sourceKey);
try {
const raw = localStorage.getItem(storageKey);
if (!raw) {
const persistedData = localStorage.getItem(storageKey);
if (!persistedData) {
return { values: createDefaultFormValues(), isPersisted: false };
}
const parsed: unknown = JSON.parse(raw);
const parsed: unknown = JSON.parse(persistedData);
if (isPersistedFormValues(parsed)) {
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.
*/
@@ -1,27 +1,35 @@
.emptyState {
height: 100%;
min-height: 16rem;
display: grid;
place-content: center;
gap: 0.35rem;
padding: 1.5rem;
padding: 3rem 1.5rem;
text-align: center;
}
.emptyMessage {
width: min(30rem, 100%);
margin-inline: auto;
}
.emptyTitle {
color: $ui-white;
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.25rem;
color: rgba($gray-200, 0.72);
font-size: calc(1rem + 2px);
font-weight: 400;
}
.emptyBody {
color: $gray-400;
font-size: 0.95rem;
color: rgba($gray-200, 0.55);
font-size: calc(1rem - 3px);
line-height: 1.5;
}
.emptyAction {
margin: 1rem auto 0;
}
.table {
width: 100%;
border-collapse: collapse;
border-collapse: separate;
border-spacing: 0;
color: $ui-white;
font-size: calc(1rem - 2px);
text-align: left;
table-layout: auto;
@@ -34,31 +42,101 @@
}
th {
font-weight: 400;
color: $gray-400;
text-transform: capitalize;
vertical-align: top;
color: $gray-300;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
vertical-align: bottom;
white-space: normal;
}
th,
td {
padding: 0.5rem;
box-sizing: border-box;
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) {
background-color: $white-1;
tbody tr {
--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 {
width: 4.5rem;
min-width: 4.5rem;
left: 0;
width: 3.25rem;
min-width: 3.25rem !important;
color: $gray-400;
text-align: right;
}
.rowType {
width: 7rem;
min-width: 7rem;
left: 3.25rem;
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;
}
@@ -1,7 +1,11 @@
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 Button from '../../../../../../../common/components/buttons/Button';
import Tag from '../../../../../../../common/components/tag/Tag';
import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata';
import { getCellValue } from './previewTableUtils';
import style from './PreviewTable.module.scss';
@@ -9,14 +13,85 @@ import style from './PreviewTable.module.scss';
interface PreviewTableProps {
preview: SpreadsheetPreviewResponse | null;
columnLabels: string[];
canRefresh: boolean;
isLoadingMetadata: boolean;
isRefreshing: boolean;
needsPreviewRefresh: boolean;
onRefresh: () => void;
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({
preview,
columnLabels,
canRefresh,
isLoadingMetadata,
isRefreshing,
needsPreviewRefresh,
onRefresh,
worksheetHeaders,
}: PreviewTableProps) {
const customFieldKeyByLabel = useMemo(() => {
@@ -24,33 +99,57 @@ export default function PreviewTable({
return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId]));
}, [preview]);
const displayColumns = useMemo(() => getDisplayColumns(columnLabels), [columnLabels]);
const previewMetadata = useMemo(() => {
if (!preview) return null;
return getRundownMetadata(preview.rundown, null);
}, [preview]);
if (!preview) {
let emptyTitle = 'Preview not generated';
let emptyContent = 'Select the fields you want to import, then click Preview import.';
if (isLoadingMetadata) {
emptyTitle = 'Loading worksheet';
emptyContent = 'Loading worksheet metadata...';
} else if (worksheetHeaders.length === 0) {
emptyTitle = 'No headers found';
emptyContent =
'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 (
<div className={style.emptyState}>
<div className={style.emptyTitle}>Preview not generated</div>
<div className={style.emptyBody}>{emptyContent}</div>
<div className={style.emptyMessage}>
<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>
);
}
let eventIndex = 0;
return (
<table className={style.table}>
<thead>
<tr>
<th className={style.rowNumber}>#</th>
<th className={style.rowType}>Type</th>
{columnLabels.map((label, index) => (
{displayColumns.map((label, index) => (
<th key={`${label}-${index}`}>{label}</th>
))}
</tr>
@@ -59,16 +158,29 @@ export default function PreviewTable({
{preview.rundown.flatOrder.map((entryId) => {
const entry = preview.rundown.entries[entryId];
const isEvent = isOntimeEvent(entry);
if (isEvent) eventIndex++;
const hasType = isEvent || isOntimeGroup(entry) || isOntimeMilestone(entry);
const entryMetadata = previewMetadata?.[entryId];
const { rowClassName, entryColour, entryType } = getEntryDisplay(entry, entryMetadata?.groupColour);
return (
<tr key={entryId}>
<td className={style.rowNumber}>{isEvent ? eventIndex : ''}</td>
<td className={style.rowType}>{hasType ? entry.type : ''}</td>
{columnLabels.map((label, colIndex) => (
<td key={`${entryId}-${colIndex}`}>{getCellValue(label, entry, customFieldKeyByLabel)}</td>
))}
<tr key={entryId} className={rowClassName} style={{ '--entry-colour': entryColour } as CSSProperties}>
<td className={style.rowNumber}>{isEvent ? entryMetadata?.eventIndex : ''}</td>
<td className={style.rowType}>
<Tag>{entryType}</Tag>
</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>
);
})}
@@ -1,7 +1,13 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import type {
ImportedFields,
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 { maybeAxiosError } from '../../../../../../common/api/utils';
@@ -11,8 +17,11 @@ import {
builtInFieldDefs,
convertToImportMap,
getImportWarnings,
getPersistedImportMode,
getPersistedImportState,
getProvidedImportFields,
getResolvedCustomFields,
persistImportMode,
persistImportState,
} from './importMapUtils';
import { deriveHeaderOptionsState } from './spreadsheetImportUtils';
@@ -24,7 +33,7 @@ type ImportAction =
| { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse }
| { type: 'applySuccess' }
| { type: 'exportSuccess' }
| { type: 'clearPreview'; error?: string }
| { type: 'clearPreview'; error?: string; needsRefresh?: boolean }
| { type: 'failure'; error: string }
| { type: 'reset' };
@@ -32,12 +41,14 @@ type ImportState = {
loading: '' | 'preview' | 'apply' | 'export';
error: string;
preview: SpreadsheetPreviewResponse | null;
needsPreviewRefresh: boolean;
};
const initialImportState: ImportState = {
loading: '',
error: '',
preview: null,
needsPreviewRefresh: false,
};
function importReducer(state: ImportState, action: ImportAction): ImportState {
@@ -49,15 +60,15 @@ function importReducer(state: ImportState, action: ImportAction): ImportState {
case 'startExport':
return { ...state, loading: 'export', error: '' };
case 'previewSuccess':
return { loading: '', error: '', preview: action.preview };
return { loading: '', error: '', preview: action.preview, needsPreviewRefresh: false };
case 'applySuccess':
case 'exportSuccess':
return { ...state, loading: '' };
case 'clearPreview':
return { ...state, error: action.error ?? '', preview: null };
return { ...state, error: action.error ?? '', preview: null, needsPreviewRefresh: action.needsRefresh ?? false };
case 'failure': {
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 };
}
@@ -100,16 +111,23 @@ function buildColumnLabels(values: ImportFormValues): string[] {
interface UseSheetImportFormProps {
sourceKey: string;
defaultRundownName: string;
worksheetNames: string[];
initialMetadata: SpreadsheetWorksheetMetadata | null;
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
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>;
}
export function useSheetImportForm({
sourceKey,
defaultRundownName,
worksheetNames,
initialMetadata,
loadMetadata,
@@ -170,6 +188,8 @@ export function useSheetImportForm({
const columnLabels = buildColumnLabels(values);
const [state, dispatch] = useReducer(importReducer, initialImportState);
const [importMode, setImportMode] = useState<RundownImportMode>(() => getPersistedImportMode(sourceKey));
const [newRundownTitle, setNewRundownTitle] = useState(defaultRundownName);
const warnings = getImportWarnings(values, headers);
const warningCount = Object.values(warnings).filter(Boolean).length;
const previewRef = useRef<SpreadsheetPreviewResponse | null>(null);
@@ -182,6 +202,12 @@ export function useSheetImportForm({
dispatch({ type: '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.
useEffect(() => {
if (worksheetNames.length === 0) return;
@@ -198,7 +224,7 @@ export function useSheetImportForm({
const sub = watch(() => {
if (!previewRef.current) return;
previewRef.current = null;
dispatch({ type: 'clearPreview' });
dispatch({ type: 'clearPreview', needsRefresh: true });
});
return () => sub.unsubscribe();
}, [watch]);
@@ -241,13 +267,15 @@ export function useSheetImportForm({
try {
dispatch({ type: 'startApply' });
await onApply(state.preview);
const providedFields = getProvidedImportFields(convertToImportMap(getValues()));
await onApply(state.preview, importMode, newRundownTitle, providedFields);
persistImportState(sourceKey, getValues());
persistImportMode(sourceKey, importMode);
dispatch({ type: 'applySuccess' });
} catch (error) {
dispatch({ type: 'failure', error: maybeAxiosError(error) });
}
}, [getValues, onApply, sourceKey, state.preview]);
}, [getValues, importMode, newRundownTitle, onApply, sourceKey, state.preview]);
const handleExport = useCallback(
async (formValues: ImportFormValues) => {
@@ -272,15 +300,37 @@ export function useSheetImportForm({
}, [append]);
const toolbarStatus = (() => {
const warningText = warningCount > 0 ? ` | warnings: ${warningCount}` : '';
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;
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 {
@@ -300,6 +350,10 @@ export function useSheetImportForm({
isBusy,
canPreview,
displayError,
importMode,
setImportMode,
newRundownTitle,
setNewRundownTitle,
handlePreviewSubmit: handleSubmit(handlePreview),
handleExportSubmit: handleSubmit(handleExport),
handleApply,
@@ -1,30 +1,17 @@
import { useQueryClient } from '@tanstack/react-query';
import { CustomFields, ProjectRundowns } from 'ontime-types';
import { RundownImportPayload } from 'ontime-types';
import { useCallback } from 'react';
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../../common/api/constants';
import { patchData } from '../../../../../common/api/db';
import { importRundownWithOptions } from '../../../../../common/api/rundown';
export default function useSpreadsheetImport() {
const queryClient = useQueryClient();
/** applies rundown and customFields to current project */
const importRundown = useCallback(
async (rundowns: ProjectRundowns, customFields: CustomFields) => {
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],
);
/** applies a spreadsheet import: override or merge into the current rundown, or create a new one */
const applyImport = useCallback(async (payload: RundownImportPayload) => {
// the backend broadcasts a refetch once the rundown is parsed and applied, so the caches update
// through that single path rather than racing it with an optimistic write from here
await importRundownWithOptions(payload);
}, []);
return {
importRundown,
applyImport,
};
}
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { Suspense, useState } from 'react';
import { IoArrowDown, IoArrowUp } from 'react-icons/io5';
import Info from '../../../../common/components/info/Info';
@@ -11,11 +11,25 @@ import style from './ProjectPanel.module.scss';
type SortParameter = 'alphabetical' | 'modified';
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 [editingFilename, setEditingFilename] = useState<string | null>(null);
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) => {
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;
return (
@@ -73,6 +73,10 @@
margin-right: 0.25rem;
}
.tagOvertime {
color: $playback-over;
}
.time {
color: $section-white;
font-size: $text-body-size;
@@ -1,12 +1,13 @@
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
import { dayInMs, millisToString } from 'ontime-utils';
import { millisToString } from 'ontime-utils';
import { PropsWithChildren } from 'react';
import AppLink from '../../../../common/components/link/app-link/AppLink';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import useReport from '../../../../common/hooks-query/useReport';
import { useTimer } from '../../../../common/hooks/useSocket';
import { formatDuration } from '../../../../common/utils/time';
import { useTimerProgress } from '../../../../common/hooks/useSocket';
import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration, normaliseWallClock } from '../../../../common/utils/time';
import TimerDisplay from '../timer-display/TimerDisplay';
import style from './PlaybackTimer.module.scss';
@@ -24,15 +25,14 @@ function resolveAddedTimeLabel(addedTime: number) {
}
export default function PlaybackTimer({ children }: PropsWithChildren) {
const timer = useTimer();
'use memo';
const timer = useTimerProgress();
const isRolling = timer.playback === Playback.Roll;
const isWaiting = timer.phase === TimerPhase.Pending;
const isOvertime = timer.phase === TimerPhase.Overtime;
const hasAddedTime = Boolean(timer.addedTime);
const rollLabel = isRolling ? 'Roll mode active' : '';
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
return (
@@ -51,7 +51,13 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
{isWaiting ? (
<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>
{children}
@@ -62,16 +68,18 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
interface RunningStatusProps {
startedAt: 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 />;
}
const started = millisToString(startedAt);
const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null;
const finish = millisToString(finishedMs);
const finish = millisToString(expectedFinish === null ? null : normaliseWallClock(expectedFinish));
return (
<>
@@ -80,7 +88,9 @@ function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusPro
<span className={style.time}>{started}</span>
</span>
<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>
</>
@@ -14,7 +14,7 @@ export interface 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: settings, status: settingsStatus } = useSettings();
@@ -1,9 +1,10 @@
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 { useIsOnline } from '../../common/hooks/useSocket';
import { cx } from '../../common/utils/styleUtils';
import Loader from '../../views/common/loader/Loader';
import style from './Overview.module.scss';
@@ -15,18 +16,37 @@ export function OverviewWrapper({ navElements, children }: PropsWithChildren<Ove
const isOnline = useIsOnline();
return (
<div className={cx([style.overview, !isOnline && style.isOffline])}>
<ErrorBoundary>
<div className={style.nav}>{navElements}</div>
<ScrollArea
className={style.infoScroll}
contentClassName={style.info}
contentStyle={{ minWidth: '100%' }}
orientation='horizontal'
>
{children}
</ScrollArea>
</ErrorBoundary>
<Suspense fallback={<OverviewFallback navElements={navElements} />}>
<div className={cx([style.overview, !isOnline && style.isOffline])}>
<ErrorBoundary>
<div className={style.nav}>{navElements}</div>
<ScrollArea
className={style.infoScroll}
contentClassName={style.info}
contentStyle={{ minWidth: '100%' }}
orientation='horizontal'
>
{children}
</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>
);
}
@@ -210,7 +210,7 @@ export function MetadataTimes() {
function GroupTimes() {
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
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);
@@ -266,7 +266,7 @@ function GroupTimes() {
function FlagTimes() {
const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView();
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);
@@ -6,7 +6,7 @@ import style from './TitleOverview.module.scss';
export default function TitleOverview() {
'use memo';
const { data: projectData } = useProjectData();
const { data: rundownData } = useRundownAuxData();
const { data: rundownData } = useRundownAuxData(null);
if (!projectData.title && !rundownData.title) {
return null;
+20 -1
View File
@@ -105,12 +105,29 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
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
useRundownKeyboard({
cursor,
commands,
clearSelectedEvents,
setEntryCopyId,
jumpToCurrent,
});
// DND handlers
@@ -322,7 +339,9 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
increaseViewportBy={{ top: 200, bottom: 400 }}
style={{ height: '100%' }}
components={{
Header: isEditMode ? () => <QuickAddButtons previousEventId={null} parentGroup={null} /> : undefined,
Header: isEditMode
? () => <QuickAddButtons previousEventId={null} nextEventId={order[0]} parentGroup={null} />
: undefined,
Footer: () => (
<>
{isEditMode && (
@@ -6,8 +6,7 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useLoadedRundownSource } from '../../common/hooks-query/useScopedRundown';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext';
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils';
@@ -39,28 +38,29 @@ function RundownExport() {
defaultValue: RundownViewMode.List,
});
const isSmallDevice = useIsSmallDevice();
const entryActions = useEntryActions();
if (isSmallDevice && isExtracted) {
return (
<EntryActionsProvider actions={entryActions}>
<ProtectRoute permission='editor'>
<div
className={cx([style.rundownExport, style.extracted])}
data-target='small-device'
data-testid='panel-rundown'
>
<FinderPlacement />
<ViewNavigationMenu suppressSettings />
<div className={style.rundown}>
<ErrorBoundary>
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
<RundownSelectionContextProvider>
<EntryActionsProvider>
<ProtectRoute permission='editor'>
<div
className={cx([style.rundownExport, style.extracted])}
data-target='small-device'
data-testid='panel-rundown'
>
<FinderPlacement />
<ViewNavigationMenu suppressSettings />
<div className={style.rundown}>
<ErrorBoundary>
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</div>
</div>
</div>
</ProtectRoute>
</EntryActionsProvider>
</ProtectRoute>
</EntryActionsProvider>
</RundownSelectionContextProvider>
);
}
@@ -70,30 +70,32 @@ function RundownExport() {
viewMode === RundownViewMode.Table;
return (
<EntryActionsProvider actions={entryActions}>
<ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement />
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.rundown}>
<Editor.Panel className={style.list}>
<ErrorBoundary>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</Editor.Panel>
{!hideSideBar && (
<div className={style.side}>
<RundownSelectionContextProvider>
<EntryActionsProvider>
<ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement />
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.rundown}>
<Editor.Panel className={style.list}>
<ErrorBoundary>
<RundownEntryEditor />
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</div>
)}
</Editor.Panel>
{!hideSideBar && (
<div className={style.side}>
<ErrorBoundary>
<RundownEntryEditor />
</ErrorBoundary>
</div>
)}
</div>
</div>
</div>
</ProtectRoute>
</EntryActionsProvider>
</ProtectRoute>
</EntryActionsProvider>
</RundownSelectionContextProvider>
);
}
@@ -105,17 +107,16 @@ interface RundownRootProps {
}
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
const source = useLoadedRundownSource();
return (
<div className={style.rundownRoot}>
{isSmallDevice ? (
<RundownHeaderMobile viewMode={viewMode} setViewMode={setViewMode} />
) : (
// TODO: add data-background-rundown={!isLoadedRundown} styling
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
)}
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
{viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />}
{viewMode === RundownViewMode.Table && <EntryEditModal />}
<RenumberCuesDialog />
</div>
);
@@ -1,16 +1,23 @@
import { Playback } from 'ontime-types';
import { memo } from 'react';
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 Rundown from './Rundown';
const backgroundFeatureData = {
playback: Playback.Stop,
selectedEventId: null,
nextEventId: null,
};
export default memo(RundownList);
function RundownList() {
const { data, status, rundownMetadata } = useRundownWithMetadata();
const { rundown, status, rundownMetadata, isLoadedRundown } = useContextRundownList();
const featureData = useRundownEditor();
const isLoading = status !== 'success' || !data || !rundownMetadata;
const isLoading = status !== 'success' || !rundown || !rundownMetadata;
if (isLoading) {
return <Empty text='Connecting to server' />;
@@ -18,12 +25,12 @@ function RundownList() {
return (
<Rundown
order={data.order}
flatOrder={data.flatOrder}
entries={data.entries}
id={data.id}
order={rundown.order}
flatOrder={rundown.flatOrder}
entries={rundown.entries}
id={rundown.id}
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 />
<Combo keys={['PgDn']} />
</Shortcut>
<Shortcut label='Jump to current entry'>
<Combo keys={[deviceAlt, 'L']} />
</Shortcut>
<Shortcut label='Deselect entry'>
<Combo keys={['Esc']} />
</Shortcut>
@@ -1,7 +1,7 @@
import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { useMemo } from 'react';
import useRundown from '../../../common/hooks-query/useRundown';
import { useRundown } from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../useEventSelection';
import EventEditorFooter from './composite/EventEditorFooter';
import EventEditor from './EventEditor';
@@ -13,7 +13,7 @@ import style from './EntryEditor.module.scss';
export default function RundownEntryEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown();
const { data } = useRundown(null);
const entry = useMemo<OntimeEntry | null>(() => {
if (data.order.length === 0) {
@@ -10,7 +10,7 @@ import Switch from '../../../../common/components/switch/Switch';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
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 style from '../EntryEditor.module.scss';
@@ -68,8 +68,10 @@ function EventEditorTimes({
};
const hasDelay = delay !== 0;
const delayedStart = normaliseWallClock(timeStart + delay);
const delayedEnd = normaliseWallClock(timeEnd + delay);
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 (
@@ -86,7 +88,6 @@ function EventEditorTimes({
timeStrategy={timeStrategy}
linkStart={linkStart}
delay={delay}
countToEnd={countToEnd}
showLabels
/>
</div>
@@ -11,13 +11,17 @@ import style from './QuickAddButtons.module.scss';
interface QuickAddButtonsProps {
previousEventId: MaybeString;
nextEventId?: MaybeString;
parentGroup: MaybeString;
backgroundColor?: string;
}
export default memo(QuickAddButtons);
function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) {
function QuickAddButtons({ previousEventId, nextEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) {
const { addEntry } = useEntryActionsContext();
const appendOptions = previousEventId ? { after: previousEventId, lastEventId: previousEventId } : undefined;
const prependOptions = nextEventId ? { before: nextEventId } : undefined;
const insertionOptions = appendOptions ?? prependOptions;
const addEvent = () => {
addEntry(
@@ -25,44 +29,23 @@ function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: Quic
type: SupportedEntry.Event,
parent: parentGroup,
},
{
after: previousEventId,
lastEventId: previousEventId,
},
insertionOptions,
);
};
const addDelay = () => {
addEntry(
{ type: SupportedEntry.Delay, parent: parentGroup },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
addEntry({ type: SupportedEntry.Delay, parent: parentGroup }, insertionOptions);
};
const addMilestone = () => {
addEntry(
{ type: SupportedEntry.Milestone, parent: parentGroup },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
addEntry({ type: SupportedEntry.Milestone, parent: parentGroup }, insertionOptions);
};
const addGroup = () => {
if (parentGroup !== null) {
return;
}
addEntry(
{ type: SupportedEntry.Group },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
addEntry({ type: SupportedEntry.Group }, insertionOptions);
};
/**
@@ -96,7 +96,7 @@ export function useRundownCommands({
addEntry(patch, {
after: id && !above ? id : undefined,
before: id && above ? id : undefined,
lastEventId: !above && id ? id : undefined,
lastEventId: id && !above ? id : undefined,
});
},
[addEntry],
@@ -19,6 +19,7 @@ interface UseRundownKeyboardOptions {
};
clearSelectedEvents: () => void;
setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void;
jumpToCurrent: () => void;
}
/**
@@ -40,6 +41,7 @@ export function useRundownKeyboard({
commands,
clearSelectedEvents,
setEntryCopyId,
jumpToCurrent,
}: UseRundownKeyboardOptions) {
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
@@ -127,6 +129,8 @@ export function useRundownKeyboard({
{ preventDefault: true, usePhysicalKeys: true },
],
['alt + L', () => jumpToCurrent(), { preventDefault: true, usePhysicalKeys: true }],
[
'alt + mod + ArrowDown',
() => commands.moveEntry(cursor, 'down'),
@@ -7,7 +7,7 @@ import Button from '../../../common/components/buttons/Button';
import Dialog from '../../../common/components/dialog/Dialog';
import Input from '../../../common/components/input/input/Input';
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 { useEventSelection } from '../useEventSelection';
@@ -17,8 +17,7 @@ type RenumberCueData = Pick<RenumberCues, 'increment' | 'prefix' | 'start'>;
export default function RenumberCuesDialog() {
'use memo';
const { data } = useRundown();
const { flatOrder } = data;
const { flatOrder } = useContextRundownCueRenumberModal();
const { onClose, isOpen } = useRenumberCuesDialogStore();
const { renumberCues } = useEntryActionsContext();
const selectedEvents = useEventSelection((state) => state.selectedEvents);
@@ -7,6 +7,7 @@ $skip-opacity: 0.2;
background-color: $block-bg;
margin-block: 0.25rem;
overflow: initial;
position: relative;
display: grid;
grid-template-areas:
@@ -16,7 +17,7 @@ $skip-opacity: 0.2;
'binder pb-actions estatus estatus'
'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;
align-items: center;
padding-right: $block-clearance;
@@ -136,6 +137,7 @@ $skip-opacity: 0.2;
align-items: center;
gap: $block-clearance;
height: 100%;
min-width: 0;
}
.eventTimers.editMode:hover {
@@ -155,7 +157,7 @@ $skip-opacity: 0.2;
align-items: center;
justify-content: space-between;
.nextTag {
.warningMeta {
font-size: 1rem;
color: $orange-500;
letter-spacing: 0.03px;
@@ -231,6 +233,10 @@ $skip-opacity: 0.2;
color: var(--status-color-active-override, $active-indicator);
}
.statusIcon.countToEndStatus {
color: $orange-400;
}
.statusIcon.warning {
color: $orange-500;
}
@@ -115,12 +115,12 @@ function RundownEventInner({
delay={delay}
timeStrategy={timeStrategy}
linkStart={linkStart}
countToEnd={countToEnd}
/>
</div>
<div className={style.titleSection}>
<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>
<EventBlockPlayback
eventId={eventId}
@@ -157,7 +157,13 @@ function RundownEventInner({
<EndActionIcon action={endAction} className={style.statusIcon} />
</Tooltip>
<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 text={automationTooltip} render={<span />}>
<IoFlash className={automationIconClasses} />
@@ -1,10 +1,33 @@
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
import { formatDelay } from '../rundownEvent.utils';
describe('formatDelay()', () => {
it('adds a given delay to the start time', () => {
const timeStart = 60000; // 1 min
const delay = 60000; // 1 min
const timeStart = 1 * MILLIS_PER_MINUTE; // 00:01
const delay = 1 * MILLIS_PER_MINUTE; // 00:01
const result = formatDelay(timeStart, delay);
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 { formatDuration } from '../../../common/utils/time';
import { formatDuration, normaliseWallClock } from '../../../common/utils/time';
export function formatDelay(timeStart: number, delay: number): string | undefined {
if (!delay) return;
const delayedStart = Math.max(0, timeStart + delay);
const delayedStart = normaliseWallClock(timeStart + delay);
const timeTag = removeTrailingZero(millisToString(delayedStart));
return `New start ${timeTag}`;
}
export function formatGap(gap: number, isNextDay: boolean) {
if (gap === 0) {
if (isNextDay) {
@@ -8,6 +8,7 @@ import Tooltip from '../../../common/components/tooltip/Tooltip';
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
import { AppMode } from '../../../ontimeConfig';
import { EditorLayoutMode, useEditorLayout } from '../../../views/editor/useEditorLayout';
import { RundownSelect } from '../common/RundownSelect';
import { RundownViewMode } from '../rundown.options';
import { useEditorFollowMode } from '../useEditorFollowMode';
import RundownMenu from './RundownMenu';
@@ -24,6 +25,7 @@ interface HeaderControlsConfig {
showRunEditToggle: boolean;
showOffsetToggle: boolean;
showOverflowMenu: boolean;
showRundownSelect: boolean;
}
export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConfig> = {
@@ -31,16 +33,19 @@ export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConf
showRunEditToggle: true,
showOffsetToggle: true,
showOverflowMenu: true,
showRundownSelect: false,
},
[EditorLayoutMode.PLANNING]: {
showRunEditToggle: false,
showOffsetToggle: false,
showOverflowMenu: true,
showRundownSelect: true,
},
[EditorLayoutMode.TRACKING]: {
showRunEditToggle: false,
showOffsetToggle: true,
showOverflowMenu: false,
showRundownSelect: false,
},
} as const;
@@ -50,7 +55,8 @@ function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProp
const offsetMode = useOffsetMode();
const { layoutMode } = useEditorLayout();
const { showRunEditToggle, showOffsetToggle, showOverflowMenu } = HEADER_CONTROLS_CONFIG[layoutMode];
const { showRunEditToggle, showOffsetToggle, showOverflowMenu, showRundownSelect } =
HEADER_CONTROLS_CONFIG[layoutMode];
const toggleAppMode = (mode: AppMode[]) => {
// we need to stop user from deselecting a mode
@@ -122,6 +128,8 @@ function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProp
</ToggleGroup>
)}
{showRundownSelect && <RundownSelect appMode={editorMode} />}
{showOverflowMenu && <RundownMenu allowNavigation={!isExtracted} />}
</Toolbar.Root>
);
@@ -1,10 +1,7 @@
import { memo, useEffect, useMemo } from 'react';
import EmptyPage from '../../../common/components/state/EmptyPage';
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
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 CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
@@ -16,8 +13,6 @@ function RundownTable() {
const { data: customFields, status: customFieldStatus } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource();
const actions = useEntryActions();
// Editor always has full permissions
useEffect(() => {
@@ -35,14 +30,12 @@ function RundownTable() {
const isLoading = !customFields || customFieldStatus === 'pending';
return (
<EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'>
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
)}
</CuesheetDnd>
</EntryActionsProvider>
<CuesheetDnd columns={columns} tableRoot='editor'>
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' />
)}
</CuesheetDnd>
);
}
@@ -4,10 +4,15 @@
.timerNote {
width: 1.25em;
flex: 0 0 1.25em;
color: $blue-500;
font-size: 1.5em;
}
.timerNotePlaceholder {
visibility: hidden;
}
.inactive {
color: $muted-gray;
}
@@ -14,7 +14,6 @@ import style from './TimeInputFlow.module.scss';
interface TimeInputFlowProps {
eventId: string;
countToEnd: boolean;
timeStart: number;
timeEnd: number;
duration: number;
@@ -22,12 +21,12 @@ interface TimeInputFlowProps {
linkStart: boolean;
delay: number;
showLabels?: boolean;
showWarnings?: boolean;
}
export default memo(TimeInputFlow);
function TimeInputFlow({
eventId,
countToEnd,
timeStart,
timeEnd,
duration,
@@ -35,6 +34,7 @@ function TimeInputFlow({
linkStart,
delay,
showLabels,
showWarnings = true,
}: TimeInputFlowProps) {
const { updateEntry, updateTimer } = useEntryActionsContext();
@@ -56,10 +56,6 @@ function TimeInputFlow({
warnings.push('Over midnight');
}
if (countToEnd) {
warnings.push('Count to End');
}
const hasDelay = delay !== 0;
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
@@ -136,11 +132,19 @@ function TimeInputFlow({
</TimeInputGroup>
</div>
{warnings.length > 0 && (
<Tooltip text={warnings.join(' - ')} className={style.timerNote} data-testid='event-warning' render={<span />}>
<IoAlertCircleOutline />
</Tooltip>
)}
{showWarnings &&
(warnings.length > 0 ? (
<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);
// HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundown();
const { data: rundownData, status: rundownStatus } = useFlatRundown(null);
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -45,7 +45,7 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
[filter],
);
const { data: events } = usePartialRundown(filterCallback);
const { data: events } = usePartialRundown(null, filterCallback);
const [firstIndex, setFirstIndex] = useState(-1);
const [numPages, setNumPages] = useState(0);
@@ -153,7 +153,7 @@ type ScheduleTimeProps = {
//TODO: consider relative mode
export function ScheduleTime(props: ScheduleTimeProps) {
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;
@@ -164,7 +164,6 @@ export function ScheduleTime(props: ScheduleTimeProps) {
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
const plannedEnd = plannedStart + duration + delay;
const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
return (
@@ -1,17 +1,15 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { getExpectedStart } from 'ontime-utils';
import { getExpectedEnd, getExpectedStart } from 'ontime-utils';
import { IoPencil } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button';
import useReport from '../../common/hooks-query/useReport';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import { useExpectedStartData } from '../../common/hooks/useSocket';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils';
import { useCountdownOptions } from './countdown.options';
import { CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
import { CountdownEvent, CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
import { ScheduleTime } from './CountdownSubscriptions';
import './SingleEventCountdown.scss';
@@ -38,8 +36,10 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
mode,
});
const expectedEnd = getExpectedEnd(subscribedEvent, expectedStart, currentDay);
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 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
@@ -64,7 +64,7 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
}
interface SubscriptionStatusProps {
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
event: CountdownEvent;
}
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
@@ -12,7 +12,7 @@ import {
isOntimeGroup,
isPlayableEvent,
} 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 { 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
};
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.
@@ -271,6 +271,7 @@ export function extendEventData(
offset,
mode,
});
const expectedEnd = getExpectedEnd(event, expectedStart, currentDay);
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);
// HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata(null);
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -14,7 +14,3 @@
'table';
color: $ui-white;
}
.rundownSelect {
min-width: min(20rem, calc(100vw - 6rem));
}
+23 -28
View File
@@ -4,51 +4,46 @@ import { IoApps } from 'react-icons/io5';
import IconButton from '../../common/components/buttons/IconButton';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useScopedRundown } from '../../common/hooks-query/useScopedRundown';
import { useScopedEntryActions } from '../../common/hooks/useEntryAction';
import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { getIsNavigationLocked } from '../../externals';
import CuesheetOverview from '../../features/overview/CuesheetOverview';
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import CuesheetTableWrapper from './CuesheetTableWrapper';
import { FOLLOW_LOADED_RUNDOWN_ID, useCuesheetRundownSelection } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss';
export default function CuesheetPage() {
'use memo';
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');
const isLocked = getIsNavigationLocked();
return (
<EntryActionsProvider actions={actions}>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
<EntryEditModal rundown={source.rundown} />
<div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetOverview>
{!isLocked && (
<IconButton aria-label='Toggle navigation' variant='subtle-white' size='xlarge' onClick={menuHandler.open}>
<IoApps />
</IconButton>
)}
</CuesheetOverview>
<CuesheetProgress />
<CuesheetTableWrapper
source={source}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</div>
</EntryActionsProvider>
<RundownSelectionContextProvider>
<EntryActionsProvider>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
<EntryEditModal />
<div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetOverview>
{!isLocked && (
<IconButton
aria-label='Toggle navigation'
variant='subtle-white'
size='xlarge'
onClick={menuHandler.open}
>
<IoApps />
</IconButton>
)}
</CuesheetOverview>
<CuesheetProgress />
<CuesheetTableWrapper />
</div>
</EntryActionsProvider>
</RundownSelectionContextProvider>
);
}
@@ -1,39 +1,20 @@
import { MaybeString, ProjectRundown } from 'ontime-types';
import { memo, use, useMemo } from 'react';
import Select from '../../common/components/select/Select';
import EmptyPage from '../../common/components/state/EmptyPage';
import { PresetContext } from '../../common/context/PresetContext';
import { useRundownSelectionContext } from '../../common/context/RundownSelectionContext';
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 { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import CuesheetTable from './cuesheet-table/CuesheetTable';
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);
function CuesheetTableWrapper({
source,
selectedRundownId,
setSelectedRundownId,
loadedRundownId,
projectRundowns,
}: CuesheetTableWrapperProps) {
function CuesheetTableWrapper() {
const preset = use(PresetContext);
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { isLoadedRundown } = useRundownSelectionContext();
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isLoadedRundown });
const { data: customFields, status: customFieldStatus } = useCustomFields();
const columns = useMemo(
@@ -50,66 +31,11 @@ function CuesheetTableWrapper({
) : (
<CuesheetTable
columns={columns}
source={source}
cuesheetMode={cuesheetMode}
tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode}
isCurrentRundown={isCurrentRundown}
insertElement={
<>
<RundownSelect
cuesheetMode={cuesheetMode}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</>
}
/>
)}
</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 Modal from '../../../common/components/modal/Modal';
import { useContextRundownEditModal } from '../../../common/hooks-query/useContextRundown';
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
import { useEditModal } from './useEditModal';
interface EntryEditModalProps {
rundown: Rundown;
}
export default memo(EntryEditModal);
function EntryEditModal({ rundown }: EntryEditModalProps) {
function EntryEditModal() {
const { rundown } = useContextRundownEditModal();
const entryId = useEditModal((state) => state.selectedEntryId);
const closeModal = useEditModal((state) => state.clearSelection);
@@ -1,7 +1,7 @@
import { useTableNav } from '@table-nav/react';
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
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 {
ContextProp,
ItemProps,
@@ -14,7 +14,7 @@ import {
import EmptyPage from '../../../common/components/state/EmptyPage';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
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 { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
@@ -35,34 +35,22 @@ import style from './CuesheetTable.module.scss';
type CuesheetTableBaseProps = {
columns: ColumnDef<ExtendedEntry>[];
cuesheetMode: AppMode;
source: RundownSource;
insertElement?: ReactNode;
};
type EditorCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'editor';
setCuesheetMode?: undefined;
isCurrentRundown?: undefined;
};
type ViewCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'cuesheet';
setCuesheetMode: (mode: AppMode) => void;
isCurrentRundown?: boolean;
};
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
export default function CuesheetTable({
columns,
cuesheetMode,
source,
tableRoot,
setCuesheetMode,
isCurrentRundown,
insertElement,
}: CuesheetTableProps) {
const { flatRundown, status, selectedEventId } = source;
export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCuesheetMode }: CuesheetTableProps) {
const { flatRundown, status, loadedEventId } = useContextRundownTable();
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
@@ -143,17 +131,17 @@ export default function CuesheetTable({
// in Run mode, follow the current event
useEffect(() => {
if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !selectedEventId) {
if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !loadedEventId) {
return;
}
const eventIndex = flatRundown.findIndex((event) => event.id === selectedEventId);
const eventIndex = flatRundown.findIndex((event) => event.id === loadedEventId);
if (eventIndex === -1) {
return;
}
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)
useEffect(() => {
@@ -242,17 +230,9 @@ export default function CuesheetTable({
handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
insertElement={insertElement}
modeControls={
tableRoot === 'cuesheet'
? {
cuesheetMode,
setCuesheetMode,
isCurrentRundown,
}
: undefined
}
showShare={tableRoot === 'cuesheet'}
appMode={cuesheetMode}
tableRoot={tableRoot}
setCuesheetMode={setCuesheetMode}
/>
<TableVirtuoso
ref={virtuosoRef}
@@ -10,7 +10,9 @@ import Button from '../../../../common/components/buttons/Button';
import Checkbox from '../../../../common/components/checkbox/Checkbox';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import PopoverContents from '../../../../common/components/popover/Popover';
import { useRundownSelectionContext } from '../../../../common/context/RundownSelectionContext';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { RundownSelect } from '../../../../features/rundown/common/RundownSelect';
import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetPermissions } from '../../useTablePermissions';
import CuesheetShareModal from './CuesheetShareModal';
@@ -30,21 +32,15 @@ type TableHeaderOptionValues = Pick<
'hideTableSeconds' | 'hideIndexColumn' | 'showDelayedTimes' | 'hideDelays'
>;
type TableModeControls = {
cuesheetMode: AppMode;
setCuesheetMode: (mode: AppMode) => void;
isCurrentRundown?: boolean;
};
interface CuesheetTableHeaderToolbarProps {
columns: Column<ExtendedEntry, unknown>[];
optionsStore: TableHeaderOptionsStore;
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
insertElement?: ReactNode;
modeControls?: TableModeControls;
showShare?: boolean;
setCuesheetMode?: (mode: AppMode) => void;
appMode: AppMode;
tableRoot: 'editor' | 'cuesheet';
}
export default function CuesheetTableHeaderToolbar({
@@ -53,23 +49,23 @@ export default function CuesheetTableHeaderToolbar({
handleResetResizing,
handleResetReordering,
handleClearToggles,
insertElement,
modeControls,
showShare = false,
setCuesheetMode,
tableRoot,
appMode,
}: CuesheetTableHeaderToolbarProps) {
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode);
const canShare = useCuesheetPermissions((state) => state.canShare);
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode) && tableRoot === 'cuesheet';
const canShare = useCuesheetPermissions((state) => state.canShare) && tableRoot === 'cuesheet';
const showRundownSelect = tableRoot === 'cuesheet';
const { isLoadedRundown } = useRundownSelectionContext();
const toggleCuesheetMode = (mode: AppMode[]) => {
const newValue = mode.at(0);
if (!newValue || !modeControls) return;
modeControls.setCuesheetMode(newValue);
if (!newValue || !setCuesheetMode) return;
setCuesheetMode(newValue);
};
const isBackground = !(modeControls?.isCurrentRundown ?? true);
return (
<Toolbar.Root className={style.tableSettings} data-background-rundown={isBackground}>
<Toolbar.Root className={style.tableSettings} data-background-rundown={!isLoadedRundown}>
<ViewSettings optionsStore={optionsStore} />
<ColumnSettings
columns={columns}
@@ -77,14 +73,14 @@ export default function CuesheetTableHeaderToolbar({
handleResetReordering={handleResetReordering}
handleClearToggles={handleClearToggles}
/>
{modeControls && canChangeMode && (
<div className={style.apart}>
{insertElement}
<div className={style.apart}>
{showRundownSelect && <RundownSelect appMode={appMode} />}
{canChangeMode && (
<ToggleGroup
value={[modeControls.cuesheetMode]}
value={[appMode]}
onValueChange={toggleCuesheetMode}
className={style.group}
disabled={!modeControls.isCurrentRundown}
disabled={!isLoadedRundown}
>
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
Run
@@ -93,10 +89,10 @@ export default function CuesheetTableHeaderToolbar({
Edit
</Toolbar.Button>
</ToggleGroup>
</div>
)}
)}
</div>
{showShare && canShare && (
{canShare && (
<>
<Editor.Separator orientation='vertical' />
<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],
);
}
+43 -35
View File
@@ -1,5 +1,6 @@
import { lazy } from 'react';
import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext';
import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar';
import { AppMode } from '../../ontimeConfig';
import TitleList from './title-list/TitleList';
@@ -14,44 +15,51 @@ const MessageControl = lazy(() => import('../../features/control/message/Message
export default function Editor() {
const { layoutMode } = useEditorLayout();
if (layoutMode === EditorLayoutMode.CONTROL) {
return (
<div id='panels' className={styles.panelContainer}>
<div className={styles.left}>
<TimerControl />
<MessageControl />
</div>
<Rundown />
</div>
);
}
if (layoutMode === EditorLayoutMode.TRACKING) {
return (
<div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<TitleList mode={AppMode.Run} />
switch (layoutMode) {
case EditorLayoutMode.TRACKING: {
return (
<div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<RundownSelectionContextProvider>
<TitleList mode={AppMode.Run} />
</RundownSelectionContextProvider>
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
</div>
<div className={styles.rundownPanel}>
<Rundown />
<TrackingPlaybackBar />
</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>
<TrackingPlaybackBar />
</div>
);
}
return (
<div id='panels' className={styles.panelContainer}>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<TitleList mode={AppMode.Edit} />
</div>
<div className={styles.rundownPanel}>
);
}
case EditorLayoutMode.CONTROL:
default: {
return (
<div id='panels' className={styles.panelContainer}>
<div className={styles.left}>
<TimerControl />
<MessageControl />
</div>
<Rundown />
</div>
</div>
</div>
);
);
}
}
}
@@ -15,7 +15,7 @@ function EditorLayoutOptions() {
{
type: 'item',
label: 'Planning',
description: 'Edit-focused list with planning stats',
description: 'Edit focused list with planning stats',
icon: layoutMode === EditorLayoutMode.PLANNING ? IoCheckmark : undefined,
onClick: () => setLayoutMode(EditorLayoutMode.PLANNING),
},
@@ -1,7 +1,7 @@
import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
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';
const maxResults = 12;
@@ -38,7 +38,7 @@ type FilterableMilestone = {
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
export default function useFinder() {
const { data, rundownId } = useFlatRundown();
const { rundown: data, rundownId } = useContextRundownFinder();
const [results, setResults] = useState<FilterableEntry[]>([]);
const [error, setError] = useState<MaybeString>(null);
const lastSearchString = useRef('');
@@ -28,9 +28,9 @@
.timer-container {
flex: 1;
align-content: center;
justify-self: center;
align-self: center;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
overflow: hidden;
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
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 { ExtendedEntry, getFlatRundownMetadata } from '../../../common/utils/rundownMetadata';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
@@ -20,7 +20,7 @@ interface TitleListProps {
}
export default function TitleList({ mode }: TitleListProps) {
const { data: rundown } = useRundown();
const { rundown } = useContextRundownList();
const selectedEventId = useSelectedEventId();
const cursor = useEventSelection((state) => state.cursor);
@@ -1,6 +1,9 @@
import { isValueOfEnum } from 'ontime-utils';
import { useCallback, useEffect } from 'react';
import { useSearchParams } from 'react-router';
import { setSelectRundownInParams } from '../../common/context/RundownSelectionContext';
const layoutParam = 'layout';
export enum EditorLayoutMode {
@@ -20,14 +23,29 @@ function getEditorLayout(value: string | null): EditorLayoutMode {
}
export function useEditorLayout() {
'use memo';
const [searchParams, setSearchParams] = useSearchParams();
const layoutMode = getEditorLayout(searchParams.get(layoutParam));
const setLayoutMode = (mode: EditorLayoutMode) => {
const nextParams = new URLSearchParams(searchParams);
nextParams.set(layoutParam, mode);
setSearchParams(nextParams, { replace: true });
};
useEffect(() => {
setSearchParams((searchParams) => {
if (layoutMode !== EditorLayoutMode.PLANNING) setSelectRundownInParams(null, searchParams);
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 };
}
@@ -16,7 +16,7 @@ export interface TimelineData {
export function useTimelineData(): ViewData<TimelineData> {
// HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata(null);
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
+3 -3
View File
@@ -82,9 +82,9 @@
.timer-container {
flex: 1;
align-content: center;
justify-self: center;
align-self: center;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
overflow: hidden;
+2 -2
View File
@@ -2,7 +2,7 @@ import { CustomFields, ProjectData, RundownEntries, Settings, ViewSettings } fro
import useCustomFields from '../../common/hooks-query/useCustomFields';
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 useViewSettings from '../../common/hooks-query/useViewSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
@@ -26,7 +26,7 @@ export function useTimerData(): ViewData<TimerData> {
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
const { data: rundown, status: rundownStatus } = useRundown();
const { data: rundown, status: rundownStatus } = useRundown(null);
const { entries } = rundown;
return {
+8 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.10.0",
"version": "4.11.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -125,6 +125,13 @@
"**/*",
"*{.ts}"
]
},
{
"from": "../server/src/html/",
"to": "extraResources/html/",
"filter": [
"**/*"
]
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/resolver",
"version": "4.10.0",
"version": "4.11.0",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.10.0",
"version": "4.11.0",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -163,5 +163,70 @@ describe('parseOutput', () => {
secondarySource: 'secondary',
});
});
it('parses message-secondary with a text value', () => {
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
text: 'hello',
}),
).toMatchObject({
text: 'hello',
});
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: undefined,
text: 'hello',
}),
).toMatchObject({
text: 'hello',
});
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'secondary',
text: 'hello',
}),
).toMatchObject({
secondarySource: 'secondary',
text: 'hello',
});
// an empty text is treated as no change
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'secondary',
text: '',
}),
).toMatchObject({
secondarySource: 'secondary',
text: undefined,
});
// text can be set while clearing the secondary source
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: null,
text: 'hello',
}),
).toMatchObject({
secondarySource: null,
text: 'hello',
});
expect(() =>
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'secondary',
text: 123,
}),
).toThrow('Unexpected payload type:');
});
});
});
@@ -0,0 +1,113 @@
import { runtimeStorePlaceholder } from 'ontime-types';
import * as messageService from '../../../services/message-service/message.service.js';
import { toOntimeAction } from '../clients/ontime.client.js';
vi.mock('../../../services/message-service/message.service.js', () => ({
patch: vi.fn(),
}));
describe('toOntimeAction()', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('parses templates in primary message text', () => {
toOntimeAction(
{
type: 'ontime',
action: 'message-set',
text: 'Current: {{timer.current}}',
visible: true,
},
{
...runtimeStorePlaceholder,
timer: {
...runtimeStorePlaceholder.timer,
current: 42,
},
},
);
expect(messageService.patch).toHaveBeenCalledWith({
timer: {
text: 'Current: 42',
visible: true,
},
});
});
it('parses templates in secondary message text', () => {
toOntimeAction(
{
type: 'ontime',
action: 'message-secondary',
secondarySource: 'secondary',
text: 'Next: {{eventNext.title}}',
},
{
...runtimeStorePlaceholder,
eventNext: {
id: 'next-event',
type: 'event',
cue: '2',
title: 'Keynote',
note: '',
timeStart: 0,
timeEnd: 0,
duration: 0,
timerType: 'count-down',
colour: '',
delay: 0,
isPublic: true,
skip: false,
endAction: 'none',
revision: 0,
custom: {},
},
},
);
expect(messageService.patch).toHaveBeenCalledWith({
timer: {
secondarySource: 'secondary',
},
secondary: 'Next: Keynote',
});
});
it('can set secondary message text without changing the secondary source', () => {
toOntimeAction(
{
type: 'ontime',
action: 'message-secondary',
text: 'Next: {{eventNext.title}}',
},
{
...runtimeStorePlaceholder,
eventNext: {
id: 'next-event',
type: 'event',
cue: '2',
title: 'Keynote',
note: '',
timeStart: 0,
timeEnd: 0,
duration: 0,
timerType: 'count-down',
colour: '',
delay: 0,
isPublic: true,
skip: false,
endAction: 'none',
revision: 0,
custom: {},
},
},
);
expect(messageService.patch).toHaveBeenCalledWith({
secondary: 'Next: Keynote',
});
});
});
@@ -132,7 +132,7 @@ function send(output: AutomationOutput[], store: RuntimeStore) {
} else if (isHTTPOutput(payload)) {
emitHTTP(payload, store);
} else if (isOntimeAction(payload)) {
toOntimeAction(payload);
toOntimeAction(payload, store);
} else {
logger.warning(LogOrigin.Tx, `Unknown output type: ${payload}`);
}
@@ -225,13 +225,28 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
}
if (maybeOntimeAction.action === 'message-secondary') {
assert.hasKeys(maybeOntimeAction, ['secondarySource']);
// the secondary text is optional, an empty string is treated as no change
let text: string | undefined = undefined;
if ('text' in maybeOntimeAction) {
assert.isString(maybeOntimeAction.text);
text = indeterminateText(maybeOntimeAction.text);
}
if (!('secondarySource' in maybeOntimeAction) || maybeOntimeAction.secondarySource === undefined) {
return {
type: 'ontime',
action: 'message-secondary',
text,
};
}
// null is used to clear the secondary source
if (maybeOntimeAction.secondarySource === null) {
return {
type: 'ontime',
action: 'message-secondary',
secondarySource: null,
text,
};
}
@@ -240,6 +255,7 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
type: 'ontime',
action: 'message-secondary',
secondarySource: chooseSecondarySource(maybeOntimeAction.secondarySource),
text,
};
}

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