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
37 changed files with 517 additions and 316 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'>
@@ -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() {
@@ -32,7 +32,7 @@ export default function useRundown() {
data: { loaded: loadedRundownId },
} = useProjectRundowns();
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
const { data, status, isError, isLoadingError, refetch, isFetching } = useQuery<Rundown>({
queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
refetchInterval: queryRefetchIntervalSlow,
@@ -50,14 +50,14 @@ export default function useRundown() {
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [loadedRundownId, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
return { data: data ?? cachedRundownPlaceholder, status, isError, isLoadingError, refetch, isFetching };
}
export function useRundownWithMetadata() {
const { data, status } = useRundown();
const { data, status, isLoadingError } = useRundown();
const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, rundownMetadata };
return { data, status, isLoadingError, rundownMetadata };
}
/**
@@ -65,7 +65,7 @@ export function useRundownWithMetadata() {
* built from the order and rundown fields
*/
export function useFlatRundown() {
const { data, status } = useRundown();
const { data, status, isLoadingError } = useRundown();
const flatRundown = useMemo(() => {
if (data.revision === -1) {
@@ -74,15 +74,15 @@ export function useFlatRundown() {
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() {
const { data, status } = useRundown();
const { data, status, isLoadingError } = useRundown();
const selectedEventId = useSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status };
return { data: rundownWithMetadata, status, isLoadingError };
}
/**
@@ -127,7 +127,7 @@ export function useRundownAuxData() {
export function useRundownById(rundownId: string | null | undefined) {
const enabled = Boolean(rundownId);
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
const { data, status, isError, isLoadingError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(rundownId ?? ''),
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
enabled,
@@ -135,5 +135,5 @@ export function useRundownById(rundownId: string | null | undefined) {
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
return { data: data ?? cachedRundownPlaceholder, status, isError, isLoadingError, refetch, isFetching };
}
@@ -11,6 +11,7 @@ export type RundownSource = {
rundown: Rundown;
flatRundown: ExtendedEntry[];
status: string;
isLoadingError: boolean;
selectedEventId: EntryId | null;
};
@@ -35,7 +36,7 @@ function useRundownSource(rundownId: string | null, loadedRundownId: string | nu
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
const runtimeSelectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
const { data: rundown, status } = useRundownById(rundownId);
const { data: rundown, status, isLoadingError } = useRundownById(rundownId);
const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
@@ -47,8 +48,9 @@ function useRundownSource(rundownId: string | null, loadedRundownId: string | nu
rundown,
flatRundown,
status,
isLoadingError,
selectedEventId: effectiveSelectedEventId,
}),
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
[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 };
}
+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();
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,19 +1,24 @@
import { memo } from 'react';
import Empty from '../../common/components/state/Empty';
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';
export default memo(RundownList);
function RundownList() {
const { data, status, rundownMetadata } = useRundownWithMetadata();
const { data, status, isLoadingError, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor();
const { getLocalizedString } = useTranslation();
const isLoading = status !== 'success' || !data || !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 (
@@ -1,6 +1,5 @@
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';
@@ -13,7 +12,7 @@ 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();
@@ -32,16 +31,10 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
const isLoading = !customFields || customFieldStatus === 'pending';
return (
<EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'>
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
)}
<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();
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;
}
}
}
+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();
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 },
]),
};
}
@@ -2,7 +2,6 @@ import { MaybeString, ProjectRundown } from 'ontime-types';
import { memo, use, useMemo } from 'react';
import Select from '../../common/components/select/Select';
import EmptyPage from '../../common/components/state/EmptyPage';
import { PresetContext } from '../../common/context/PresetContext';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
@@ -34,40 +33,34 @@ function CuesheetTableWrapper({
const preset = use(PresetContext);
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { data: customFields, status: customFieldStatus } = useCustomFields();
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}
source={source}
cuesheetMode={cuesheetMode}
tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode}
isCurrentRundown={isCurrentRundown}
insertElement={
<>
<RundownSelect
cuesheetMode={cuesheetMode}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</>
}
/>
)}
<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>
);
}
@@ -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;
@@ -11,7 +11,7 @@ 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 type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
@@ -19,6 +19,7 @@ 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';
@@ -62,8 +63,9 @@ export default function CuesheetTable({
isCurrentRundown,
insertElement,
}: CuesheetTableProps) {
const { flatRundown, status, selectedEventId } = source;
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;
@@ -228,10 +230,13 @@ export default function CuesheetTable({
});
}, [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 (
@@ -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();
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} />;
+18 -10
View File
@@ -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();
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';
}