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 { .emptyContainer {
width: 100%; width: 100%;
text-align: center; text-align: center;
color: $white-10; color: var(--secondary-color-override, $viewer-secondary-color);
.empty { .empty {
display: block; display: block;
width: min(100%, 24rem); width: min(100%, 14rem);
margin-inline: auto; margin: 0 auto -1.5rem;
opacity: 0.8; opacity: 0.6;
} }
.text { .text {
display: block; display: block;
margin-inline: auto; margin-inline: auto;
font-weight: 600; font-weight: 400;
font-size: 2em; font-size: clamp(1rem, 1.55vw, 1.5rem);
max-width: min(100%, 600px); 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 { CSSProperties } from 'react';
import { IoWarningOutline } from 'react-icons/io5';
import EmptyImage from '../../../assets/images/empty.svg?react'; import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -9,12 +10,18 @@ interface EmptyProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
className?: string; className?: string;
variant?: 'error';
} }
export default function Empty({ text, className, injectedStyles }: EmptyProps) { export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) {
return ( 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} /> <EmptyImage className={style.empty} />
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
{text && <span className={style.text}>{text}</span>} {text && <span className={style.text}>{text}</span>}
</div> </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 */ box-sizing: border-box; /* reset */
overflow: hidden; overflow: hidden;
width: 100%; /* restrict the page width to viewport */ width: 100%; /* restrict the page width to viewport */
height: 100vh; height: 100dvh;
font-family: var(--font-family-override, $viewer-font-family); font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color); background: var(--background-color-override, $viewer-background-color);
@@ -16,5 +16,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding-top: 5rem; justify-content: center;
padding-block: 5rem;
} }
@@ -7,12 +7,13 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps { interface EmptyPageProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
variant?: 'error';
} }
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) { export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) {
return ( return (
<div className={style.page}> <div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} /> <Empty text={text} injectedStyles={injectedStyles} variant={variant} />
</div> </div>
); );
} }
@@ -15,9 +15,4 @@
gap: 1rem; gap: 1rem;
margin-top: 1em; margin-top: 1em;
} }
.text {
font-weight: 600;
font-size: 2em;
}
} }
@@ -18,8 +18,7 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
<tbody className={style.emptyContainer}> <tbody className={style.emptyContainer}>
<tr> <tr>
<td colSpan={99} className={style.emptyCell}> <td colSpan={99} className={style.emptyCell}>
<Empty injectedStyles={{ marginTop: '5vh' }} /> <Empty text={text} injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
{handleAddNew && ( {handleAddNew && (
<div className={style.inline}> <div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'> <Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
@@ -1,15 +1,15 @@
import { PropsWithChildren, createContext, useContext } from 'react'; import { PropsWithChildren, createContext, useContext } from 'react';
import { useEntryActions, useScopedEntryActions } from '../hooks/useEntryAction'; import { useEntryActions } from '../hooks/useEntryAction';
import { useRundownSelectionContext } from './RundownSelectionContext';
type EntryActionsContextValue = ReturnType<typeof useEntryActions>; type EntryActionsContextValue = ReturnType<typeof useEntryActions>;
const EntryActionsContext = createContext<EntryActionsContextValue | null>(null); const EntryActionsContext = createContext<EntryActionsContextValue | null>(null);
export function EntryActionsProvider({ children }: PropsWithChildren) { interface EntryActionsProviderProps extends PropsWithChildren {
const { effectiveRundownId } = useRundownSelectionContext(); actions: EntryActionsContextValue;
const actions = useScopedEntryActions(effectiveRundownId); }
export function EntryActionsProvider({ children, actions }: EntryActionsProviderProps) {
return <EntryActionsContext.Provider value={actions}>{children}</EntryActionsContext.Provider>; return <EntryActionsContext.Provider value={actions}>{children}</EntryActionsContext.Provider>;
} }
@@ -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 = {}; const placeholder: CustomFields = {};
export default function useCustomFields() { export default function useCustomFields() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS, queryKey: CUSTOM_FIELDS,
queryFn: ({ signal }) => getCustomFields({ signal }), queryFn: ({ signal }) => getCustomFields({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, 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'; import { projectDataPlaceholder } from '../models/ProjectData';
export default function useProjectData() { export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({
queryKey: PROJECT_DATA, queryKey: PROJECT_DATA,
queryFn: ({ signal }) => getProjectData({ signal }), queryFn: ({ signal }) => getProjectData({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch }; return { data: data ?? projectDataPlaceholder, status, isFetching, isError, isLoadingError, refetch };
} }
export function useUpdateProjectData() { export function useUpdateProjectData() {
@@ -1,18 +1,24 @@
import { useSuspenseQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ProjectFile, ProjectFileList } from 'ontime-types'; import { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_LIST } from '../api/constants'; import { PROJECT_LIST } from '../api/constants';
import { getProjects } from '../api/db'; import { getProjects } from '../api/db';
export function useProjectList() { const placeholderProjectList: ProjectFileListResponse = {
const { data, status, refetch } = useSuspenseQuery({ files: [],
lastLoadedProject: '',
};
function useProjectList() {
const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST, queryKey: PROJECT_LIST,
queryFn: ({ signal }) => getProjects({ signal }), 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'; 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 { ProjectRundownsList } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_RUNDOWNS } from '../api/constants'; import { PROJECT_RUNDOWNS } from '../api/constants';
import { import {
createRundown, createRundown,
@@ -11,21 +11,24 @@ import {
loadRundown, loadRundown,
renameRundown, renameRundown,
} from '../api/rundown'; } from '../api/rundown';
import { ontimeQueryClient } from '../queryClient';
//TODO: make suspends so we don't have to deal with no value all over
/** /**
* Project rundowns * Project rundowns
*/ */
export function useProjectRundowns() { export function useProjectRundowns() {
const { data, status, isError, refetch, isFetching } = useSuspenseQuery<ProjectRundownsList>({ const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
queryKey: PROJECT_RUNDOWNS, queryKey: PROJECT_RUNDOWNS,
queryFn: ({ signal }) => fetchProjectRundownList({ signal }), 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() { export function useMutateProjectRundowns() {
const ontimeQueryClient = useQueryClient();
const { mutateAsync: create } = useMutation({ const { mutateAsync: create } = useMutation({
mutationFn: createRundown, mutationFn: createRundown,
onMutate: () => { onMutate: () => {
@@ -1,10 +1,10 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { EntryId, Maybe, OntimeEntry, Rundown } from 'ontime-types'; import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
import { useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { getRundownQueryKey } from '../api/constants'; import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
import { fetchRundown } from '../api/rundown'; import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket'; import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns'; import { useProjectRundowns } from './useProjectRundowns';
@@ -20,45 +20,52 @@ const cachedRundownPlaceholder: Rundown = {
}; };
/** /**
* Provides access to a specific rundown by ID. * Normalised rundown data for the currently loaded rundown.
* When rundownId is not provided the loaded rundown is provided *
* Bootstraps via the `/current` alias so the first paint is a single round-trip,
* independent of the project rundown list. Once the loaded id is known, the
* query key swaps to the id-keyed cache that is shared with `useRundownById`.
*/ */
export function useRundown(rundownId: Maybe<string>) { export default function useRundown() {
'use memo'; const queryClient = useQueryClient();
const { const {
data: { loaded: loadedRundownId }, data: { loaded: loadedRundownId },
} = useProjectRundowns(); } = useProjectRundowns();
const effectiveRundownId = rundownId !== null ? rundownId : loadedRundownId; const { data, status, isError, isLoadingError, refetch, isFetching } = useQuery<Rundown>({
const isLoadedRundown = effectiveRundownId === loadedRundownId; queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(effectiveRundownId),
queryFn: ({ signal }) => fetchRundown(effectiveRundownId, { signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, 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>) { export function useRundownWithMetadata() {
'use memo'; const { data, status, isLoadingError } = useRundown();
const { data, status, isLoadedRundown } = useRundown(rundownId);
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? selectedEventId : null; const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
const rundownMetadata = getRundownMetadata(data, effectiveSelectedEventId); return { data, status, isLoadingError, rundownMetadata };
return { data, status, rundownMetadata };
} }
/** /**
* Provides access to a flat rundown * Provides access to a flat rundown
* built from the order and rundown fields * built from the order and rundown fields
*/ */
export function useFlatRundown(rundownId: Maybe<string>) { export function useFlatRundown() {
const { data, status } = useRundown(rundownId); const { data, status, isLoadingError } = useRundown();
const flatRundown = useMemo(() => { const flatRundown = useMemo(() => {
if (data.revision === -1) { 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); return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]); }, [data]);
return { data: flatRundown, rundownId: data.id, status }; return { data: flatRundown, rundownId: data.id, status, isLoadingError };
} }
export function useFlatRundownWithMetadata(rundownId: Maybe<string>) { export function useFlatRundownWithMetadata() {
'use memo'; const { data, status, isLoadingError } = useRundown();
const { data, status, isLoadedRundown } = useRundown(rundownId);
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? selectedEventId : null;
const rundownWithMetadata = getFlatRundownMetadata(data, effectiveSelectedEventId); const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status }; return { data: rundownWithMetadata, status, isLoadingError };
} }
/** /**
@@ -87,8 +92,8 @@ export function useFlatRundownWithMetadata(rundownId: Maybe<string>) {
* re-filtering on every render. * re-filtering on every render.
* *
*/ */
export function usePartialRundown(rundownId: Maybe<string>, cb: (event: ExtendedEntry<OntimeEntry>) => boolean) { export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata(rundownId); const { data, status } = useFlatRundownWithMetadata();
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
return data.filter(cb); return data.filter(cb);
}, [data, 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 * Hook to get a specific entry by ID from the rundown
* @deprecated
*/ */
export function useEntry(rundownId: Maybe<string>, entryId: EntryId | null): OntimeEntry | null { export function useEntry(entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useRundown(rundownId); const { data: rundown } = useRundown();
if (entryId === null) return null; if (entryId === null) return null;
return rundown.entries[entryId] ?? null; return rundown.entries[entryId] ?? null;
} }
export function useRundownAuxData(rundownId: Maybe<string>) { export function useRundownAuxData() {
const { data, status } = useRundown(rundownId); const { data, status } = useRundown();
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
const { title, id } = data; const { title, id } = data;
return { title, id }; return { title, id };
}, [data]); }, [data]);
return { data: filteredData, status }; return { data: filteredData, status };
} }
/**
* Provides access to a specific rundown by ID.
* When rundownId is null/undefined the query is disabled and returns the placeholder.
*/
export function useRundownById(rundownId: string | null | undefined) {
const enabled = Boolean(rundownId);
const { data, status, isError, 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'; import { ontimePlaceholderSettings } from '../models/OntimeSettings';
export default function useSettings() { export default function useSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({
queryKey: APP_SETTINGS, queryKey: APP_SETTINGS,
queryFn: ({ signal }) => getSettings({ signal }), queryFn: ({ signal }) => getSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, 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'; import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() { export default function useViewSettings() {
const { data, status } = useQuery({ const { data, status, isLoadingError } = useQuery({
queryKey: VIEW_SETTINGS, queryKey: VIEW_SETTINGS,
queryFn: ({ signal }) => getViewSettings({ signal }), queryFn: ({ signal }) => getViewSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, 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, eventIndex: 0,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -108,7 +107,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 1, // UI indexes are 1 based eventIndex: 1, // UI indexes are 1 based
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -126,7 +124,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 1, eventIndex: 1,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
isParentToLoaded: true,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -143,7 +140,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 2, eventIndex: 2,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -160,7 +156,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 2, eventIndex: 2,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -177,7 +172,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 3, eventIndex: 3,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: true, isLinkedToLoaded: true,
isLoaded: true, isLoaded: true,
@@ -194,7 +188,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 4, eventIndex: 4,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: true, isLinkedToLoaded: true,
isLoaded: false, isLoaded: false,
@@ -211,7 +204,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 5, eventIndex: 5,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 7, totalGap: 7,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -264,7 +256,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 0, eventIndex: 0,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -282,7 +273,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 1, eventIndex: 1,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -300,7 +290,6 @@ describe('initRundownMetadata()', () => {
eventIndex: 2, eventIndex: 2,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -27,7 +27,6 @@ export type RundownMetadata = {
groupColour: string | undefined; groupColour: string | undefined;
groupEntries: number | undefined; groupEntries: number | undefined;
isFirstAfterGroup: boolean; isFirstAfterGroup: boolean;
isParentToLoaded: boolean; // if the group contains the loaded event
}; };
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata; export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata;
@@ -95,7 +94,6 @@ export function initRundownMetadata(selectedEventId: MaybeString) {
groupColour: undefined, groupColour: undefined,
groupEntries: undefined, groupEntries: undefined,
isFirstAfterGroup: false, isFirstAfterGroup: false,
isParentToLoaded: false,
}; };
function process(entry: OntimeEntry): Readonly<RundownMetadata> { function process(entry: OntimeEntry): Readonly<RundownMetadata> {
@@ -119,7 +117,6 @@ function processEntry(
// initialise data to be overridden below // initialise data to be overridden below
processedData.isNextDay = false; processedData.isNextDay = false;
processedData.isLoaded = false; processedData.isLoaded = false;
processedData.isParentToLoaded = false;
processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration
processedData.thisId = entry.id; // we reassign thisId processedData.thisId = entry.id; // we reassign thisId
@@ -135,7 +132,6 @@ function processEntry(
processedData.groupId = entry.id; processedData.groupId = entry.id;
processedData.groupColour = entry.colour; processedData.groupColour = entry.colour;
processedData.groupEntries = entry.entries.length; processedData.groupEntries = entry.entries.length;
processedData.isParentToLoaded = selectedEventId ? entry.entries.includes(selectedEventId) : false;
} else { } else {
// for delays and groups, we insert the group metadata // for delays and groups, we insert the group metadata
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) { if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) {
+8 -4
View File
@@ -14,6 +14,7 @@ import {
APP_SETTINGS, APP_SETTINGS,
CLIENT_LIST, CLIENT_LIST,
CSS_OVERRIDE, CSS_OVERRIDE,
CURRENT_RUNDOWN_QUERY_KEY,
CUSTOM_FIELDS, CUSTOM_FIELDS,
PROJECT_DATA, PROJECT_DATA,
REPORT, REPORT,
@@ -24,7 +25,6 @@ import {
VIEW_SETTINGS, VIEW_SETTINGS,
getRundownQueryKey, getRundownQueryKey,
PROJECT_RUNDOWNS, PROJECT_RUNDOWNS,
PROJECT_LIST,
} from '../api/constants'; } from '../api/constants';
import { invalidateAllCaches } from '../api/utils'; import { invalidateAllCaches } from '../api/utils';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
@@ -216,9 +216,6 @@ export const connectSocket = () => {
case RefetchKey.ProjectRundowns: case RefetchKey.ProjectRundowns:
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS }); ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS });
break; break;
case RefetchKey.ProjectFiles:
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_LIST });
break;
default: { default: {
target satisfies never; target satisfies never;
break; break;
@@ -241,6 +238,7 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
if (!rundownId) { if (!rundownId) {
// we omit rundownId to signify invalidate all rundowns // we omit rundownId to signify invalidate all rundowns
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
return; return;
} }
@@ -254,6 +252,12 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
} }
ontimeQueryClient.invalidateQueries({ queryKey, exact: true }); ontimeQueryClient.invalidateQueries({ queryKey, exact: true });
// keep current alias in sync with the ID-based cache
const loadedRundownId = ontimeQueryClient.getQueryData<{ loaded: string }>(PROJECT_RUNDOWNS)?.loaded;
if (!loadedRundownId || loadedRundownId === rundownId) {
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}
} }
export function sendSocket<T extends MessageTag | ApiActionTag>( export function sendSocket<T extends MessageTag | ApiActionTag>(
@@ -5,7 +5,7 @@ import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils'; import { createBlob, downloadBlob } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import useReport from '../../../../common/hooks-query/useReport'; import useReport from '../../../../common/hooks-query/useReport';
import { useRundown } from '../../../../common/hooks-query/useRundown'; import useRundown from '../../../../common/hooks-query/useRundown';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import { formatTime } from '../../../../common/utils/time'; import { formatTime } from '../../../../common/utils/time';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -15,7 +15,7 @@ import style from './ReportSettings.module.scss';
export default function ReportSettings() { export default function ReportSettings() {
const { data: reportData } = useReport(); const { data: reportData } = useReport();
const { data } = useRundown(null); const { data } = useRundown();
const clearReport = async () => await deleteAllReport(); const clearReport = async () => await deleteAllReport();
const downloadCSV = (combinedReport: CombinedReport[]) => { const downloadCSV = (combinedReport: CombinedReport[]) => {
@@ -1,5 +1,5 @@
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { Suspense, useState } from 'react'; import { useState } from 'react';
import { import {
IoAdd, IoAdd,
IoDocumentOutline, IoDocumentOutline,
@@ -18,9 +18,9 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Dialog from '../../../../common/components/dialog/Dialog'; import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu'; import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import { useDirectLinkToBackgroundEdit } from '../../../../common/context/RundownSelectionContext';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm'; import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm'; import { ManageRundownForm } from './ManageRundownForm';
@@ -28,20 +28,6 @@ import { ManageRundownForm } from './ManageRundownForm';
import style from './ManagePanel.module.scss'; import style from './ManagePanel.module.scss';
export default function ManageRundowns() { export default function ManageRundowns() {
return (
<Suspense
fallback={
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
}
>
<ManageRundownsSuspense />
</Suspense>
);
}
function ManageRundownsSuspense() {
const { data } = useProjectRundowns(); const { data } = useProjectRundowns();
const { duplicate, remove, load, rename } = useMutateProjectRundowns(); const { duplicate, remove, load, rename } = useMutateProjectRundowns();
const [isOpenDelete, deleteHandlers] = useDisclosure(); const [isOpenDelete, deleteHandlers] = useDisclosure();
@@ -24,7 +24,7 @@ import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info'; import Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../../common/components/modal/Modal'; import Modal from '../../../../../common/components/modal/Modal';
import { useRundown } from '../../../../../common/hooks-query/useRundown'; import useRundown from '../../../../../common/hooks-query/useRundown';
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils'; import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import GSheetSetup from './GSheetSetup'; import GSheetSetup from './GSheetSetup';
@@ -59,7 +59,7 @@ export default function SourcesPanel() {
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null); const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const [completedRundownTitle, setCompletedRundownTitle] = useState(''); const [completedRundownTitle, setCompletedRundownTitle] = useState('');
const { data: currentRundown } = useRundown(null); const { data: currentRundown } = useRundown();
const { applyImport } = useSpreadsheetImport(); const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -1,4 +1,4 @@
import { Suspense, useState } from 'react'; import { useState } from 'react';
import { IoArrowDown, IoArrowUp } from 'react-icons/io5'; import { IoArrowDown, IoArrowUp } from 'react-icons/io5';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
@@ -11,25 +11,11 @@ import style from './ProjectPanel.module.scss';
type SortParameter = 'alphabetical' | 'modified'; type SortParameter = 'alphabetical' | 'modified';
export default function ProjectList() { export default function ProjectList() {
return (
<Suspense
fallback={
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
}
>
<ProjectListSuspend />
</Suspense>
);
}
function ProjectListSuspend() {
const [editingMode, setEditingMode] = useState<EditMode | null>(null); const [editingMode, setEditingMode] = useState<EditMode | null>(null);
const [editingFilename, setEditingFilename] = useState<string | null>(null); const [editingFilename, setEditingFilename] = useState<string | null>(null);
const [sortMode, setSortMode] = useState<ProjectSortMode>('modified-desc'); const [sortMode, setSortMode] = useState<ProjectSortMode>('modified-desc');
const { data, refetch } = useOrderedProjectList(sortMode); const { data, refetch, status } = useOrderedProjectList(sortMode);
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => { const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode)); setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
@@ -52,6 +38,14 @@ function ProjectListSuspend() {
}); });
}; };
if (status === 'pending') {
return (
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
);
}
const numProjects = data.reorderedProjectFiles.length; const numProjects = data.reorderedProjectFiles.length;
return ( return (
+110 -102
View File
@@ -1,6 +1,7 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types'; import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
@@ -10,6 +11,7 @@ import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle'; import { throttle } from '../../common/utils/throttle';
import { getDefaultFormat } from '../../common/utils/time'; import { getDefaultFormat } from '../../common/utils/time';
import { isTouchDevice } from '../../externals'; import { isTouchDevice } from '../../externals';
import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../../views/common/loader/Loader'; import Loader from '../../views/common/loader/Loader';
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal'; import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
import FollowButton from './follow-button/FollowButton'; import FollowButton from './follow-button/FollowButton';
@@ -35,7 +37,7 @@ export default function OperatorLoader() {
} }
if (status === 'error') { 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} />; return <Operator {...data} />;
@@ -43,6 +45,7 @@ export default function OperatorLoader() {
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) { function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const { getLocalizedString } = useTranslation();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions(); const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
const [showEditPrompt, setShowEditPrompt] = useState(false); 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 operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
const canEdit = shouldEdit && subscribe.length; const canEdit = shouldEdit && subscribe.length;
const hasEvents = rundown.order.length > 0;
return ( return (
<div className={style.operatorContainer} data-testid='operator-view'> <div className={style.operatorContainer} data-testid='operator-view'>
@@ -127,117 +131,121 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
</div> </div>
)} )}
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> {!hasEvents ? (
{rundown.order.map((entryId) => { <EmptyFill text={getLocalizedString('common.no_data')} />
const entry = rundown.entries[entryId]; ) : (
if (isOntimeEvent(entry)) { <div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId]; {rundown.order.map((entryId) => {
// hide past events (if setting) and skipped events const entry = rundown.entries[entryId];
if ((hidePast && isPast) || entry.skip) { if (isOntimeEvent(entry)) {
return null; 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( const { mainField, secondaryField, subscribedData } = getEventData(
entry, entry,
mainSource, mainSource,
secondarySource, secondarySource,
subscribe, subscribe,
customFields, customFields,
); );
return ( return (
<OperatorEvent <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
key={entry.id} key={entry.id}
title={entry.title} id={entry.id}
colour={entry.colour} colour={entry.colour}
count={entry.entries.length} cue={entry.cue}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
duration={entry.duration} 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 const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
if ((hidePast && isPast) || nestedEntry.skip) {
return null;
}
const { mainField, secondaryField, subscribedData } = getEventData( if (hidePast && isPast && !isCurrentParent) {
nestedEntry, return null;
mainSource, }
secondarySource,
subscribe,
customFields,
);
return ( return (
<OperatorEvent <Fragment key={entry.id}>
key={nestedEntry.id} <OperatorGroup
id={nestedEntry.id} key={entry.id}
colour={nestedEntry.colour} title={entry.title}
cue={nestedEntry.cue} colour={entry.colour}
main={mainField} count={entry.entries.length}
secondary={secondaryField} duration={entry.duration}
timeStart={nestedEntry.timeStart} />
duration={nestedEntry.duration} {entry.entries.map((nestedEntryId) => {
delay={nestedEntry.delay} const nestedEntry = rundown.entries[nestedEntryId];
dayOffset={nestedEntry.dayOffset} if (!isOntimeEvent(nestedEntry)) {
isLinkedToLoaded={isLinkedToLoaded} return null;
isSelected={isLoaded} }
isPast={isPast}
groupColour={entry.colour} const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart} // hide past events (if setting) and skipped events
subscribed={subscribedData} if ((hidePast && isPast) || nestedEntry.skip) {
totalGap={totalGap} return null;
onLongPress={canEdit ? handleEdit : () => undefined} }
/>
); const { mainField, secondaryField, subscribedData } = getEventData(
})} nestedEntry,
</Fragment> mainSource,
); secondarySource,
} subscribe,
return null; customFields,
})} );
</div>
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} /> <FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
</div> </div>
); );
@@ -14,9 +14,18 @@ export interface OperatorData {
} }
export function useOperatorData(): ViewData<OperatorData> { export function useOperatorData(): ViewData<OperatorData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata(null); const {
const { data: customFields, status: customFieldStatus } = useCustomFields(); data: rundown,
const { data: settings, status: settingsStatus } = useSettings(); rundownMetadata,
status: rundownStatus,
isLoadingError: rundownIsLoadingError,
} = useRundownWithMetadata();
const {
data: customFields,
status: customFieldStatus,
isLoadingError: customFieldIsLoadingError,
} = useCustomFields();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
return { return {
data: { data: {
@@ -25,6 +34,10 @@ export function useOperatorData(): ViewData<OperatorData> {
customFields, customFields,
settings, 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 { ErrorBoundary } from '@sentry/react';
import { PropsWithChildren, ReactNode, Suspense } from 'react'; import { PropsWithChildren, ReactNode } from 'react';
import ScrollArea from '../../common/components/scroll-area/ScrollArea'; import ScrollArea from '../../common/components/scroll-area/ScrollArea';
import { useIsOnline } from '../../common/hooks/useSocket'; import { useIsOnline } from '../../common/hooks/useSocket';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import Loader from '../../views/common/loader/Loader';
import style from './Overview.module.scss'; import style from './Overview.module.scss';
@@ -16,37 +15,18 @@ export function OverviewWrapper({ navElements, children }: PropsWithChildren<Ove
const isOnline = useIsOnline(); const isOnline = useIsOnline();
return ( return (
<Suspense fallback={<OverviewFallback navElements={navElements} />}> <div className={cx([style.overview, !isOnline && style.isOffline])}>
<div className={cx([style.overview, !isOnline && style.isOffline])}> <ErrorBoundary>
<ErrorBoundary> <div className={style.nav}>{navElements}</div>
<div className={style.nav}>{navElements}</div> <ScrollArea
<ScrollArea className={style.infoScroll}
className={style.infoScroll} contentClassName={style.info}
contentClassName={style.info} contentStyle={{ minWidth: '100%' }}
contentStyle={{ minWidth: '100%' }} orientation='horizontal'
orientation='horizontal' >
> {children}
{children} </ScrollArea>
</ScrollArea> </ErrorBoundary>
</ErrorBoundary>
</div>
</Suspense>
);
}
function OverviewFallback({ navElements }: OverviewWrapperProps) {
return (
<div className={style.overview}>
<div className={style.nav}>{navElements}</div>
<ScrollArea
className={style.infoScroll}
contentClassName={style.info}
contentStyle={{ minWidth: '100%' }}
orientation='horizontal'
>
{/* TODO: this could be alined in a nicer way */}
<Loader />
</ScrollArea>
</div> </div>
); );
} }
@@ -210,7 +210,7 @@ export function MetadataTimes() {
function GroupTimes() { function GroupTimes() {
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView(); const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
const currentGroupId = useCurrentGroupId(); const currentGroupId = useCurrentGroupId();
const group = useEntry(null, currentGroupId) as OntimeGroup | null; const group = useEntry(currentGroupId) as OntimeGroup | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback); const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -266,7 +266,7 @@ function GroupTimes() {
function FlagTimes() { function FlagTimes() {
const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView(); const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView();
const { id, expectedStart } = useNextFlag(); const { id, expectedStart } = useNextFlag();
const entry = useEntry(null, id) as OntimeEvent | null; const entry = useEntry(id) as OntimeEvent | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback); const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -6,7 +6,7 @@ import style from './TitleOverview.module.scss';
export default function TitleOverview() { export default function TitleOverview() {
'use memo'; 'use memo';
const { data: projectData } = useProjectData(); const { data: projectData } = useProjectData();
const { data: rundownData } = useRundownAuxData(null); const { data: rundownData } = useRundownAuxData();
if (!projectData.title && !rundownData.title) { if (!projectData.title && !rundownData.title) {
return null; return null;
@@ -6,7 +6,8 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext'; import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { 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 { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils'; import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
@@ -38,29 +39,28 @@ function RundownExport() {
defaultValue: RundownViewMode.List, defaultValue: RundownViewMode.List,
}); });
const isSmallDevice = useIsSmallDevice(); const isSmallDevice = useIsSmallDevice();
const entryActions = useEntryActions();
if (isSmallDevice && isExtracted) { if (isSmallDevice && isExtracted) {
return ( return (
<RundownSelectionContextProvider> <EntryActionsProvider actions={entryActions}>
<EntryActionsProvider> <ProtectRoute permission='editor'>
<ProtectRoute permission='editor'> <div
<div className={cx([style.rundownExport, style.extracted])}
className={cx([style.rundownExport, style.extracted])} data-target='small-device'
data-target='small-device' data-testid='panel-rundown'
data-testid='panel-rundown' >
> <FinderPlacement />
<FinderPlacement /> <ViewNavigationMenu suppressSettings />
<ViewNavigationMenu suppressSettings /> <div className={style.rundown}>
<div className={style.rundown}> <ErrorBoundary>
<ErrorBoundary> <RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} /> <RundownContextMenu />
<RundownContextMenu /> </ErrorBoundary>
</ErrorBoundary>
</div>
</div> </div>
</ProtectRoute> </div>
</EntryActionsProvider> </ProtectRoute>
</RundownSelectionContextProvider> </EntryActionsProvider>
); );
} }
@@ -70,32 +70,30 @@ function RundownExport() {
viewMode === RundownViewMode.Table; viewMode === RundownViewMode.Table;
return ( return (
<RundownSelectionContextProvider> <EntryActionsProvider actions={entryActions}>
<EntryActionsProvider> <ProtectRoute permission='editor'>
<ProtectRoute permission='editor'> <div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'> <FinderPlacement />
<FinderPlacement /> {isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />} <div className={style.rundown}>
<div className={style.rundown}> <Editor.Panel className={style.list}>
<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> <ErrorBoundary>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />} <RundownEntryEditor />
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary> </ErrorBoundary>
</Editor.Panel> </div>
{!hideSideBar && ( )}
<div className={style.side}>
<ErrorBoundary>
<RundownEntryEditor />
</ErrorBoundary>
</div>
)}
</div>
</div> </div>
</ProtectRoute> </div>
</EntryActionsProvider> </ProtectRoute>
</RundownSelectionContextProvider> </EntryActionsProvider>
); );
} }
@@ -107,16 +105,17 @@ interface RundownRootProps {
} }
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) { function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
const source = useLoadedRundownSource();
return ( return (
<div className={style.rundownRoot}> <div className={style.rundownRoot}>
{isSmallDevice ? ( {isSmallDevice ? (
<RundownHeaderMobile viewMode={viewMode} setViewMode={setViewMode} /> <RundownHeaderMobile viewMode={viewMode} setViewMode={setViewMode} />
) : ( ) : (
// TODO: add data-background-rundown={!isLoadedRundown} styling
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} /> <RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
)} )}
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />} {viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
{viewMode === RundownViewMode.Table && <EntryEditModal />} {viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />}
<RenumberCuesDialog /> <RenumberCuesDialog />
</div> </div>
); );
@@ -1,36 +1,34 @@
import { Playback } from 'ontime-types';
import { memo } from 'react'; import { memo } from 'react';
import Empty from '../../common/components/state/Empty'; import EmptyFill from '../../common/components/state/EmptyFill';
import { useContextRundownList } from '../../common/hooks-query/useContextRundown'; import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useRundownEditor } from '../../common/hooks/useSocket'; import { useRundownEditor } from '../../common/hooks/useSocket';
import { useTranslation } from '../../translation/TranslationProvider';
import Rundown from './Rundown'; import Rundown from './Rundown';
const backgroundFeatureData = {
playback: Playback.Stop,
selectedEventId: null,
nextEventId: null,
};
export default memo(RundownList); export default memo(RundownList);
function RundownList() { function RundownList() {
const { rundown, status, rundownMetadata, isLoadedRundown } = useContextRundownList(); const { data, status, isLoadingError, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor(); 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) { if (isLoadingError) {
return <Empty text='Connecting to server' />; return <EmptyFill text={getLocalizedString('common.no_data')} />;
} }
return ( return (
<Rundown <Rundown
order={rundown.order} order={data.order}
flatOrder={rundown.flatOrder} flatOrder={data.flatOrder}
entries={rundown.entries} entries={data.entries}
id={rundown.id} id={data.id}
rundownMetadata={rundownMetadata} 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 { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useRundown } from '../../../common/hooks-query/useRundown'; import useRundown from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
import EventEditorFooter from './composite/EventEditorFooter'; import EventEditorFooter from './composite/EventEditorFooter';
import EventEditor from './EventEditor'; import EventEditor from './EventEditor';
@@ -13,7 +13,7 @@ import style from './EntryEditor.module.scss';
export default function RundownEntryEditor() { export default function RundownEntryEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown(null); const { data } = useRundown();
const entry = useMemo<OntimeEntry | null>(() => { const entry = useMemo<OntimeEntry | null>(() => {
if (data.order.length === 0) { if (data.order.length === 0) {
@@ -7,7 +7,7 @@ import Button from '../../../common/components/buttons/Button';
import Dialog from '../../../common/components/dialog/Dialog'; import Dialog from '../../../common/components/dialog/Dialog';
import Input from '../../../common/components/input/input/Input'; import Input from '../../../common/components/input/input/Input';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextRundownCueRenumberModal } from '../../../common/hooks-query/useContextRundown'; import useRundown from '../../../common/hooks-query/useRundown';
import { orderEntries } from '../rundown.utils'; import { orderEntries } from '../rundown.utils';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
@@ -17,7 +17,8 @@ type RenumberCueData = Pick<RenumberCues, 'increment' | 'prefix' | 'start'>;
export default function RenumberCuesDialog() { export default function RenumberCuesDialog() {
'use memo'; 'use memo';
const { flatOrder } = useContextRundownCueRenumberModal(); const { data } = useRundown();
const { flatOrder } = data;
const { onClose, isOpen } = useRenumberCuesDialogStore(); const { onClose, isOpen } = useRenumberCuesDialogStore();
const { renumberCues } = useEntryActionsContext(); const { renumberCues } = useEntryActionsContext();
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
@@ -8,7 +8,6 @@ import Tooltip from '../../../common/components/tooltip/Tooltip';
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket'; import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
import { AppMode } from '../../../ontimeConfig'; import { AppMode } from '../../../ontimeConfig';
import { EditorLayoutMode, useEditorLayout } from '../../../views/editor/useEditorLayout'; import { EditorLayoutMode, useEditorLayout } from '../../../views/editor/useEditorLayout';
import { RundownSelect } from '../common/RundownSelect';
import { RundownViewMode } from '../rundown.options'; import { RundownViewMode } from '../rundown.options';
import { useEditorFollowMode } from '../useEditorFollowMode'; import { useEditorFollowMode } from '../useEditorFollowMode';
import RundownMenu from './RundownMenu'; import RundownMenu from './RundownMenu';
@@ -25,7 +24,6 @@ interface HeaderControlsConfig {
showRunEditToggle: boolean; showRunEditToggle: boolean;
showOffsetToggle: boolean; showOffsetToggle: boolean;
showOverflowMenu: boolean; showOverflowMenu: boolean;
showRundownSelect: boolean;
} }
export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConfig> = { export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConfig> = {
@@ -33,19 +31,16 @@ export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConf
showRunEditToggle: true, showRunEditToggle: true,
showOffsetToggle: true, showOffsetToggle: true,
showOverflowMenu: true, showOverflowMenu: true,
showRundownSelect: false,
}, },
[EditorLayoutMode.PLANNING]: { [EditorLayoutMode.PLANNING]: {
showRunEditToggle: false, showRunEditToggle: false,
showOffsetToggle: false, showOffsetToggle: false,
showOverflowMenu: true, showOverflowMenu: true,
showRundownSelect: true,
}, },
[EditorLayoutMode.TRACKING]: { [EditorLayoutMode.TRACKING]: {
showRunEditToggle: false, showRunEditToggle: false,
showOffsetToggle: true, showOffsetToggle: true,
showOverflowMenu: false, showOverflowMenu: false,
showRundownSelect: false,
}, },
} as const; } as const;
@@ -55,8 +50,7 @@ function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProp
const offsetMode = useOffsetMode(); const offsetMode = useOffsetMode();
const { layoutMode } = useEditorLayout(); const { layoutMode } = useEditorLayout();
const { showRunEditToggle, showOffsetToggle, showOverflowMenu, showRundownSelect } = const { showRunEditToggle, showOffsetToggle, showOverflowMenu } = HEADER_CONTROLS_CONFIG[layoutMode];
HEADER_CONTROLS_CONFIG[layoutMode];
const toggleAppMode = (mode: AppMode[]) => { const toggleAppMode = (mode: AppMode[]) => {
// we need to stop user from deselecting a mode // we need to stop user from deselecting a mode
@@ -128,8 +122,6 @@ function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProp
</ToggleGroup> </ToggleGroup>
)} )}
{showRundownSelect && <RundownSelect appMode={editorMode} />}
{showOverflowMenu && <RundownMenu allowNavigation={!isExtracted} />} {showOverflowMenu && <RundownMenu allowNavigation={!isExtracted} />}
</Toolbar.Root> </Toolbar.Root>
); );
@@ -1,7 +1,9 @@
import { memo, useEffect, useMemo } from 'react'; 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 useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd'; import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable'; import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions'; import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
@@ -10,9 +12,11 @@ import { makeRundownColumns } from './makeRundownColumns';
export default memo(RundownTable); export default memo(RundownTable);
function RundownTable() { function RundownTable() {
const { data: customFields, status: customFieldStatus } = useCustomFields(); const { data: customFields } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions); const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode(); const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource();
const actions = useEntryActions();
// Editor always has full permissions // Editor always has full permissions
useEffect(() => { useEffect(() => {
@@ -27,15 +31,11 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]); const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
const isLoading = !customFields || customFieldStatus === 'pending';
return ( return (
<CuesheetDnd columns={columns} tableRoot='editor'> <EntryActionsProvider actions={actions}>
{isLoading ? ( <CuesheetDnd columns={columns} tableRoot='editor'>
<EmptyPage text='Loading...' /> <CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
) : ( </CuesheetDnd>
<CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' /> </EntryActionsProvider>
)}
</CuesheetDnd>
); );
} }
@@ -35,7 +35,7 @@ export default function BackstageLoader() {
} }
if (status === 'error') { 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} />; return <Backstage {...data} />;
@@ -20,10 +20,14 @@ export function useBackstageData(): ViewData<BackstageData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundown(null); const { data: rundownData, status: rundownStatus, isLoadingError: rundownIsLoadingError } = useFlatRundown();
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields(); const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
return { return {
data: { data: {
@@ -33,6 +37,11 @@ export function useBackstageData(): ViewData<BackstageData> {
isMirrored, isMirrored,
settings, 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; display: grid;
place-items: center; place-items: center;
background-color: var(--background-color-override, $viewer-background-color); background-color: var(--background-color-override, $viewer-background-color);
height: 100vh; height: 100dvh;
} }
.ellipsis { .ellipsis {
@@ -21,26 +21,26 @@ $dot-spacing: 1.5rem;
height: $dot-size; height: $dot-size;
border-radius: 50%; border-radius: 50%;
background-color: var(--accent-color-override, $ontime-color); 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) { &:nth-child(1) {
left: $dot-size; left: $dot-size;
animation: lds-ellipsis1 0.6s infinite; animation: lds-ellipsis1 1s infinite;
} }
&:nth-child(2) { &:nth-child(2) {
left: $dot-size; left: $dot-size;
animation: lds-ellipsis2 0.6s infinite; animation: lds-ellipsis2 1s infinite;
} }
&:nth-child(3) { &:nth-child(3) {
left: calc($dot-size + $dot-spacing); left: calc($dot-size + $dot-spacing);
animation: lds-ellipsis2 0.6s infinite; animation: lds-ellipsis2 1s infinite;
} }
&:nth-child(4) { &:nth-child(4) {
left: calc($dot-size + 2 * $dot-spacing); 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], [filter],
); );
const { data: events } = usePartialRundown(null, filterCallback); const { data: events } = usePartialRundown(filterCallback);
const [firstIndex, setFirstIndex] = useState(-1); const [firstIndex, setFirstIndex] = useState(-1);
const [numPages, setNumPages] = useState(0); const [numPages, setNumPages] = useState(0);
+1 -15
View File
@@ -80,21 +80,7 @@ $item-height: 3.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 1.5rem;
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;
}
} }
.list-container { .list-container {
@@ -43,7 +43,7 @@ export default function CountdownLoader() {
} }
if (status === 'error') { 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} />; return <Countdown {...data} />;
@@ -87,7 +87,7 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
{!hasEvents && ( {!hasEvents && (
<div className='empty-state'> <div className='empty-state'>
<Empty text={getLocalizedString('common.no_data')} className='empty-state__content' /> <Empty text={getLocalizedString('common.no_data')} />
</div> </div>
)} )}
@@ -121,7 +121,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscriptions.length === 0) { if (subscriptions.length === 0) {
return ( return (
<div className='empty-state'> <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}> <Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add <IoAdd /> Add
</Button> </Button>
@@ -137,7 +137,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscribedEvents.length === 0) { if (subscribedEvents.length === 0) {
return ( return (
<div className='empty-state'> <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}> <Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add <IoAdd /> Add
</Button> </Button>
@@ -154,7 +154,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (eventsToShow.length === 0) { if (eventsToShow.length === 0) {
return ( return (
<div className='empty-state'> <div className='empty-state'>
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-state__content' /> <Empty text={getLocalizedString('countdown.all_have_finished')} />
</div> </div>
); );
} }
@@ -21,10 +21,18 @@ export function useCountdownData(): ViewData<CountdownData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata(null); const {
const { data: projectData, status: projectDataStatus } = useProjectData(); data: rundownData,
const { data: settings, status: settingsStatus } = useSettings(); status: rundownStatus,
const { data: customFields, status: customFieldsStatus } = useCustomFields(); 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 { return {
data: { data: {
@@ -34,6 +42,11 @@ export function useCountdownData(): ViewData<CountdownData> {
isMirrored, isMirrored,
settings, 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'; 'table';
color: $ui-white; 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 IconButton from '../../common/components/buttons/IconButton';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu'; import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext'; import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { 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 { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { getIsNavigationLocked } from '../../externals'; import { getIsNavigationLocked } from '../../externals';
import CuesheetOverview from '../../features/overview/CuesheetOverview'; import CuesheetOverview from '../../features/overview/CuesheetOverview';
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal'; import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress'; import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import CuesheetTableWrapper from './CuesheetTableWrapper'; import CuesheetTableWrapper from './CuesheetTableWrapper';
import { FOLLOW_LOADED_RUNDOWN_ID, useCuesheetRundownSelection } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss'; import styles from './CuesheetPage.module.scss';
export default function CuesheetPage() { export default function CuesheetPage() {
'use memo'; 'use memo';
const [isMenuOpen, menuHandler] = useDisclosure(); const [isMenuOpen, menuHandler] = useDisclosure();
const { selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = useCuesheetRundownSelection();
const source = useScopedRundown(selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? loadedRundownId : selectedRundownId);
const actions = useScopedEntryActions(source.rundownId);
useWindowTitle('Cuesheet'); useWindowTitle('Cuesheet');
const isLocked = getIsNavigationLocked(); const isLocked = getIsNavigationLocked();
return ( return (
<RundownSelectionContextProvider> <EntryActionsProvider actions={actions}>
<EntryActionsProvider> <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} /> <EntryEditModal rundown={source.rundown} />
<EntryEditModal /> <div className={styles.tableWrapper} data-testid='cuesheet'>
<div className={styles.tableWrapper} data-testid='cuesheet'> <CuesheetOverview>
<CuesheetOverview> {!isLocked && (
{!isLocked && ( <IconButton aria-label='Toggle navigation' variant='subtle-white' size='xlarge' onClick={menuHandler.open}>
<IconButton <IoApps />
aria-label='Toggle navigation' </IconButton>
variant='subtle-white' )}
size='xlarge' </CuesheetOverview>
onClick={menuHandler.open} <CuesheetProgress />
> <CuesheetTableWrapper
<IoApps /> source={source}
</IconButton> selectedRundownId={selectedRundownId}
)} loadedRundownId={loadedRundownId}
</CuesheetOverview> setSelectedRundownId={setSelectedRundownId}
<CuesheetProgress /> projectRundowns={projectRundowns}
<CuesheetTableWrapper /> />
</div> </div>
</EntryActionsProvider> </EntryActionsProvider>
</RundownSelectionContextProvider>
); );
} }
@@ -1,41 +1,108 @@
import { MaybeString, ProjectRundown } from 'ontime-types';
import { memo, use, useMemo } from 'react'; 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 { PresetContext } from '../../common/context/PresetContext';
import { useRundownSelectionContext } from '../../common/context/RundownSelectionContext';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
import { AppMode } from '../../ontimeConfig';
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd'; import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory'; import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import CuesheetTable from './cuesheet-table/CuesheetTable'; import CuesheetTable from './cuesheet-table/CuesheetTable';
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy'; import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
import { FOLLOW_LOADED_RUNDOWN_ID } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss';
interface CuesheetTableWrapperProps {
source: RundownSource;
selectedRundownId: MaybeString;
loadedRundownId: string;
setSelectedRundownId: (rundownId: string) => void;
projectRundowns: ProjectRundown[];
}
export default memo(CuesheetTableWrapper); export default memo(CuesheetTableWrapper);
function CuesheetTableWrapper() { function CuesheetTableWrapper({
source,
selectedRundownId,
setSelectedRundownId,
loadedRundownId,
projectRundowns,
}: CuesheetTableWrapperProps) {
const preset = use(PresetContext); const preset = use(PresetContext);
const { isLoadedRundown } = useRundownSelectionContext(); const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isLoadedRundown }); const { data: customFields } = useCustomFields();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const columns = useMemo( const columns = useMemo(
() => makeCuesheetColumns(customFields, cuesheetMode, preset), () => makeCuesheetColumns(customFields, cuesheetMode, preset),
[customFields, cuesheetMode, preset], [customFields, cuesheetMode, preset],
); );
const isLoading = !customFields || customFieldStatus === 'pending';
return ( return (
<CuesheetDnd columns={columns}> <CuesheetDnd columns={columns}>
{isLoading ? ( <CuesheetTable
<EmptyPage text='Loading...' /> columns={columns}
) : ( source={source}
<CuesheetTable cuesheetMode={cuesheetMode}
columns={columns} tableRoot='cuesheet'
cuesheetMode={cuesheetMode} setCuesheetMode={setCuesheetMode}
tableRoot='cuesheet' isCurrentRundown={isCurrentRundown}
setCuesheetMode={setCuesheetMode} insertElement={
/> <>
)} <RundownSelect
cuesheetMode={cuesheetMode}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</>
}
/>
</CuesheetDnd> </CuesheetDnd>
); );
} }
interface RundownSelectProps {
cuesheetMode: AppMode;
selectedRundownId: MaybeString;
loadedRundownId: string;
setSelectedRundownId: (rundownId: string) => void;
projectRundowns: ProjectRundown[];
}
function RundownSelect({
cuesheetMode,
projectRundowns,
loadedRundownId,
selectedRundownId,
setSelectedRundownId,
}: RundownSelectProps) {
'use memo';
const options = projectRundowns.map(({ id, title }) => ({
value: id,
label: loadedRundownId === id ? `${title} (loaded)` : title,
}));
options.unshift({
value: FOLLOW_LOADED_RUNDOWN_ID,
label: 'Follow loaded', // TODO: Better wording and maybe icon? and translation
});
return (
<div className={styles.rundownSelect}>
<Select
value={selectedRundownId ?? undefined}
options={options}
onValueChange={(value) => {
if (value) {
setSelectedRundownId(value);
}
}}
disabled={cuesheetMode === AppMode.Run}
fluid
/>
</div>
);
}
@@ -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 { memo } from 'react';
import Modal from '../../../common/components/modal/Modal'; import Modal from '../../../common/components/modal/Modal';
import { useContextRundownEditModal } from '../../../common/hooks-query/useContextRundown';
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor'; import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
import { useEditModal } from './useEditModal'; import { useEditModal } from './useEditModal';
interface EntryEditModalProps {
rundown: Rundown;
}
export default memo(EntryEditModal); export default memo(EntryEditModal);
function EntryEditModal() { function EntryEditModal({ rundown }: EntryEditModalProps) {
const { rundown } = useContextRundownEditModal();
const entryId = useEditModal((state) => state.selectedEntryId); const entryId = useEditModal((state) => state.selectedEntryId);
const closeModal = useEditModal((state) => state.clearSelection); const closeModal = useEditModal((state) => state.clearSelection);
@@ -5,6 +5,10 @@ $table-header-font-size: calc(1rem - 2px);
@include rows.cuesheet-row-columns($table-header-font-size); @include rows.cuesheet-row-columns($table-header-font-size);
.tableLoading {
grid-area: table;
}
.cuesheet { .cuesheet {
font-size: $table-font-size; font-size: $table-font-size;
font-weight: 400; font-weight: 400;
@@ -1,7 +1,7 @@
import { useTableNav } from '@table-nav/react'; import { useTableNav } from '@table-nav/react';
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react'; import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { import {
ContextProp, ContextProp,
ItemProps, ItemProps,
@@ -11,14 +11,15 @@ import {
TableVirtuosoHandle, TableVirtuosoHandle,
} from 'react-virtuoso'; } 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 EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; 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 type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options'; import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import { useEventSelection } from '../../../features/rundown/useEventSelection'; import { useEventSelection } from '../../../features/rundown/useEventSelection';
import { AppMode } from '../../../ontimeConfig'; import { AppMode } from '../../../ontimeConfig';
import { useTranslation } from '../../../translation/TranslationProvider';
import { usePersistedCuesheetOptions } from '../cuesheet.options'; import { usePersistedCuesheetOptions } from '../cuesheet.options';
import { useCuesheetPermissions } from '../useTablePermissions'; import { useCuesheetPermissions } from '../useTablePermissions';
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader'; import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
@@ -35,23 +36,36 @@ import style from './CuesheetTable.module.scss';
type CuesheetTableBaseProps = { type CuesheetTableBaseProps = {
columns: ColumnDef<ExtendedEntry>[]; columns: ColumnDef<ExtendedEntry>[];
cuesheetMode: AppMode; cuesheetMode: AppMode;
source: RundownSource;
insertElement?: ReactNode;
}; };
type EditorCuesheetTableProps = CuesheetTableBaseProps & { type EditorCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'editor'; tableRoot: 'editor';
setCuesheetMode?: undefined; setCuesheetMode?: undefined;
isCurrentRundown?: undefined;
}; };
type ViewCuesheetTableProps = CuesheetTableBaseProps & { type ViewCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'cuesheet'; tableRoot: 'cuesheet';
setCuesheetMode: (mode: AppMode) => void; setCuesheetMode: (mode: AppMode) => void;
isCurrentRundown?: boolean;
}; };
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps; type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCuesheetMode }: CuesheetTableProps) { export default function CuesheetTable({
const { flatRundown, status, loadedEventId } = useContextRundownTable(); columns,
cuesheetMode,
source,
tableRoot,
setCuesheetMode,
isCurrentRundown,
insertElement,
}: CuesheetTableProps) {
const { flatRundown, status, isLoadingError, selectedEventId } = source;
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext(); const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const { getLocalizedString } = useTranslation();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit; const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions; 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 // in Run mode, follow the current event
useEffect(() => { useEffect(() => {
if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !loadedEventId) { if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !selectedEventId) {
return; return;
} }
const eventIndex = flatRundown.findIndex((event) => event.id === loadedEventId); const eventIndex = flatRundown.findIndex((event) => event.id === selectedEventId);
if (eventIndex === -1) { if (eventIndex === -1) {
return; return;
} }
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 }); virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
}, [cuesheetMode, flatRundown, loadedEventId]); }, [cuesheetMode, flatRundown, selectedEventId]);
// Provide an imperative scroll handler for explicit jumps (finder/keyboard) // Provide an imperative scroll handler for explicit jumps (finder/keyboard)
useEffect(() => { useEffect(() => {
@@ -216,10 +230,13 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
}); });
}, [cuesheetMode, hideIndexColumn, table]); }, [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) { if (isLoadingError) {
return <EmptyPage text='Loading...' />; return <EmptyFill text={getLocalizedString('common.no_data')} className={style.tableLoading} />;
} }
return ( return (
@@ -230,9 +247,17 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
handleResetResizing={resetColumnResizing} handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder} handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible} handleClearToggles={setAllVisible}
appMode={cuesheetMode} insertElement={insertElement}
tableRoot={tableRoot} modeControls={
setCuesheetMode={setCuesheetMode} tableRoot === 'cuesheet'
? {
cuesheetMode,
setCuesheetMode,
isCurrentRundown,
}
: undefined
}
showShare={tableRoot === 'cuesheet'}
/> />
<TableVirtuoso <TableVirtuoso
ref={virtuosoRef} ref={virtuosoRef}
@@ -10,9 +10,7 @@ import Button from '../../../../common/components/buttons/Button';
import Checkbox from '../../../../common/components/checkbox/Checkbox'; import Checkbox from '../../../../common/components/checkbox/Checkbox';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import PopoverContents from '../../../../common/components/popover/Popover'; import PopoverContents from '../../../../common/components/popover/Popover';
import { useRundownSelectionContext } from '../../../../common/context/RundownSelectionContext';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata'; import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { RundownSelect } from '../../../../features/rundown/common/RundownSelect';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetPermissions } from '../../useTablePermissions'; import { useCuesheetPermissions } from '../../useTablePermissions';
import CuesheetShareModal from './CuesheetShareModal'; import CuesheetShareModal from './CuesheetShareModal';
@@ -32,15 +30,21 @@ type TableHeaderOptionValues = Pick<
'hideTableSeconds' | 'hideIndexColumn' | 'showDelayedTimes' | 'hideDelays' 'hideTableSeconds' | 'hideIndexColumn' | 'showDelayedTimes' | 'hideDelays'
>; >;
type TableModeControls = {
cuesheetMode: AppMode;
setCuesheetMode: (mode: AppMode) => void;
isCurrentRundown?: boolean;
};
interface CuesheetTableHeaderToolbarProps { interface CuesheetTableHeaderToolbarProps {
columns: Column<ExtendedEntry, unknown>[]; columns: Column<ExtendedEntry, unknown>[];
optionsStore: TableHeaderOptionsStore; optionsStore: TableHeaderOptionsStore;
handleResetResizing: () => void; handleResetResizing: () => void;
handleResetReordering: () => void; handleResetReordering: () => void;
handleClearToggles: () => void; handleClearToggles: () => void;
setCuesheetMode?: (mode: AppMode) => void; insertElement?: ReactNode;
appMode: AppMode; modeControls?: TableModeControls;
tableRoot: 'editor' | 'cuesheet'; showShare?: boolean;
} }
export default function CuesheetTableHeaderToolbar({ export default function CuesheetTableHeaderToolbar({
@@ -49,23 +53,23 @@ export default function CuesheetTableHeaderToolbar({
handleResetResizing, handleResetResizing,
handleResetReordering, handleResetReordering,
handleClearToggles, handleClearToggles,
setCuesheetMode, insertElement,
tableRoot, modeControls,
appMode, showShare = false,
}: CuesheetTableHeaderToolbarProps) { }: CuesheetTableHeaderToolbarProps) {
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode) && tableRoot === 'cuesheet'; const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode);
const canShare = useCuesheetPermissions((state) => state.canShare) && tableRoot === 'cuesheet'; const canShare = useCuesheetPermissions((state) => state.canShare);
const showRundownSelect = tableRoot === 'cuesheet';
const { isLoadedRundown } = useRundownSelectionContext();
const toggleCuesheetMode = (mode: AppMode[]) => { const toggleCuesheetMode = (mode: AppMode[]) => {
const newValue = mode.at(0); const newValue = mode.at(0);
if (!newValue || !setCuesheetMode) return; if (!newValue || !modeControls) return;
setCuesheetMode(newValue); modeControls.setCuesheetMode(newValue);
}; };
const isBackground = !(modeControls?.isCurrentRundown ?? true);
return ( return (
<Toolbar.Root className={style.tableSettings} data-background-rundown={!isLoadedRundown}> <Toolbar.Root className={style.tableSettings} data-background-rundown={isBackground}>
<ViewSettings optionsStore={optionsStore} /> <ViewSettings optionsStore={optionsStore} />
<ColumnSettings <ColumnSettings
columns={columns} columns={columns}
@@ -73,14 +77,14 @@ export default function CuesheetTableHeaderToolbar({
handleResetReordering={handleResetReordering} handleResetReordering={handleResetReordering}
handleClearToggles={handleClearToggles} handleClearToggles={handleClearToggles}
/> />
<div className={style.apart}> {modeControls && canChangeMode && (
{showRundownSelect && <RundownSelect appMode={appMode} />} <div className={style.apart}>
{canChangeMode && ( {insertElement}
<ToggleGroup <ToggleGroup
value={[appMode]} value={[modeControls.cuesheetMode]}
onValueChange={toggleCuesheetMode} onValueChange={toggleCuesheetMode}
className={style.group} className={style.group}
disabled={!isLoadedRundown} disabled={!modeControls.isCurrentRundown}
> >
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}> <Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
Run Run
@@ -89,10 +93,10 @@ export default function CuesheetTableHeaderToolbar({
Edit Edit
</Toolbar.Button> </Toolbar.Button>
</ToggleGroup> </ToggleGroup>
)} </div>
</div> )}
{canShare && ( {showShare && canShare && (
<> <>
<Editor.Separator orientation='vertical' /> <Editor.Separator orientation='vertical' />
<CuesheetShareModal /> <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 { lazy } from 'react';
import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext';
import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar'; import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar';
import { AppMode } from '../../ontimeConfig'; import { AppMode } from '../../ontimeConfig';
import TitleList from './title-list/TitleList'; import TitleList from './title-list/TitleList';
@@ -15,51 +14,44 @@ const MessageControl = lazy(() => import('../../features/control/message/Message
export default function Editor() { export default function Editor() {
const { layoutMode } = useEditorLayout(); const { layoutMode } = useEditorLayout();
switch (layoutMode) { if (layoutMode === EditorLayoutMode.CONTROL) {
case EditorLayoutMode.TRACKING: { return (
return ( <div id='panels' className={styles.panelContainer}>
<div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}> <div className={styles.left}>
<div className={styles.rundownLayout}> <TimerControl />
<div className={styles.titlesPanel}> <MessageControl />
<RundownSelectionContextProvider>
<TitleList mode={AppMode.Run} />
</RundownSelectionContextProvider>
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
</div>
<TrackingPlaybackBar />
</div> </div>
); <Rundown />
} </div>
case EditorLayoutMode.PLANNING: { );
return ( }
<div id='panels' className={styles.panelContainer}>
<div className={styles.rundownLayout}> if (layoutMode === EditorLayoutMode.TRACKING) {
<div className={styles.titlesPanel}> return (
<RundownSelectionContextProvider> <div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}>
<TitleList mode={AppMode.Edit} /> <div className={styles.rundownLayout}>
</RundownSelectionContextProvider> <div className={styles.titlesPanel}>
</div> <TitleList mode={AppMode.Run} />
<div className={styles.rundownPanel}> </div>
<Rundown /> <div className={styles.rundownPanel}>
</div> <Rundown />
</div> </div>
</div> </div>
); <TrackingPlaybackBar />
} </div>
case EditorLayoutMode.CONTROL: );
default: { }
return (
<div id='panels' className={styles.panelContainer}> return (
<div className={styles.left}> <div id='panels' className={styles.panelContainer}>
<TimerControl /> <div className={styles.rundownLayout}>
<MessageControl /> <div className={styles.titlesPanel}>
</div> <TitleList mode={AppMode.Edit} />
</div>
<div className={styles.rundownPanel}>
<Rundown /> <Rundown />
</div> </div>
); </div>
} </div>
} );
} }
@@ -1,7 +1,7 @@
import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useContextRundownFinder } from '../../../common/hooks-query/useContextRundown'; import { useFlatRundown } from '../../../common/hooks-query/useRundown';
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry'; import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
const maxResults = 12; const maxResults = 12;
@@ -38,7 +38,7 @@ type FilterableMilestone = {
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone; type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
export default function useFinder() { export default function useFinder() {
const { rundown: data, rundownId } = useContextRundownFinder(); const { data, rundownId } = useFlatRundown();
const [results, setResults] = useState<FilterableEntry[]>([]); const [results, setResults] = useState<FilterableEntry[]>([]);
const [error, setError] = useState<MaybeString>(null); const [error, setError] = useState<MaybeString>(null);
const lastSearchString = useRef(''); const lastSearchString = useRef('');
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import ScrollArea from '../../../common/components/scroll-area/ScrollArea'; import ScrollArea from '../../../common/components/scroll-area/ScrollArea';
import { useContextRundownList } from '../../../common/hooks-query/useContextRundown'; import useRundown from '../../../common/hooks-query/useRundown';
import { useSelectedEventId } from '../../../common/hooks/useSocket'; import { useSelectedEventId } from '../../../common/hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata } from '../../../common/utils/rundownMetadata'; import { ExtendedEntry, getFlatRundownMetadata } from '../../../common/utils/rundownMetadata';
import { useEventSelection } from '../../../features/rundown/useEventSelection'; import { useEventSelection } from '../../../features/rundown/useEventSelection';
@@ -20,7 +20,7 @@ interface TitleListProps {
} }
export default function TitleList({ mode }: TitleListProps) { export default function TitleList({ mode }: TitleListProps) {
const { rundown } = useContextRundownList(); const { data: rundown } = useRundown();
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const cursor = useEventSelection((state) => state.cursor); const cursor = useEventSelection((state) => state.cursor);
@@ -1,9 +1,6 @@
import { isValueOfEnum } from 'ontime-utils'; import { isValueOfEnum } from 'ontime-utils';
import { useCallback, useEffect } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { setSelectRundownInParams } from '../../common/context/RundownSelectionContext';
const layoutParam = 'layout'; const layoutParam = 'layout';
export enum EditorLayoutMode { export enum EditorLayoutMode {
@@ -23,29 +20,14 @@ function getEditorLayout(value: string | null): EditorLayoutMode {
} }
export function useEditorLayout() { export function useEditorLayout() {
'use memo';
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const layoutMode = getEditorLayout(searchParams.get(layoutParam)); const layoutMode = getEditorLayout(searchParams.get(layoutParam));
useEffect(() => { const setLayoutMode = (mode: EditorLayoutMode) => {
setSearchParams((searchParams) => { const nextParams = new URLSearchParams(searchParams);
if (layoutMode !== EditorLayoutMode.PLANNING) setSelectRundownInParams(null, searchParams); nextParams.set(layoutParam, mode);
return searchParams; setSearchParams(nextParams, { replace: true });
}); };
}, [setSearchParams, layoutMode]);
const setLayoutMode = useCallback(
(mode: EditorLayoutMode) => {
setSearchParams((searchParams) => {
searchParams.set(layoutParam, mode);
// Only the Planning layout is allowed to look at something other than the current rundown
if (mode !== EditorLayoutMode.PLANNING) setSelectRundownInParams(null, searchParams);
return searchParams;
});
},
[setSearchParams],
);
return { layoutMode, setLayoutMode }; return { layoutMode, setLayoutMode };
} }
@@ -1,5 +1,7 @@
@use '@/theme/viewerDefs' as *; @use '@/theme/viewerDefs' as *;
$content-width: min(100%, 1100px);
.project { .project {
margin: 0; margin: 0;
box-sizing: border-box; /* reset */ box-sizing: border-box; /* reset */
@@ -16,56 +18,104 @@
display: flex; display: flex;
flex-direction: column; 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 { .logo {
max-width: min(200px, 30vw); 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 { .info {
flex: 1; flex: 1;
max-height: 100%; width: $content-width;
margin-inline: auto; margin-inline: auto;
overflow-y: auto; overflow-y: auto;
width: min(calc(100vw - 4rem), 960px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: start;
gap: $view-element-gap; gap: $view-element-gap;
padding-block: $view-element-gap;
padding-bottom: 10vh; 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 { .info__label {
font-size: $timer-label-size;
font-weight: 600; font-weight: 600;
letter-spacing: 0.05em;
color: var(--label-color-override, $viewer-label-color); color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase; text-transform: uppercase;
} }
.info__value { .info__value {
white-space: break-spaces; white-space: break-spaces;
} line-height: 1.35;
overflow-wrap: anywhere;
.info__custom {
display: flex;
gap: 1rem;
} }
.info__image-container { .info__image-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 192px; flex: 0 0 min(192px, 25%);
height: 192px;
} }
.info__image { .info__image {
display: block;
max-width: 100%; max-width: 100%;
max-height: 100%; height: auto;
object-fit: contain; object-fit: contain;
} }
.link.info__value { .link.info__value {
display: flex; display: inline-flex;
gap: $view-element-gap; gap: 0.35em;
align-items: center; align-items: center;
color: $action-text-color; color: $action-text-color;
@@ -78,12 +128,13 @@
/* =================== MOBILE ===================*/ /* =================== MOBILE ===================*/
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.project { .project {
.project-header {
flex-direction: column;
align-items: start;
gap: 0.5rem;
}
.logo img { .logo img {
height: min(50px, 10vh); height: min(50px, 10vh);
} }
.info__image-container {
width: 96px;
height: 96px;
}
} }
} }
@@ -1,4 +1,5 @@
import { OntimeView } from 'ontime-types'; import { OntimeView } from 'ontime-types';
import { type ReactNode, useState } from 'react';
import { IoOpenOutline } from 'react-icons/io5'; import { IoOpenOutline } from 'react-icons/io5';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
@@ -21,7 +22,7 @@ export default function ProjectInfoLoader() {
} }
if (status === 'error') { 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} />; return <ProjectInfo {...data} />;
@@ -41,56 +42,49 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
return ( return (
<> <>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} /> <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 ( return (
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'> <div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} /> <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'> <div className='info'>
{projectData.title && ( {projectData.info && <InfoCard label={getLocalizedString('project.info')}>{projectData.info}</InfoCard>}
<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.url && ( {projectData.url && (
<div> <div className='info__card'>
<div className='info__label'>{getLocalizedString('project.url')}</div> <div className='info__label'>{getLocalizedString('project.url')}</div>
<a href={projectData.url} target='_blank' rel='noreferrer' className='info__value link'> <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> </a>
</div> </div>
)} )}
{projectData.custom.map((info, idx) => { {projectData.custom.map((info, idx) => {
const hasUrl = Boolean(info.url);
return ( return (
// oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here // 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'> <div key={`${info.title}-${idx}`} className='info__card'>
{hasUrl && ( {info.title && <div className='info__label'>{info.title}</div>}
<div className='info__image-container'> {info.url ? (
<img className='info__image' src={info.url} loading='lazy' /> <div className='info__media'>
<InfoImage src={info.url} />
{info.value && <div className='info__value'>{info.value}</div>}
</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> </div>
); );
})} })}
@@ -98,3 +92,31 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
</div> </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 useProjectData from '../../common/hooks-query/useProjectData';
import { useViewOptionsStore } from '../../common/stores/viewOptions'; import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ViewData } from '../utils/viewLoader.utils'; import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
export interface ProjectInfoData { export interface ProjectInfoData {
projectData: ProjectData; projectData: ProjectData;
@@ -14,13 +14,13 @@ export function useProjectInfoData(): ViewData<ProjectInfoData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
return { return {
data: { data: {
projectData, projectData,
isMirrored, isMirrored,
}, },
status: projectDataStatus, status: aggregateQueryStatus([{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError }]),
}; };
} }
+1 -1
View File
@@ -25,7 +25,7 @@ export default function StudioLoader() {
} }
if (status === 'error') { 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} />; return <Studio {...data} />;
+18 -5
View File
@@ -20,10 +20,18 @@ export function useStudioData(): ViewData<StudioData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings(); const {
const { data: settings, status: settingsStatus } = useSettings(); data: viewSettings,
const { data: customFields, status: customFieldsStatus } = useCustomFields(); status: viewSettingsStatus,
isLoadingError: viewSettingsIsLoadingError,
} = useViewSettings();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
return { return {
data: { data: {
@@ -33,6 +41,11 @@ export function useStudioData(): ViewData<StudioData> {
settings, settings,
viewSettings, 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 { OntimeView } from 'ontime-types';
import { useMemo } from 'react'; import { useMemo } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
import ViewLogo from '../../common/components/view-logo/ViewLogo'; import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
@@ -29,7 +30,7 @@ export default function TimelinePageLoader() {
} }
if (status === 'error') { 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} />; return <TimelinePage {...data} />;
@@ -73,7 +74,7 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
totalDuration={totalDuration} totalDuration={totalDuration}
/> />
) : ( ) : (
<EmptyPage text={getLocalizedString('common.no_data')} /> <EmptyFill text={getLocalizedString('common.no_data')} />
)} )}
</div> </div>
); );
@@ -16,10 +16,18 @@ export interface TimelineData {
export function useTimelineData(): ViewData<TimelineData> { export function useTimelineData(): ViewData<TimelineData> {
// HTTP API data // HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata(null); const {
const { data: projectData, status: projectDataStatus } = useProjectData(); data: rundownData,
const { data: settings, status: settingsStatus } = useSettings(); status: rundownStatus,
const { data: customFields, status: customFieldsStatus } = useCustomFields(); 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 { return {
data: { data: {
@@ -28,6 +36,11 @@ export function useTimelineData(): ViewData<TimelineData> {
projectData, projectData,
settings, 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') { 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} />; 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 useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData'; import useProjectData from '../../common/hooks-query/useProjectData';
import { useRundown } from '../../common/hooks-query/useRundown'; import useRundown from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings'; import useSettings from '../../common/hooks-query/useSettings';
import useViewSettings from '../../common/hooks-query/useViewSettings'; import useViewSettings from '../../common/hooks-query/useViewSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions'; import { useViewOptionsStore } from '../../common/stores/viewOptions';
@@ -22,11 +22,19 @@ export function useTimerData(): ViewData<TimerData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings(); const {
const { data: settings, status: settingsStatus } = useSettings(); data: viewSettings,
const { data: customFields, status: customFieldsStatus } = useCustomFields(); status: viewSettingsStatus,
const { data: rundown, status: rundownStatus } = useRundown(null); 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; const { entries } = rundown;
return { return {
@@ -39,11 +47,11 @@ export function useTimerData(): ViewData<TimerData> {
entries, entries,
}, },
status: aggregateQueryStatus([ status: aggregateQueryStatus([
projectDataStatus, { status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
viewSettingsStatus, { status: viewSettingsStatus, isLoadingError: viewSettingsIsLoadingError },
settingsStatus, { status: settingsStatus, isLoadingError: settingsIsLoadingError },
customFieldsStatus, { status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
rundownStatus, { status: rundownStatus, isLoadingError: rundownIsLoadingError },
]), ]),
}; };
} }
+17 -11
View File
@@ -5,21 +5,27 @@ export type ViewData<T> = {
status: QueryStatus; 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. * Aggregates a loading status from multiple queries, for the purpose of deciding
* If all statuses are 'pending', returns 'pending'. * whether a view can render.
* If all statuses are 'success', returns 'success'. * - 'pending' while any query hasn't settled yet (no result, first fetch in flight)
* If any status is 'error', returns 'error'. * - '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 { export function aggregateQueryStatus(queries: AggregatableQuery[]): QueryStatus {
if (statuses.every((status) => status === 'pending')) { const allSettled = queries.every((query) => query.status !== 'pending');
if (!allSettled) {
return 'pending'; return 'pending';
} }
if (statuses.every((status) => status === 'success')) { if (queries.some((query) => query.isLoadingError)) {
return 'success';
}
if (statuses.some((status) => status === 'error')) {
return 'error'; return 'error';
} }
return 'pending'; return 'success';
} }
@@ -1,10 +1,9 @@
import { copyFile } from 'fs/promises'; import { copyFile } from 'fs/promises';
import { join } from 'path'; 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 { getErrorMessage, getFirstRundown } from 'ontime-utils';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js'; import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
import { parseDatabaseModel } from '../../api-data/db/db.parser.js'; import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js'; import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
@@ -106,9 +105,6 @@ async function loadProject(projectData: DatabaseModel, fileName: string, rundown
currentProjectName: fileName, currentProjectName: fileName,
}; };
setImmediate(() => {
sendRefetch(RefetchKey.ProjectFiles);
});
return fileName; return fileName;
} }
@@ -267,10 +263,6 @@ export async function duplicateProjectFile(originalFile: string, newFilename: st
const pathToDuplicate = getPathToProject(newFilename); const pathToDuplicate = getPathToProject(newFilename);
await copyFile(projectFilePath, pathToDuplicate); await copyFile(projectFilePath, pathToDuplicate);
setImmediate(() => {
sendRefetch(RefetchKey.ProjectFiles);
});
return; return;
} }
@@ -301,10 +293,6 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
const newFileName = await loadProject(projectData.data, newFilename); const newFileName = await loadProject(projectData.data, newFilename);
return newFileName; return newFileName;
} }
setImmediate(() => {
sendRefetch(RefetchKey.ProjectFiles);
});
return newFilename; return newFilename;
} }
@@ -344,9 +332,6 @@ export async function deleteProjectFile(filename: string) {
} }
await deleteFile(projectFilePath); 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; return updatedData;
} }
@@ -1,7 +1,6 @@
export enum RefetchKey { export enum RefetchKey {
All = 'all', All = 'all',
CustomFields = 'custom-fields', CustomFields = 'custom-fields',
ProjectFiles = 'project-files',
ProjectData = 'project-data', ProjectData = 'project-data',
ProjectRundowns = 'project-rundowns', ProjectRundowns = 'project-rundowns',
Report = 'report', Report = 'report',