Compare commits

..

6 Commits

Author SHA1 Message Date
Claude f2455fec17 refactor(views): keep query status truthful, push render logic to the view boundary
Reworks the previous fix, which had base hooks (useRundown, useSettings,
useCustomFields, useProjectData, useViewSettings) rewrite their own
status/isError to hide background-refetch failures. That was misleading:
those hooks are consumed well beyond the 9 view loaders, and other callers
may legitimately want to know a fetch genuinely failed.

Instead the base hooks now return react-query's status/isError untouched,
plus the isLoadingError flag react-query already computes (true only when
a query has never received data). aggregateQueryStatus (viewLoader.utils.ts)
takes {status, isLoadingError} pairs and only reports 'error' once every
query has settled and at least one never got data; a background/refetch
error with data present now aggregates to 'success'. The two single-query
consumers outside that aggregator (RundownList/CuesheetTable via
useScopedRundown, and ProjectInfo) branch on isLoadingError directly instead
of status === 'error'.

Removes the standalone deriveQueryStatus helper introduced in the prior
iteration, since the logic now lives in the one place that already existed
for this purpose (aggregateQueryStatus) rather than a new abstraction.
2026-08-02 05:28:49 +00:00
Claude f7b58f5ebd refactor(views): use react-query's isLoadingError instead of reimplementing it
deriveQueryStatus previously inferred "never received data" by checking
data !== undefined itself. TanStack Query already computes and exposes
this exact distinction as isLoadingError (isError && no data) vs
isRefetchError (isError && data present) on every useQuery result, so
consume that directly instead of duplicating the logic.
2026-08-01 18:59:39 +00:00
Claude 9e2dfaab16 fix(views): move stale-data-on-error fix to the shared query hooks
The previous Operator-only fix (checking rundown.revision) papered over a
bug shared by every view loader: react-query sets status to 'error' on any
failed fetch, including a background refetch, even when a prior successful
fetch's data is still cached. All view loaders treated that as "no data"
and blanked the whole view.

Add deriveQueryStatus(), a small helper that only reports 'error' when a
query has never received data, and apply it in the five base data hooks
(useRundown, useRundownById, useCustomFields, useSettings, useProjectData,
useViewSettings). This fixes the class of bug for every consumer (Operator,
Timer, Backstage, Studio, Countdown, TimelinePage, ProjectInfo, RundownList,
CuesheetTable) at the source, so the Operator-specific revision check can
be reverted back to the plain status check.
2026-08-01 18:53:44 +00:00
Claude 65e1d3cfe9 fix(operator): only show error empty state when no rundown data is available
The operator view previously replaced its content with the full-page
error state on any query error, even when a previously fetched rundown
was still cached. Now it falls back to rendering the cached data and
only shows the error placeholder if a rundown has never been received.
2026-08-01 18:47:01 +00:00
Carlos Valente 94d54529ee refactor(views): consistent loading and empty states 2026-08-01 16:25:14 +02:00
Carlos Valente f7535651f6 refactor(info): improve UI consistency and polish 2026-08-01 14:41:39 +02:00
71 changed files with 1049 additions and 935 deletions
@@ -1,20 +1,43 @@
@use '@/theme/viewerDefs' as *;
.emptyContainer {
width: 100%;
text-align: center;
color: $white-10;
color: var(--secondary-color-override, $viewer-secondary-color);
.empty {
display: block;
width: min(100%, 24rem);
margin-inline: auto;
opacity: 0.8;
width: min(100%, 14rem);
margin: 0 auto -1.5rem;
opacity: 0.6;
}
.text {
display: block;
margin-inline: auto;
font-weight: 600;
font-size: 2em;
max-width: min(100%, 600px);
font-weight: 400;
font-size: clamp(1rem, 1.55vw, 1.5rem);
line-height: 1.35;
max-width: min(100%, 40rem);
}
&.error {
color: $error-red;
.empty {
opacity: 0.35;
filter: grayscale(1);
}
.text {
margin-top: 0.5rem;
}
}
.errorIcon {
display: block;
width: 1.5rem;
height: 1.5rem;
margin: -0.125rem auto 0;
}
}
@@ -1,4 +1,5 @@
import { CSSProperties } from 'react';
import { IoWarningOutline } from 'react-icons/io5';
import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils';
@@ -9,12 +10,18 @@ interface EmptyProps {
text?: string;
injectedStyles?: CSSProperties;
className?: string;
variant?: 'error';
}
export default function Empty({ text, className, injectedStyles }: EmptyProps) {
export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) {
return (
<div className={cx([style.emptyContainer, className])} style={injectedStyles}>
<div
className={cx([style.emptyContainer, variant === 'error' && style.error, className])}
style={injectedStyles}
role={variant === 'error' ? 'alert' : undefined}
>
<EmptyImage className={style.empty} />
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
{text && <span className={style.text}>{text}</span>}
</div>
);
@@ -0,0 +1,11 @@
.fill {
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
flex: 1;
display: grid;
place-items: start center;
padding: clamp(4rem, 20dvh, 12rem) 1.5rem 1rem;
}
@@ -0,0 +1,19 @@
import { cx } from '../../utils/styleUtils';
import Empty from './Empty';
import style from './EmptyFill.module.scss';
interface EmptyFillProps {
text?: string;
/** placed on the fill wrapper — e.g. to assign a grid-area in a grid parent */
className?: string;
}
/** Container-filling empty/loading state for panels and grid/flex cells. */
export default function EmptyFill({ text, className }: EmptyFillProps) {
return (
<div className={cx([style.fill, className])}>
<Empty text={text} />
</div>
);
}
@@ -6,7 +6,7 @@
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
height: 100dvh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
@@ -16,5 +16,6 @@
display: flex;
flex-direction: column;
align-items: center;
padding-top: 5rem;
justify-content: center;
padding-block: 5rem;
}
@@ -7,12 +7,13 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps {
text?: string;
injectedStyles?: CSSProperties;
variant?: 'error';
}
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) {
return (
<div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} />
<Empty text={text} injectedStyles={injectedStyles} variant={variant} />
</div>
);
}
@@ -15,9 +15,4 @@
gap: 1rem;
margin-top: 1em;
}
.text {
font-weight: 600;
font-size: 2em;
}
}
@@ -18,8 +18,7 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
<tbody className={style.emptyContainer}>
<tr>
<td colSpan={99} className={style.emptyCell}>
<Empty injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
<Empty text={text} injectedStyles={{ marginTop: '5vh' }} />
{handleAddNew && (
<div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
@@ -1,15 +1,15 @@
import { PropsWithChildren, createContext, useContext } from 'react';
import { useEntryActions, useScopedEntryActions } from '../hooks/useEntryAction';
import { useRundownSelectionContext } from './RundownSelectionContext';
import { useEntryActions } from '../hooks/useEntryAction';
type EntryActionsContextValue = ReturnType<typeof useEntryActions>;
const EntryActionsContext = createContext<EntryActionsContextValue | null>(null);
export function EntryActionsProvider({ children }: PropsWithChildren) {
const { effectiveRundownId } = useRundownSelectionContext();
const actions = useScopedEntryActions(effectiveRundownId);
interface EntryActionsProviderProps extends PropsWithChildren {
actions: EntryActionsContextValue;
}
export function EntryActionsProvider({ children, actions }: EntryActionsProviderProps) {
return <EntryActionsContext.Provider value={actions}>{children}</EntryActionsContext.Provider>;
}
@@ -1,120 +0,0 @@
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],
);
}
@@ -1,101 +0,0 @@
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],
);
}
@@ -8,12 +8,12 @@ import { getCustomFields } from '../api/customFields';
const placeholder: CustomFields = {};
export default function useCustomFields() {
const { data, status, isFetching, isError, refetch } = useQuery({
const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS,
queryFn: ({ signal }) => getCustomFields({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? placeholder, status, isFetching, isError, refetch };
return { data: data ?? placeholder, status, isFetching, isError, isLoadingError, refetch };
}
@@ -6,14 +6,14 @@ import { getProjectData, postProjectData } from '../api/project';
import { projectDataPlaceholder } from '../models/ProjectData';
export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({
const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({
queryKey: PROJECT_DATA,
queryFn: ({ signal }) => getProjectData({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch };
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, isLoadingError, refetch };
}
export function useUpdateProjectData() {
@@ -1,18 +1,24 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { ProjectFile, ProjectFileList } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { useQuery } from '@tanstack/react-query';
import { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types';
import { useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_LIST } from '../api/constants';
import { getProjects } from '../api/db';
export function useProjectList() {
const { data, status, refetch } = useSuspenseQuery({
const placeholderProjectList: ProjectFileListResponse = {
files: [],
lastLoadedProject: '',
};
function useProjectList() {
const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST,
queryFn: ({ signal }) => getProjects({ signal }),
staleTime: MILLIS_PER_HOUR,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data, status, refetch };
return { data: data ?? placeholderProjectList, status, refetch };
}
export type ProjectSortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'modified-asc' | 'modified-desc';
@@ -1,7 +1,7 @@
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } 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,21 +11,24 @@ 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 } = useSuspenseQuery<ProjectRundownsList>({
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
queryKey: PROJECT_RUNDOWNS,
queryFn: ({ signal }) => fetchProjectRundownList({ signal }),
staleTime: MILLIS_PER_HOUR,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data, status, isError, refetch, isFetching };
return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching };
}
export function useMutateProjectRundowns() {
const ontimeQueryClient = useQueryClient();
const { mutateAsync: create } = useMutation({
mutationFn: createRundown,
onMutate: () => {
@@ -1,10 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import { EntryId, Maybe, OntimeEntry, Rundown } from 'ontime-types';
import { useMemo } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { getRundownQueryKey } from '../api/constants';
import { fetchRundown } from '../api/rundown';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
@@ -20,45 +20,52 @@ const cachedRundownPlaceholder: Rundown = {
};
/**
* Provides access to a specific rundown by ID.
* When rundownId is not provided the loaded rundown is provided
* 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`.
*/
export function useRundown(rundownId: Maybe<string>) {
'use memo';
export default function useRundown() {
const queryClient = useQueryClient();
const {
data: { loaded: loadedRundownId },
} = useProjectRundowns();
const effectiveRundownId = rundownId !== null ? rundownId : loadedRundownId;
const isLoadedRundown = effectiveRundownId === loadedRundownId;
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(effectiveRundownId),
queryFn: ({ signal }) => fetchRundown(effectiveRundownId, { signal }),
placeholderData: (previousData, _previousQuery) => previousData,
const { data, status, isError, isLoadingError, refetch, isFetching } = useQuery<Rundown>({
queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching, isLoadedRundown };
// 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, isLoadingError, refetch, isFetching };
}
export function useRundownWithMetadata(rundownId: Maybe<string>) {
'use memo';
const { data, status, isLoadedRundown } = useRundown(rundownId);
export function useRundownWithMetadata() {
const { data, status, isLoadingError } = useRundown();
const selectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? selectedEventId : null;
const rundownMetadata = getRundownMetadata(data, effectiveSelectedEventId);
return { data, status, rundownMetadata };
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, isLoadingError, rundownMetadata };
}
/**
* Provides access to a flat rundown
* built from the order and rundown fields
*/
export function useFlatRundown(rundownId: Maybe<string>) {
const { data, status } = useRundown(rundownId);
export function useFlatRundown() {
const { data, status, isLoadingError } = useRundown();
const flatRundown = useMemo(() => {
if (data.revision === -1) {
@@ -67,17 +74,15 @@ export function useFlatRundown(rundownId: Maybe<string>) {
return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]);
return { data: flatRundown, rundownId: data.id, status };
return { data: flatRundown, rundownId: data.id, status, isLoadingError };
}
export function useFlatRundownWithMetadata(rundownId: Maybe<string>) {
'use memo';
const { data, status, isLoadedRundown } = useRundown(rundownId);
export function useFlatRundownWithMetadata() {
const { data, status, isLoadingError } = useRundown();
const selectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? selectedEventId : null;
const rundownWithMetadata = getFlatRundownMetadata(data, effectiveSelectedEventId);
return { data: rundownWithMetadata, status };
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status, isLoadingError };
}
/**
@@ -87,8 +92,8 @@ export function useFlatRundownWithMetadata(rundownId: Maybe<string>) {
* re-filtering on every render.
*
*/
export function usePartialRundown(rundownId: Maybe<string>, cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata(rundownId);
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata();
const filteredData = useMemo(() => {
return data.filter(cb);
}, [data, cb]);
@@ -98,20 +103,37 @@ export function usePartialRundown(rundownId: Maybe<string>, cb: (event: Extended
/**
* Hook to get a specific entry by ID from the rundown
* @deprecated
*/
export function useEntry(rundownId: Maybe<string>, entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useRundown(rundownId);
export function useEntry(entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useRundown();
if (entryId === null) return null;
return rundown.entries[entryId] ?? null;
}
export function useRundownAuxData(rundownId: Maybe<string>) {
const { data, status } = useRundown(rundownId);
export function useRundownAuxData() {
const { data, status } = useRundown();
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, isLoadingError, 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, isLoadingError, refetch, isFetching };
}
@@ -0,0 +1,56 @@
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;
isLoadingError: boolean;
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, isLoadingError } = useRundownById(rundownId);
const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
rundownId,
rundown,
flatRundown,
status,
isLoadingError,
selectedEventId: effectiveSelectedEventId,
}),
[effectiveSelectedEventId, flatRundown, isLoadingError, rundown, rundownId, status],
);
}
@@ -6,7 +6,7 @@ import { getSettings } from '../api/settings';
import { ontimePlaceholderSettings } from '../models/OntimeSettings';
export default function useSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({
queryKey: APP_SETTINGS,
queryFn: ({ signal }) => getSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
@@ -22,5 +22,5 @@ export default function useSettings() {
},
});
return { data: data ?? ontimePlaceholderSettings, status, isFetching, isError, refetch };
return { data: data ?? ontimePlaceholderSettings, status, isFetching, isError, isLoadingError, refetch };
}
@@ -7,7 +7,7 @@ import { VIEW_SETTINGS } from '../api/constants';
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() {
const { data, status } = useQuery({
const { data, status, isLoadingError } = useQuery({
queryKey: VIEW_SETTINGS,
queryFn: ({ signal }) => getViewSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
@@ -24,5 +24,5 @@ export default function useViewSettings() {
},
});
return { data: data ?? viewsSettingsPlaceholder, status, mutateAsync };
return { data: data ?? viewsSettingsPlaceholder, status, isLoadingError, mutateAsync };
}
@@ -90,7 +90,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 0,
isPast: true,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -108,7 +107,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 1, // UI indexes are 1 based
isPast: true,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -126,7 +124,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 1,
isPast: true,
isNextDay: false,
isParentToLoaded: true,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -143,7 +140,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 2,
isPast: true,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -160,7 +156,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 2,
isPast: true,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -177,7 +172,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 3,
isPast: false,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: true,
isLoaded: true,
@@ -194,7 +188,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 4,
isPast: false,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: true,
isLoaded: false,
@@ -211,7 +204,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 5,
isPast: false,
isNextDay: false,
isParentToLoaded: false,
totalGap: 7,
isLinkedToLoaded: false,
isLoaded: false,
@@ -264,7 +256,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 0,
isPast: false,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -282,7 +273,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 1,
isPast: false,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -300,7 +290,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 2,
isPast: false,
isNextDay: false,
isParentToLoaded: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
@@ -27,7 +27,6 @@ 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;
@@ -95,7 +94,6 @@ export function initRundownMetadata(selectedEventId: MaybeString) {
groupColour: undefined,
groupEntries: undefined,
isFirstAfterGroup: false,
isParentToLoaded: false,
};
function process(entry: OntimeEntry): Readonly<RundownMetadata> {
@@ -119,7 +117,6 @@ 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
@@ -135,7 +132,6 @@ 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) {
+8 -4
View File
@@ -14,6 +14,7 @@ import {
APP_SETTINGS,
CLIENT_LIST,
CSS_OVERRIDE,
CURRENT_RUNDOWN_QUERY_KEY,
CUSTOM_FIELDS,
PROJECT_DATA,
REPORT,
@@ -24,7 +25,6 @@ import {
VIEW_SETTINGS,
getRundownQueryKey,
PROJECT_RUNDOWNS,
PROJECT_LIST,
} from '../api/constants';
import { invalidateAllCaches } from '../api/utils';
import { ontimeQueryClient } from '../queryClient';
@@ -216,9 +216,6 @@ 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;
@@ -241,6 +238,7 @@ 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;
}
@@ -254,6 +252,12 @@ 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>(
@@ -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(null);
const { data } = useRundown();
const clearReport = async () => await deleteAllReport();
const downloadCSV = (combinedReport: CombinedReport[]) => {
@@ -1,5 +1,5 @@
import { useDisclosure } from '@mantine/hooks';
import { Suspense, useState } from 'react';
import { 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,20 +28,6 @@ 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();
@@ -24,7 +24,7 @@ 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 useRundown from '../../../../../common/hooks-query/useRundown';
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import GSheetSetup from './GSheetSetup';
@@ -59,7 +59,7 @@ export default function SourcesPanel() {
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
const { data: currentRundown } = useRundown(null);
const { data: currentRundown } = useRundown();
const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate();
@@ -1,4 +1,4 @@
import { Suspense, useState } from 'react';
import { useState } from 'react';
import { IoArrowDown, IoArrowUp } from 'react-icons/io5';
import Info from '../../../../common/components/info/Info';
@@ -11,25 +11,11 @@ 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 } = useOrderedProjectList(sortMode);
const { data, refetch, status } = useOrderedProjectList(sortMode);
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
@@ -52,6 +38,14 @@ function ProjectListSuspend() {
});
};
if (status === 'pending') {
return (
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
);
}
const numProjects = data.reorderedProjectFiles.length;
return (
+110 -102
View File
@@ -1,6 +1,7 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent';
@@ -10,6 +11,7 @@ import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle';
import { getDefaultFormat } from '../../common/utils/time';
import { isTouchDevice } from '../../externals';
import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../../views/common/loader/Loader';
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
import FollowButton from './follow-button/FollowButton';
@@ -35,7 +37,7 @@ export default function OperatorLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Operator {...data} />;
@@ -43,6 +45,7 @@ export default function OperatorLoader() {
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
const selectedEventId = useSelectedEventId();
const { getLocalizedString } = useTranslation();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
const [showEditPrompt, setShowEditPrompt] = useState(false);
@@ -113,6 +116,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
const canEdit = shouldEdit && subscribe.length;
const hasEvents = rundown.order.length > 0;
return (
<div className={style.operatorContainer} data-testid='operator-view'>
@@ -127,117 +131,121 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
</div>
)}
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{rundown.order.map((entryId) => {
const entry = rundown.entries[entryId];
if (isOntimeEvent(entry)) {
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || entry.skip) {
return null;
}
{!hasEvents ? (
<EmptyFill text={getLocalizedString('common.no_data')} />
) : (
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{rundown.order.map((entryId) => {
const entry = rundown.entries[entryId];
if (isOntimeEvent(entry)) {
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || entry.skip) {
return null;
}
const { mainField, secondaryField, subscribedData } = getEventData(
entry,
mainSource,
secondarySource,
subscribe,
customFields,
);
const { mainField, secondaryField, subscribedData } = getEventData(
entry,
mainSource,
secondarySource,
subscribe,
customFields,
);
return (
<OperatorEvent
key={entry.id}
id={entry.id}
colour={entry.colour}
cue={entry.cue}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
duration={entry.duration}
delay={entry.delay}
dayOffset={entry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
}
if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
if (hidePast && isPast && !isCurrentParent) {
return null;
}
return (
<Fragment key={entry.id}>
<OperatorGroup
return (
<OperatorEvent
key={entry.id}
title={entry.title}
id={entry.id}
colour={entry.colour}
count={entry.entries.length}
cue={entry.cue}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
duration={entry.duration}
delay={entry.delay}
dayOffset={entry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
{entry.entries.map((nestedEntryId) => {
const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) {
return null;
}
);
}
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || nestedEntry.skip) {
return null;
}
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
const { mainField, secondaryField, subscribedData } = getEventData(
nestedEntry,
mainSource,
secondarySource,
subscribe,
customFields,
);
if (hidePast && isPast && !isCurrentParent) {
return null;
}
return (
<OperatorEvent
key={nestedEntry.id}
id={nestedEntry.id}
colour={nestedEntry.colour}
cue={nestedEntry.cue}
main={mainField}
secondary={secondaryField}
timeStart={nestedEntry.timeStart}
duration={nestedEntry.duration}
delay={nestedEntry.delay}
dayOffset={nestedEntry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
groupColour={entry.colour}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
})}
</Fragment>
);
}
return null;
})}
</div>
return (
<Fragment key={entry.id}>
<OperatorGroup
key={entry.id}
title={entry.title}
colour={entry.colour}
count={entry.entries.length}
duration={entry.duration}
/>
{entry.entries.map((nestedEntryId) => {
const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) {
return null;
}
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || nestedEntry.skip) {
return null;
}
const { mainField, secondaryField, subscribedData } = getEventData(
nestedEntry,
mainSource,
secondarySource,
subscribe,
customFields,
);
return (
<OperatorEvent
key={nestedEntry.id}
id={nestedEntry.id}
colour={nestedEntry.colour}
cue={nestedEntry.cue}
main={mainField}
secondary={secondaryField}
timeStart={nestedEntry.timeStart}
duration={nestedEntry.duration}
delay={nestedEntry.delay}
dayOffset={nestedEntry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
groupColour={entry.colour}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
})}
</Fragment>
);
}
return null;
})}
</div>
)}
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
</div>
);
@@ -14,9 +14,18 @@ export interface OperatorData {
}
export function useOperatorData(): ViewData<OperatorData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata(null);
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: settings, status: settingsStatus } = useSettings();
const {
data: rundown,
rundownMetadata,
status: rundownStatus,
isLoadingError: rundownIsLoadingError,
} = useRundownWithMetadata();
const {
data: customFields,
status: customFieldStatus,
isLoadingError: customFieldIsLoadingError,
} = useCustomFields();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
return {
data: {
@@ -25,6 +34,10 @@ export function useOperatorData(): ViewData<OperatorData> {
customFields,
settings,
},
status: aggregateQueryStatus([rundownStatus, customFieldStatus, settingsStatus]),
status: aggregateQueryStatus([
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: customFieldStatus, isLoadingError: customFieldIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
]),
};
}
@@ -1,10 +1,9 @@
import { ErrorBoundary } from '@sentry/react';
import { PropsWithChildren, ReactNode, Suspense } from 'react';
import { PropsWithChildren, ReactNode } 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';
@@ -16,37 +15,18 @@ export function OverviewWrapper({ navElements, children }: PropsWithChildren<Ove
const isOnline = useIsOnline();
return (
<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 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>
);
}
@@ -210,7 +210,7 @@ export function MetadataTimes() {
function GroupTimes() {
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
const currentGroupId = useCurrentGroupId();
const group = useEntry(null, currentGroupId) as OntimeGroup | null;
const group = useEntry(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(null, id) as OntimeEvent | null;
const entry = useEntry(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(null);
const { data: rundownData } = useRundownAuxData();
if (!projectData.title && !rundownData.title) {
return null;
@@ -6,7 +6,8 @@ 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 { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext';
import { useLoadedRundownSource } from '../../common/hooks-query/useScopedRundown';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils';
@@ -38,29 +39,28 @@ function RundownExport() {
defaultValue: RundownViewMode.List,
});
const isSmallDevice = useIsSmallDevice();
const entryActions = useEntryActions();
if (isSmallDevice && isExtracted) {
return (
<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>
<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>
</div>
</ProtectRoute>
</EntryActionsProvider>
</RundownSelectionContextProvider>
</div>
</ProtectRoute>
</EntryActionsProvider>
);
}
@@ -70,32 +70,30 @@ function RundownExport() {
viewMode === RundownViewMode.Table;
return (
<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}>
<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}>
<ErrorBoundary>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
<RundownEntryEditor />
</ErrorBoundary>
</Editor.Panel>
{!hideSideBar && (
<div className={style.side}>
<ErrorBoundary>
<RundownEntryEditor />
</ErrorBoundary>
</div>
)}
</div>
</div>
)}
</div>
</ProtectRoute>
</EntryActionsProvider>
</RundownSelectionContextProvider>
</div>
</ProtectRoute>
</EntryActionsProvider>
);
}
@@ -107,16 +105,17 @@ 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 />}
{viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />}
<RenumberCuesDialog />
</div>
);
@@ -1,36 +1,34 @@
import { Playback } from 'ontime-types';
import { memo } from 'react';
import Empty from '../../common/components/state/Empty';
import { useContextRundownList } from '../../common/hooks-query/useContextRundown';
import EmptyFill from '../../common/components/state/EmptyFill';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { useTranslation } from '../../translation/TranslationProvider';
import Rundown from './Rundown';
const backgroundFeatureData = {
playback: Playback.Stop,
selectedEventId: null,
nextEventId: null,
};
export default memo(RundownList);
function RundownList() {
const { rundown, status, rundownMetadata, isLoadedRundown } = useContextRundownList();
const { data, status, isLoadingError, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor();
const { getLocalizedString } = useTranslation();
const isLoading = status !== 'success' || !rundown || !rundownMetadata;
// avoid showing the editable empty state before we know whether the rundown is actually empty
if (status === 'pending') {
return <EmptyFill text='Loading…' />;
}
if (isLoading) {
return <Empty text='Connecting to server' />;
if (isLoadingError) {
return <EmptyFill text={getLocalizedString('common.no_data')} />;
}
return (
<Rundown
order={rundown.order}
flatOrder={rundown.flatOrder}
entries={rundown.entries}
id={rundown.id}
order={data.order}
flatOrder={data.flatOrder}
entries={data.entries}
id={data.id}
rundownMetadata={rundownMetadata}
featureData={isLoadedRundown ? featureData : backgroundFeatureData}
featureData={featureData}
/>
);
}
@@ -1,3 +0,0 @@
.rundownSelect {
min-width: min(20rem, calc(100vw - 6rem));
}
@@ -1,42 +0,0 @@
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>
);
}
@@ -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(null);
const { data } = useRundown();
const entry = useMemo<OntimeEntry | null>(() => {
if (data.order.length === 0) {
@@ -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 { useContextRundownCueRenumberModal } from '../../../common/hooks-query/useContextRundown';
import useRundown from '../../../common/hooks-query/useRundown';
import { orderEntries } from '../rundown.utils';
import { useEventSelection } from '../useEventSelection';
@@ -17,7 +17,8 @@ type RenumberCueData = Pick<RenumberCues, 'increment' | 'prefix' | 'start'>;
export default function RenumberCuesDialog() {
'use memo';
const { flatOrder } = useContextRundownCueRenumberModal();
const { data } = useRundown();
const { flatOrder } = data;
const { onClose, isOpen } = useRenumberCuesDialogStore();
const { renumberCues } = useEntryActionsContext();
const selectedEvents = useEventSelection((state) => state.selectedEvents);
@@ -8,7 +8,6 @@ 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';
@@ -25,7 +24,6 @@ interface HeaderControlsConfig {
showRunEditToggle: boolean;
showOffsetToggle: boolean;
showOverflowMenu: boolean;
showRundownSelect: boolean;
}
export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConfig> = {
@@ -33,19 +31,16 @@ 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;
@@ -55,8 +50,7 @@ function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProp
const offsetMode = useOffsetMode();
const { layoutMode } = useEditorLayout();
const { showRunEditToggle, showOffsetToggle, showOverflowMenu, showRundownSelect } =
HEADER_CONTROLS_CONFIG[layoutMode];
const { showRunEditToggle, showOffsetToggle, showOverflowMenu } = HEADER_CONTROLS_CONFIG[layoutMode];
const toggleAppMode = (mode: AppMode[]) => {
// we need to stop user from deselecting a mode
@@ -128,8 +122,6 @@ function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProp
</ToggleGroup>
)}
{showRundownSelect && <RundownSelect appMode={editorMode} />}
{showOverflowMenu && <RundownMenu allowNavigation={!isExtracted} />}
</Toolbar.Root>
);
@@ -1,7 +1,9 @@
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';
@@ -10,9 +12,11 @@ import { makeRundownColumns } from './makeRundownColumns';
export default memo(RundownTable);
function RundownTable() {
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: customFields } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource();
const actions = useEntryActions();
// Editor always has full permissions
useEffect(() => {
@@ -27,15 +31,11 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
const isLoading = !customFields || customFieldStatus === 'pending';
return (
<CuesheetDnd columns={columns} tableRoot='editor'>
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' />
)}
</CuesheetDnd>
<EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'>
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
</CuesheetDnd>
</EntryActionsProvider>
);
}
@@ -35,7 +35,7 @@ export default function BackstageLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Backstage {...data} />;
@@ -20,10 +20,14 @@ export function useBackstageData(): ViewData<BackstageData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
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();
const { data: rundownData, status: rundownStatus, isLoadingError: rundownIsLoadingError } = useFlatRundown();
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
return {
data: {
@@ -33,6 +37,11 @@ export function useBackstageData(): ViewData<BackstageData> {
isMirrored,
settings,
},
status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]),
status: aggregateQueryStatus([
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
};
}
@@ -7,7 +7,7 @@ $dot-spacing: 1.5rem;
display: grid;
place-items: center;
background-color: var(--background-color-override, $viewer-background-color);
height: 100vh;
height: 100dvh;
}
.ellipsis {
@@ -21,26 +21,26 @@ $dot-spacing: 1.5rem;
height: $dot-size;
border-radius: 50%;
background-color: var(--accent-color-override, $ontime-color);
animation-timing-function: cubic-bezier(0, 1, 1, 0);
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
&:nth-child(1) {
left: $dot-size;
animation: lds-ellipsis1 0.6s infinite;
animation: lds-ellipsis1 1s infinite;
}
&:nth-child(2) {
left: $dot-size;
animation: lds-ellipsis2 0.6s infinite;
animation: lds-ellipsis2 1s infinite;
}
&:nth-child(3) {
left: calc($dot-size + $dot-spacing);
animation: lds-ellipsis2 0.6s infinite;
animation: lds-ellipsis2 1s infinite;
}
&:nth-child(4) {
left: calc($dot-size + 2 * $dot-spacing);
animation: lds-ellipsis3 0.6s infinite;
animation: lds-ellipsis3 1s infinite;
}
}
}
@@ -45,7 +45,7 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
[filter],
);
const { data: events } = usePartialRundown(null, filterCallback);
const { data: events } = usePartialRundown(filterCallback);
const [firstIndex, setFirstIndex] = useState(-1);
const [numPages, setNumPages] = useState(0);
+1 -15
View File
@@ -80,21 +80,7 @@ $item-height: 3.5rem;
display: flex;
flex-direction: column;
align-items: center;
button {
margin-top: 1.75rem;
}
}
.empty-state__content {
max-width: none;
span {
max-width: none;
white-space: nowrap;
font-size: clamp(1.5rem, 4vw, 2.25rem);
line-height: 1.1;
}
gap: 1.5rem;
}
.list-container {
@@ -43,7 +43,7 @@ export default function CountdownLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Countdown {...data} />;
@@ -87,7 +87,7 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
{!hasEvents && (
<div className='empty-state'>
<Empty text={getLocalizedString('common.no_data')} className='empty-state__content' />
<Empty text={getLocalizedString('common.no_data')} />
</div>
)}
@@ -121,7 +121,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscriptions.length === 0) {
return (
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Empty text={getLocalizedString('countdown.select_event')} />
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add
</Button>
@@ -137,7 +137,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscribedEvents.length === 0) {
return (
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Empty text={getLocalizedString('countdown.select_event')} />
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add
</Button>
@@ -154,7 +154,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (eventsToShow.length === 0) {
return (
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-state__content' />
<Empty text={getLocalizedString('countdown.all_have_finished')} />
</div>
);
}
@@ -21,10 +21,18 @@ export function useCountdownData(): ViewData<CountdownData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
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();
const {
data: rundownData,
status: rundownStatus,
isLoadingError: rundownIsLoadingError,
} = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
return {
data: {
@@ -34,6 +42,11 @@ export function useCountdownData(): ViewData<CountdownData> {
isMirrored,
settings,
},
status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]),
status: aggregateQueryStatus([
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
};
}
@@ -14,3 +14,7 @@
'table';
color: $ui-white;
}
.rundownSelect {
min-width: min(20rem, calc(100vw - 6rem));
}
+28 -23
View File
@@ -4,46 +4,51 @@ 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 { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext';
import { useScopedRundown } from '../../common/hooks-query/useScopedRundown';
import { useScopedEntryActions } from '../../common/hooks/useEntryAction';
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 (
<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>
<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>
);
}
@@ -1,41 +1,108 @@
import { MaybeString, ProjectRundown } from 'ontime-types';
import { memo, use, useMemo } from 'react';
import EmptyPage from '../../common/components/state/EmptyPage';
import Select from '../../common/components/select/Select';
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() {
function CuesheetTableWrapper({
source,
selectedRundownId,
setSelectedRundownId,
loadedRundownId,
projectRundowns,
}: CuesheetTableWrapperProps) {
const preset = use(PresetContext);
const { isLoadedRundown } = useRundownSelectionContext();
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isLoadedRundown });
const { data: customFields, status: customFieldStatus } = useCustomFields();
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { data: customFields } = useCustomFields();
const columns = useMemo(
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
[customFields, cuesheetMode, preset],
);
const isLoading = !customFields || customFieldStatus === 'pending';
return (
<CuesheetDnd columns={columns}>
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable
columns={columns}
cuesheetMode={cuesheetMode}
tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode}
/>
)}
<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>
);
}
@@ -0,0 +1,21 @@
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,13 +1,16 @@
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() {
const { rundown } = useContextRundownEditModal();
function EntryEditModal({ rundown }: EntryEditModalProps) {
const entryId = useEditModal((state) => state.selectedEntryId);
const closeModal = useEditModal((state) => state.clearSelection);
@@ -5,6 +5,10 @@ $table-header-font-size: calc(1rem - 2px);
@include rows.cuesheet-row-columns($table-header-font-size);
.tableLoading {
grid-area: table;
}
.cuesheet {
font-size: $table-font-size;
font-weight: 400;
@@ -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, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import {
ContextProp,
ItemProps,
@@ -11,14 +11,15 @@ import {
TableVirtuosoHandle,
} from 'react-virtuoso';
import EmptyPage from '../../../common/components/state/EmptyPage';
import EmptyFill from '../../../common/components/state/EmptyFill';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextRundownTable } from '../../../common/hooks-query/useContextRundown';
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
import { AppMode } from '../../../ontimeConfig';
import { useTranslation } from '../../../translation/TranslationProvider';
import { usePersistedCuesheetOptions } from '../cuesheet.options';
import { useCuesheetPermissions } from '../useTablePermissions';
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
@@ -35,23 +36,36 @@ 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, tableRoot, setCuesheetMode }: CuesheetTableProps) {
const { flatRundown, status, loadedEventId } = useContextRundownTable();
export default function CuesheetTable({
columns,
cuesheetMode,
source,
tableRoot,
setCuesheetMode,
isCurrentRundown,
insertElement,
}: CuesheetTableProps) {
const { flatRundown, status, isLoadingError, selectedEventId } = source;
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const { getLocalizedString } = useTranslation();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
@@ -131,17 +145,17 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
// in Run mode, follow the current event
useEffect(() => {
if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !loadedEventId) {
if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !selectedEventId) {
return;
}
const eventIndex = flatRundown.findIndex((event) => event.id === loadedEventId);
const eventIndex = flatRundown.findIndex((event) => event.id === selectedEventId);
if (eventIndex === -1) {
return;
}
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
}, [cuesheetMode, flatRundown, loadedEventId]);
}, [cuesheetMode, flatRundown, selectedEventId]);
// Provide an imperative scroll handler for explicit jumps (finder/keyboard)
useEffect(() => {
@@ -216,10 +230,13 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
});
}, [cuesheetMode, hideIndexColumn, table]);
const isLoading = !flatRundown || status === 'pending';
// avoid showing the editable empty state before we know whether the rundown is actually empty
if (status === 'pending') {
return <EmptyFill text='Loading…' className={style.tableLoading} />;
}
if (isLoading) {
return <EmptyPage text='Loading...' />;
if (isLoadingError) {
return <EmptyFill text={getLocalizedString('common.no_data')} className={style.tableLoading} />;
}
return (
@@ -230,9 +247,17 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
appMode={cuesheetMode}
tableRoot={tableRoot}
setCuesheetMode={setCuesheetMode}
insertElement={insertElement}
modeControls={
tableRoot === 'cuesheet'
? {
cuesheetMode,
setCuesheetMode,
isCurrentRundown,
}
: undefined
}
showShare={tableRoot === 'cuesheet'}
/>
<TableVirtuoso
ref={virtuosoRef}
@@ -10,9 +10,7 @@ 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';
@@ -32,15 +30,21 @@ 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;
setCuesheetMode?: (mode: AppMode) => void;
appMode: AppMode;
tableRoot: 'editor' | 'cuesheet';
insertElement?: ReactNode;
modeControls?: TableModeControls;
showShare?: boolean;
}
export default function CuesheetTableHeaderToolbar({
@@ -49,23 +53,23 @@ export default function CuesheetTableHeaderToolbar({
handleResetResizing,
handleResetReordering,
handleClearToggles,
setCuesheetMode,
tableRoot,
appMode,
insertElement,
modeControls,
showShare = false,
}: CuesheetTableHeaderToolbarProps) {
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode) && tableRoot === 'cuesheet';
const canShare = useCuesheetPermissions((state) => state.canShare) && tableRoot === 'cuesheet';
const showRundownSelect = tableRoot === 'cuesheet';
const { isLoadedRundown } = useRundownSelectionContext();
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode);
const canShare = useCuesheetPermissions((state) => state.canShare);
const toggleCuesheetMode = (mode: AppMode[]) => {
const newValue = mode.at(0);
if (!newValue || !setCuesheetMode) return;
setCuesheetMode(newValue);
if (!newValue || !modeControls) return;
modeControls.setCuesheetMode(newValue);
};
const isBackground = !(modeControls?.isCurrentRundown ?? true);
return (
<Toolbar.Root className={style.tableSettings} data-background-rundown={!isLoadedRundown}>
<Toolbar.Root className={style.tableSettings} data-background-rundown={isBackground}>
<ViewSettings optionsStore={optionsStore} />
<ColumnSettings
columns={columns}
@@ -73,14 +77,14 @@ export default function CuesheetTableHeaderToolbar({
handleResetReordering={handleResetReordering}
handleClearToggles={handleClearToggles}
/>
<div className={style.apart}>
{showRundownSelect && <RundownSelect appMode={appMode} />}
{canChangeMode && (
{modeControls && canChangeMode && (
<div className={style.apart}>
{insertElement}
<ToggleGroup
value={[appMode]}
value={[modeControls.cuesheetMode]}
onValueChange={toggleCuesheetMode}
className={style.group}
disabled={!isLoadedRundown}
disabled={!modeControls.isCurrentRundown}
>
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
Run
@@ -89,10 +93,10 @@ export default function CuesheetTableHeaderToolbar({
Edit
</Toolbar.Button>
</ToggleGroup>
)}
</div>
</div>
)}
{canShare && (
{showShare && canShare && (
<>
<Editor.Separator orientation='vertical' />
<CuesheetShareModal />
@@ -0,0 +1,65 @@
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],
);
}
+35 -43
View File
@@ -1,6 +1,5 @@
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';
@@ -15,51 +14,44 @@ const MessageControl = lazy(() => import('../../features/control/message/Message
export default function Editor() {
const { layoutMode } = useEditorLayout();
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>
<TrackingPlaybackBar />
if (layoutMode === EditorLayoutMode.CONTROL) {
return (
<div id='panels' className={styles.panelContainer}>
<div className={styles.left}>
<TimerControl />
<MessageControl />
</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>
<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} />
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
</div>
);
}
case EditorLayoutMode.CONTROL:
default: {
return (
<div id='panels' className={styles.panelContainer}>
<div className={styles.left}>
<TimerControl />
<MessageControl />
</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}>
<Rundown />
</div>
);
}
}
</div>
</div>
);
}
@@ -1,7 +1,7 @@
import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useContextRundownFinder } from '../../../common/hooks-query/useContextRundown';
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
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 { rundown: data, rundownId } = useContextRundownFinder();
const { data, rundownId } = useFlatRundown();
const [results, setResults] = useState<FilterableEntry[]>([]);
const [error, setError] = useState<MaybeString>(null);
const lastSearchString = useRef('');
@@ -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 { useContextRundownList } from '../../../common/hooks-query/useContextRundown';
import useRundown from '../../../common/hooks-query/useRundown';
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 { rundown } = useContextRundownList();
const { data: rundown } = useRundown();
const selectedEventId = useSelectedEventId();
const cursor = useEventSelection((state) => state.cursor);
@@ -1,9 +1,6 @@
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 {
@@ -23,29 +20,14 @@ function getEditorLayout(value: string | null): EditorLayoutMode {
}
export function useEditorLayout() {
'use memo';
const [searchParams, setSearchParams] = useSearchParams();
const layoutMode = getEditorLayout(searchParams.get(layoutParam));
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],
);
const setLayoutMode = (mode: EditorLayoutMode) => {
const nextParams = new URLSearchParams(searchParams);
nextParams.set(layoutParam, mode);
setSearchParams(nextParams, { replace: true });
};
return { layoutMode, setLayoutMode };
}
@@ -1,5 +1,7 @@
@use '@/theme/viewerDefs' as *;
$content-width: min(100%, 1100px);
.project {
margin: 0;
box-sizing: border-box; /* reset */
@@ -16,56 +18,104 @@
display: flex;
flex-direction: column;
/* =================== HEADER ===================*/
.project-header {
width: $content-width;
margin-inline: auto;
display: flex;
align-items: center;
gap: clamp(12px, 2vw, 24px);
padding-bottom: $view-element-gap;
border-bottom: 1px solid $white-10;
}
.logo {
max-width: min(200px, 30vw);
}
.title {
font-size: $header-font-size;
font-weight: 600;
line-height: 1.1em;
}
.description {
font-size: $base-font-size;
color: var(--secondary-color-override, $viewer-secondary-color);
}
/* =================== CONTENT ===================*/
.info {
flex: 1;
max-height: 100%;
width: $content-width;
margin-inline: auto;
overflow-y: auto;
width: min(calc(100vw - 4rem), 960px);
display: flex;
flex-direction: column;
align-items: start;
gap: $view-element-gap;
padding-block: $view-element-gap;
padding-bottom: 10vh;
}
.info__card {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
border-radius: $element-border-radius;
padding: $view-block-padding $view-inline-padding;
display: flex;
flex-direction: column;
gap: 0.35em;
}
.info__media {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: $view-element-gap;
}
.info__media .info__value {
flex: 1;
min-width: 0;
}
.info__label {
font-size: $timer-label-size;
font-weight: 600;
letter-spacing: 0.05em;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.info__value {
white-space: break-spaces;
}
.info__custom {
display: flex;
gap: 1rem;
line-height: 1.35;
overflow-wrap: anywhere;
}
.info__image-container {
display: flex;
justify-content: center;
align-items: center;
width: 192px;
height: 192px;
flex: 0 0 min(192px, 25%);
}
.info__image {
display: block;
max-width: 100%;
max-height: 100%;
height: auto;
object-fit: contain;
}
.link.info__value {
display: flex;
gap: $view-element-gap;
display: inline-flex;
gap: 0.35em;
align-items: center;
color: $action-text-color;
@@ -78,12 +128,13 @@
/* =================== MOBILE ===================*/
@media screen and (max-width: 768px) {
.project {
.project-header {
flex-direction: column;
align-items: start;
gap: 0.5rem;
}
.logo img {
height: min(50px, 10vh);
}
.info__image-container {
width: 96px;
height: 96px;
}
}
}
@@ -1,4 +1,5 @@
import { OntimeView } from 'ontime-types';
import { type ReactNode, useState } from 'react';
import { IoOpenOutline } from 'react-icons/io5';
import EmptyPage from '../../common/components/state/EmptyPage';
@@ -21,7 +22,7 @@ export default function ProjectInfoLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <ProjectInfo {...data} />;
@@ -41,56 +42,49 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
return (
<>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
<EmptyPage text={getLocalizedString('common.no_data')} />;
<EmptyPage text={getLocalizedString('common.no_data')} />
</>
);
}
const hasHeader = Boolean(projectData.logo || projectData.title || projectData.description);
return (
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
{hasHeader && (
<div className='project-header'>
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='project-header__text'>
{projectData.title && <div className='title'>{projectData.title}</div>}
{projectData.description && <div className='description'>{projectData.description}</div>}
</div>
</div>
)}
<div className='info'>
{projectData.title && (
<div>
<div className='info__label'>{getLocalizedString('project.title')}</div>
<div className='info__value'>{projectData.title}</div>
</div>
)}
{projectData.description && (
<div>
<div className='info__label'>{getLocalizedString('project.description')}</div>
<div className='info__value'>{projectData.description}</div>
</div>
)}
{projectData.info && (
<div>
<div className='info__label'>{getLocalizedString('project.info')}</div>
<div className='info__value'>{projectData.info}</div>
</div>
)}
{projectData.info && <InfoCard label={getLocalizedString('project.info')}>{projectData.info}</InfoCard>}
{projectData.url && (
<div>
<div className='info__card'>
<div className='info__label'>{getLocalizedString('project.url')}</div>
<a href={projectData.url} target='_blank' rel='noreferrer' className='info__value link'>
{projectData.url} <IoOpenOutline style={{ fontSize: '1em' }} />
{projectData.url}
<IoOpenOutline style={{ fontSize: '1em' }} />
</a>
</div>
)}
{projectData.custom.map((info, idx) => {
const hasUrl = Boolean(info.url);
return (
// oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here
<div key={`${info.title}-${idx}`} className='info__custom'>
{hasUrl && (
<div className='info__image-container'>
<img className='info__image' src={info.url} loading='lazy' />
<div key={`${info.title}-${idx}`} className='info__card'>
{info.title && <div className='info__label'>{info.title}</div>}
{info.url ? (
<div className='info__media'>
<InfoImage src={info.url} />
{info.value && <div className='info__value'>{info.value}</div>}
</div>
) : (
info.value && <div className='info__value'>{info.value}</div>
)}
<div>
<div className='info__label'>{info.title}</div>
<div className='info__value'>{info.value}</div>
</div>
</div>
);
})}
@@ -98,3 +92,31 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
</div>
);
}
interface InfoCardProps {
label: string;
children: ReactNode;
}
function InfoCard({ label, children }: InfoCardProps) {
return (
<div className='info__card'>
<div className='info__label'>{label}</div>
<div className='info__value'>{children}</div>
</div>
);
}
function InfoImage({ src }: { src: string }) {
const [hasError, setHasError] = useState(false);
if (hasError) {
return null;
}
return (
<div className='info__image-container'>
<img className='info__image' src={src} loading='lazy' alt='' onError={() => setHasError(true)} />
</div>
);
}
@@ -2,7 +2,7 @@ import { ProjectData } from 'ontime-types';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ViewData } from '../utils/viewLoader.utils';
import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
export interface ProjectInfoData {
projectData: ProjectData;
@@ -14,13 +14,13 @@ export function useProjectInfoData(): ViewData<ProjectInfoData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
return {
data: {
projectData,
isMirrored,
},
status: projectDataStatus,
status: aggregateQueryStatus([{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError }]),
};
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function StudioLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Studio {...data} />;
+18 -5
View File
@@ -20,10 +20,18 @@ export function useStudioData(): ViewData<StudioData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const {
data: viewSettings,
status: viewSettingsStatus,
isLoadingError: viewSettingsIsLoadingError,
} = useViewSettings();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
return {
data: {
@@ -33,6 +41,11 @@ export function useStudioData(): ViewData<StudioData> {
settings,
viewSettings,
},
status: aggregateQueryStatus([projectDataStatus, viewSettingsStatus, settingsStatus, customFieldsStatus]),
status: aggregateQueryStatus([
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: viewSettingsStatus, isLoadingError: viewSettingsIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
};
}
@@ -1,6 +1,7 @@
import { OntimeView } from 'ontime-types';
import { useMemo } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
@@ -29,7 +30,7 @@ export default function TimelinePageLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <TimelinePage {...data} />;
@@ -73,7 +74,7 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
totalDuration={totalDuration}
/>
) : (
<EmptyPage text={getLocalizedString('common.no_data')} />
<EmptyFill text={getLocalizedString('common.no_data')} />
)}
</div>
);
@@ -16,10 +16,18 @@ export interface TimelineData {
export function useTimelineData(): ViewData<TimelineData> {
// HTTP API data
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();
const {
data: rundownData,
status: rundownStatus,
isLoadingError: rundownIsLoadingError,
} = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
return {
data: {
@@ -28,6 +36,11 @@ export function useTimelineData(): ViewData<TimelineData> {
projectData,
settings,
},
status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]),
status: aggregateQueryStatus([
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
};
}
+1 -1
View File
@@ -43,7 +43,7 @@ export default function TimerLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Timer {...data} />;
+19 -11
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';
@@ -22,11 +22,19 @@ export function useTimerData(): ViewData<TimerData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
const { data: rundown, status: rundownStatus } = useRundown(null);
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const {
data: viewSettings,
status: viewSettingsStatus,
isLoadingError: viewSettingsIsLoadingError,
} = useViewSettings();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
const { data: rundown, status: rundownStatus, isLoadingError: rundownIsLoadingError } = useRundown();
const { entries } = rundown;
return {
@@ -39,11 +47,11 @@ export function useTimerData(): ViewData<TimerData> {
entries,
},
status: aggregateQueryStatus([
projectDataStatus,
viewSettingsStatus,
settingsStatus,
customFieldsStatus,
rundownStatus,
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: viewSettingsStatus, isLoadingError: viewSettingsIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
]),
};
}
+17 -11
View File
@@ -5,21 +5,27 @@ export type ViewData<T> = {
status: QueryStatus;
};
type AggregatableQuery = {
status: QueryStatus;
/** true only when the query has never received data, ie useQuery's isLoadingError */
isLoadingError: boolean;
};
/**
* Aggregates a loading status from multiple query statuses.
* If all statuses are 'pending', returns 'pending'.
* If all statuses are 'success', returns 'success'.
* If any status is 'error', returns 'error'.
* Aggregates a loading status from multiple queries, for the purpose of deciding
* whether a view can render.
* - 'pending' while any query hasn't settled yet (no result, first fetch in flight)
* - 'error' once all queries have settled, if any of them never received data
* - 'success' once all queries have settled and every one has data to show,
* even if a query's last (background) fetch failed
*/
export function aggregateQueryStatus(statuses: QueryStatus[]): QueryStatus {
if (statuses.every((status) => status === 'pending')) {
export function aggregateQueryStatus(queries: AggregatableQuery[]): QueryStatus {
const allSettled = queries.every((query) => query.status !== 'pending');
if (!allSettled) {
return 'pending';
}
if (statuses.every((status) => status === 'success')) {
return 'success';
}
if (statuses.some((status) => status === 'error')) {
if (queries.some((query) => query.isLoadingError)) {
return 'error';
}
return 'pending';
return 'success';
}
@@ -1,10 +1,9 @@
import { copyFile } from 'fs/promises';
import { join } from 'path';
import { DatabaseModel, LogOrigin, ProjectFileListResponse, RefetchKey } from 'ontime-types';
import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types';
import { getErrorMessage, getFirstRundown } from 'ontime-utils';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
@@ -106,9 +105,6 @@ async function loadProject(projectData: DatabaseModel, fileName: string, rundown
currentProjectName: fileName,
};
setImmediate(() => {
sendRefetch(RefetchKey.ProjectFiles);
});
return fileName;
}
@@ -267,10 +263,6 @@ export async function duplicateProjectFile(originalFile: string, newFilename: st
const pathToDuplicate = getPathToProject(newFilename);
await copyFile(projectFilePath, pathToDuplicate);
setImmediate(() => {
sendRefetch(RefetchKey.ProjectFiles);
});
return;
}
@@ -301,10 +293,6 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
const newFileName = await loadProject(projectData.data, newFilename);
return newFileName;
}
setImmediate(() => {
sendRefetch(RefetchKey.ProjectFiles);
});
return newFilename;
}
@@ -344,9 +332,6 @@ export async function deleteProjectFile(filename: string) {
}
await deleteFile(projectFilePath);
setImmediate(() => {
sendRefetch(RefetchKey.ProjectFiles);
});
}
/**
@@ -389,7 +374,6 @@ export async function patchCurrentProject(data: Partial<DatabaseModel>) {
}
}
const updatedData = getDataProvider().getData();
const updatedData = await getDataProvider().getData();
return updatedData;
}
@@ -1,7 +1,6 @@
export enum RefetchKey {
All = 'all',
CustomFields = 'custom-fields',
ProjectFiles = 'project-files',
ProjectData = 'project-data',
ProjectRundowns = 'project-rundowns',
Report = 'report',