mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-17 05:13:32 +00:00
refactor: allow secondary rundowns (#2086)
* refactor: allow secondary rundowns * ui: move dropdown * feat: follow loaded * ui: background edit warning ui: fix disable radio button * feat(ui): add loaded sufix in the rundown list * feat(ui): add direct link to background edit from rundown manager * fix: better fallback * fix: default to is isCurrentRundown for nav bar colour * chore: rename navigate to cuesheet --------- Co-authored-by: alex-arc <ac@omnivox.dk>
This commit is contained in:
@@ -34,19 +34,17 @@ export async function fetchCurrentRundown(options?: RequestOptions): Promise<Run
|
||||
throw new Error('Invalid rundown payload');
|
||||
}
|
||||
return res.data;
|
||||
}
|
||||
|
||||
function isValidRundown(x: any): x is Rundown {
|
||||
return (
|
||||
x &&
|
||||
typeof x === 'object' &&
|
||||
typeof x.id === 'string' &&
|
||||
Array.isArray(x.order) &&
|
||||
Array.isArray(x.flatOrder) &&
|
||||
x.entries &&
|
||||
typeof x.entries === 'object' &&
|
||||
typeof x.revision === 'number'
|
||||
);
|
||||
/**
|
||||
* HTTP request to fetch all entries in the given rundown
|
||||
*/
|
||||
export async function fetchRundown(rundownId: RundownId, options?: RequestOptions): Promise<Rundown> {
|
||||
const res = await axios.get(`${rundownPath}/${rundownId}`, { signal: options?.signal });
|
||||
if (!isValidRundown(res.data)) {
|
||||
throw new Error('Invalid rundown payload');
|
||||
}
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,3 +195,16 @@ export async function requestDeleteAll(rundownId: RundownId): Promise<AxiosRespo
|
||||
}
|
||||
|
||||
// #endregion operations on rundown entries =======================
|
||||
|
||||
function isValidRundown(x: any): x is Rundown {
|
||||
return (
|
||||
x &&
|
||||
typeof x === 'object' &&
|
||||
typeof x.id === 'string' &&
|
||||
Array.isArray(x.order) &&
|
||||
Array.isArray(x.flatOrder) &&
|
||||
x.entries &&
|
||||
typeof x.entries === 'object' &&
|
||||
typeof x.revision === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
renameRundown,
|
||||
} from '../api/rundown';
|
||||
|
||||
//TODO: make suspends so we don't have to deal with no value all over
|
||||
/**
|
||||
* Project rundowns
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
|
||||
import { fetchCurrentRundown } from '../api/rundown';
|
||||
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
|
||||
import { useSelectedEventId } from '../hooks/useSocket';
|
||||
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
|
||||
import { useProjectRundowns } from './useProjectRundowns';
|
||||
@@ -20,7 +20,11 @@ const cachedRundownPlaceholder: Rundown = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalised rundown data
|
||||
* Normalised rundown data for the currently loaded rundown.
|
||||
*
|
||||
* Bootstraps via the `/current` alias so the first paint is a single round-trip,
|
||||
* independent of the project rundown list. Once the loaded id is known, the
|
||||
* query key swaps to the id-keyed cache that is shared with `useRundownById`.
|
||||
*/
|
||||
export default function useRundown() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -34,7 +38,7 @@ export default function useRundown() {
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
|
||||
// Seed the ID-based cache when fetching via the 'current' alias (bootstrap)
|
||||
// Seed the id-keyed cache when fetching via the bootstrap alias
|
||||
useEffect(() => {
|
||||
if (!data || loadedRundownId) return;
|
||||
queryClient.setQueryData(getRundownQueryKey(data.id), data);
|
||||
@@ -115,3 +119,21 @@ export function useRundownAuxData() {
|
||||
}, [data]);
|
||||
return { data: filteredData, status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides access to a specific rundown by ID.
|
||||
* When rundownId is null/undefined the query is disabled and returns the placeholder.
|
||||
*/
|
||||
export function useRundownById(rundownId: string | null | undefined) {
|
||||
const enabled = Boolean(rundownId);
|
||||
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
|
||||
queryKey: getRundownQueryKey(rundownId ?? ''),
|
||||
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
|
||||
enabled,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
|
||||
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { EntryId, Rundown } from 'ontime-types';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useSelectedEventId } from '../hooks/useSocket';
|
||||
import { getFlatRundownMetadata, type ExtendedEntry } from '../utils/rundownMetadata';
|
||||
import { useProjectRundowns } from './useProjectRundowns';
|
||||
import { useRundownById } from './useRundown';
|
||||
|
||||
export type RundownSource = {
|
||||
rundownId: string | null;
|
||||
rundown: Rundown;
|
||||
flatRundown: ExtendedEntry[];
|
||||
status: string;
|
||||
selectedEventId: EntryId | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Explicitly scoped rundown data for views that may operate on a non-loaded rundown.
|
||||
*/
|
||||
export function useScopedRundown(rundownId: string | null): RundownSource {
|
||||
const { data: projectRundowns } = useProjectRundowns();
|
||||
return useRundownSource(rundownId, projectRundowns.loaded || null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded-rundown source for views that must follow the active runtime rundown.
|
||||
*/
|
||||
export function useLoadedRundownSource(): RundownSource {
|
||||
const { data: projectRundowns } = useProjectRundowns();
|
||||
const loadedRundownId = projectRundowns.loaded || null;
|
||||
return useRundownSource(loadedRundownId, loadedRundownId);
|
||||
}
|
||||
|
||||
function useRundownSource(rundownId: string | null, loadedRundownId: string | null): RundownSource {
|
||||
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
|
||||
const runtimeSelectedEventId = useSelectedEventId();
|
||||
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
|
||||
const { data: rundown, status } = useRundownById(rundownId);
|
||||
const flatRundown = useMemo(
|
||||
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
|
||||
[effectiveSelectedEventId, rundown],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
rundownId,
|
||||
rundown,
|
||||
flatRundown,
|
||||
status,
|
||||
selectedEventId: effectiveSelectedEventId,
|
||||
}),
|
||||
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
|
||||
);
|
||||
}
|
||||
@@ -63,9 +63,16 @@ export type EventOptions = Partial<{
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Gather utilities for actions on entries
|
||||
* Gather utilities for actions on entries in the loaded rundown.
|
||||
*/
|
||||
export const useEntryActions = () => {
|
||||
export const useEntryActions = () => useEntryActionsForRundown(undefined);
|
||||
|
||||
/**
|
||||
* Gather utilities for actions on entries in an explicitly selected rundown.
|
||||
*/
|
||||
export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? '');
|
||||
|
||||
function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
linkPrevious,
|
||||
@@ -78,12 +85,12 @@ export const useEntryActions = () => {
|
||||
} = useEditorSettings();
|
||||
|
||||
const resolveCurrentRundownQueryKey = useCallback(() => {
|
||||
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
|
||||
if (loadedRundownId) {
|
||||
return getRundownQueryKey(loadedRundownId);
|
||||
if (scopedRundownId !== undefined) {
|
||||
return getRundownQueryKey(scopedRundownId);
|
||||
}
|
||||
return CURRENT_RUNDOWN_QUERY_KEY;
|
||||
}, [queryClient]);
|
||||
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
|
||||
return loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
|
||||
}, [queryClient, scopedRundownId]);
|
||||
|
||||
/**
|
||||
* Returns the currently loaded rundown
|
||||
@@ -1004,7 +1011,7 @@ export const useEntryActions = () => {
|
||||
updateTimer,
|
||||
],
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to optimistically delete entries from client cache
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
APP_SETTINGS,
|
||||
CLIENT_LIST,
|
||||
CSS_OVERRIDE,
|
||||
CUSTOM_FIELDS,
|
||||
CURRENT_RUNDOWN_QUERY_KEY,
|
||||
CUSTOM_FIELDS,
|
||||
PROJECT_DATA,
|
||||
REPORT,
|
||||
RUNDOWN,
|
||||
@@ -213,6 +213,9 @@ export const connectSocket = () => {
|
||||
case RefetchKey.Settings:
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: APP_SETTINGS });
|
||||
break;
|
||||
case RefetchKey.ProjectRundowns:
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS });
|
||||
break;
|
||||
default: {
|
||||
target satisfies never;
|
||||
break;
|
||||
@@ -242,7 +245,9 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
|
||||
// skip if we dont recognise the ID the revision is lower
|
||||
const queryKey = getRundownQueryKey(rundownId);
|
||||
const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey);
|
||||
if (revision !== null && revision === cachedRundown?.revision) {
|
||||
|
||||
if (revision === cachedRundown?.revision) {
|
||||
// we already have the latest change
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IoPencilOutline,
|
||||
IoTrash,
|
||||
} from 'react-icons/io5';
|
||||
import { TbGhost3 } from 'react-icons/tb';
|
||||
|
||||
import { downloadAsExcel } from '../../../../common/api/excel';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
@@ -19,6 +20,7 @@ import { DropdownMenu } from '../../../../common/components/dropdown-menu/Dropdo
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import RundownRenameForm from './composite/RundownRenameForm';
|
||||
import { ManageRundownForm } from './ManageRundownForm';
|
||||
@@ -97,6 +99,8 @@ export default function ManageRundowns() {
|
||||
await downloadAsExcel(rundownId, title);
|
||||
};
|
||||
|
||||
const navigateToCuesheet = useDirectLinkToBackgroundEdit();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Section>
|
||||
@@ -180,6 +184,12 @@ export default function ManageRundowns() {
|
||||
label: 'Duplicate',
|
||||
onClick: () => submitRundownDuplicate(id),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
icon: TbGhost3,
|
||||
label: 'Edit in cuesheet',
|
||||
onClick: () => navigateToCuesheet(id),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'destructive',
|
||||
|
||||
@@ -6,6 +6,7 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
|
||||
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
|
||||
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
||||
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
|
||||
import { useLoadedRundownSource } from '../../common/hooks-query/useScopedRundown';
|
||||
import { useEntryActions } from '../../common/hooks/useEntryAction';
|
||||
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
|
||||
import { handleLinks } from '../../common/utils/linkUtils';
|
||||
@@ -104,6 +105,8 @@ interface RundownRootProps {
|
||||
}
|
||||
|
||||
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
|
||||
const source = useLoadedRundownSource();
|
||||
|
||||
return (
|
||||
<div className={style.rundownRoot}>
|
||||
{isSmallDevice ? (
|
||||
@@ -112,7 +115,7 @@ function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: Rund
|
||||
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
|
||||
)}
|
||||
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
|
||||
{viewMode === RundownViewMode.Table && <EntryEditModal />}
|
||||
{viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />}
|
||||
<RenumberCuesDialog />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { OntimeEntry, Rundown, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import useRundown from '../../../common/hooks-query/useRundown';
|
||||
import EventEditor from './EventEditor';
|
||||
import GroupEditor from './GroupEditor';
|
||||
import MilestoneEditor from './MilestoneEditor';
|
||||
@@ -10,19 +9,18 @@ import style from './EntryEditor.module.scss';
|
||||
|
||||
interface CuesheetEntryEditorProps {
|
||||
entryId: string;
|
||||
rundown: Rundown;
|
||||
}
|
||||
|
||||
export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProps) {
|
||||
const { data } = useRundown();
|
||||
|
||||
export default function CuesheetEntryEditor({ entryId, rundown }: CuesheetEntryEditorProps) {
|
||||
const entry = useMemo<OntimeEntry | null>(() => {
|
||||
if (data.order.length === 0) {
|
||||
if (rundown.order.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const event = data.entries[entryId];
|
||||
const event = rundown.entries[entryId];
|
||||
return event ?? null;
|
||||
}, [entryId, data.order, data.entries]);
|
||||
}, [entryId, rundown.entries, rundown.order.length]);
|
||||
|
||||
if (isOntimeEvent(entry)) {
|
||||
return (
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Toolbar } from '@base-ui/react/toolbar';
|
||||
import type { Column } from '@tanstack/react-table';
|
||||
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import {
|
||||
ColumnSettings,
|
||||
ViewSettings,
|
||||
} from '../../../views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings';
|
||||
import { usePersistedRundownOptions } from '../rundown.options';
|
||||
|
||||
import style from '../../../views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss';
|
||||
|
||||
interface EditorTableSettingsProps {
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
handleClearToggles: () => void;
|
||||
}
|
||||
|
||||
export default function EditorTableSettings({
|
||||
columns,
|
||||
handleResetResizing,
|
||||
handleResetReordering,
|
||||
handleClearToggles,
|
||||
}: EditorTableSettingsProps) {
|
||||
const options = usePersistedRundownOptions();
|
||||
|
||||
return (
|
||||
<Toolbar.Root className={style.tableSettings}>
|
||||
<ViewSettings optionsStore={options} />
|
||||
<ColumnSettings
|
||||
columns={columns}
|
||||
handleResetResizing={handleResetResizing}
|
||||
handleResetReordering={handleResetReordering}
|
||||
handleClearToggles={handleClearToggles}
|
||||
/>
|
||||
</Toolbar.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { memo, useEffect, useMemo } from 'react';
|
||||
|
||||
import EmptyPage from '../../../common/components/state/EmptyPage';
|
||||
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
|
||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
|
||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
|
||||
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
|
||||
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
|
||||
@@ -13,6 +16,8 @@ function RundownTable() {
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
||||
const { editorMode } = useEditorFollowMode();
|
||||
const source = useLoadedRundownSource();
|
||||
const actions = useEntryActions();
|
||||
|
||||
// Editor always has full permissions
|
||||
useEffect(() => {
|
||||
@@ -30,12 +35,14 @@ function RundownTable() {
|
||||
const isLoading = !customFields || customFieldStatus === 'pending';
|
||||
|
||||
return (
|
||||
<CuesheetDnd columns={columns} tableRoot='editor'>
|
||||
{isLoading ? (
|
||||
<EmptyPage text='Loading...' />
|
||||
) : (
|
||||
<CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' />
|
||||
)}
|
||||
</CuesheetDnd>
|
||||
<EntryActionsProvider actions={actions}>
|
||||
<CuesheetDnd columns={columns} tableRoot='editor'>
|
||||
{isLoading ? (
|
||||
<EmptyPage text='Loading...' />
|
||||
) : (
|
||||
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
|
||||
)}
|
||||
</CuesheetDnd>
|
||||
</EntryActionsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { EntryId, MaybeNumber, ProjectRundownsList, Rundown, isOntimeEvent } fro
|
||||
import { MouseEvent } from 'react';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants';
|
||||
import { PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants';
|
||||
import { ontimeQueryClient } from '../../common/queryClient';
|
||||
import { isMacOS } from '../../common/utils/deviceUtils';
|
||||
|
||||
@@ -137,12 +137,9 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
|
||||
}));
|
||||
|
||||
function getLoadedRundownData() {
|
||||
const loadedRundownId = ontimeQueryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
|
||||
if (loadedRundownId) {
|
||||
return ontimeQueryClient.getQueryData<Rundown>(getRundownQueryKey(loadedRundownId));
|
||||
}
|
||||
|
||||
return ontimeQueryClient.getQueryData<Rundown>(CURRENT_RUNDOWN_QUERY_KEY);
|
||||
const rundownId = ontimeQueryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
|
||||
if (!rundownId) return undefined;
|
||||
return ontimeQueryClient.getQueryData<Rundown>(getRundownQueryKey(rundownId));
|
||||
}
|
||||
|
||||
export function getSelectionMode(event: MouseEvent): SelectionMode {
|
||||
|
||||
@@ -14,3 +14,7 @@
|
||||
'table';
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.rundownSelect {
|
||||
min-width: min(20rem, calc(100vw - 6rem));
|
||||
}
|
||||
|
||||
@@ -4,28 +4,34 @@ import { IoApps } from 'react-icons/io5';
|
||||
import IconButton from '../../common/components/buttons/IconButton';
|
||||
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
||||
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
|
||||
import { useEntryActions } from '../../common/hooks/useEntryAction';
|
||||
import { useScopedRundown } from '../../common/hooks-query/useScopedRundown';
|
||||
import { useScopedEntryActions } from '../../common/hooks/useEntryAction';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { getIsNavigationLocked } from '../../externals';
|
||||
import CuesheetOverview from '../../features/overview/CuesheetOverview';
|
||||
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
|
||||
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
||||
import CuesheetTableWrapper from './CuesheetTableWrapper';
|
||||
import { FOLLOW_LOADED_RUNDOWN_ID, useCuesheetRundownSelection } from './useCuesheetRundownSelection';
|
||||
|
||||
import styles from './CuesheetPage.module.scss';
|
||||
|
||||
export default function CuesheetPage() {
|
||||
'use memo';
|
||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||
const entryActions = useEntryActions();
|
||||
const { selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = useCuesheetRundownSelection();
|
||||
const source = useScopedRundown(selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? loadedRundownId : selectedRundownId);
|
||||
|
||||
const actions = useScopedEntryActions(source.rundownId);
|
||||
|
||||
useWindowTitle('Cuesheet');
|
||||
|
||||
const isLocked = getIsNavigationLocked();
|
||||
|
||||
return (
|
||||
<EntryActionsProvider actions={entryActions}>
|
||||
<EntryActionsProvider actions={actions}>
|
||||
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
||||
<EntryEditModal />
|
||||
<EntryEditModal rundown={source.rundown} />
|
||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||
<CuesheetOverview>
|
||||
{!isLocked && (
|
||||
@@ -35,7 +41,13 @@ export default function CuesheetPage() {
|
||||
)}
|
||||
</CuesheetOverview>
|
||||
<CuesheetProgress />
|
||||
<CuesheetTableWrapper />
|
||||
<CuesheetTableWrapper
|
||||
source={source}
|
||||
selectedRundownId={selectedRundownId}
|
||||
loadedRundownId={loadedRundownId}
|
||||
setSelectedRundownId={setSelectedRundownId}
|
||||
projectRundowns={projectRundowns}
|
||||
/>
|
||||
</div>
|
||||
</EntryActionsProvider>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,40 @@
|
||||
import { MaybeString, ProjectRundown } from 'ontime-types';
|
||||
import { memo, use, useMemo } from 'react';
|
||||
|
||||
import Select from '../../common/components/select/Select';
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import { PresetContext } from '../../common/context/PresetContext';
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
|
||||
import { AppMode } from '../../ontimeConfig';
|
||||
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
|
||||
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
|
||||
import CuesheetTable from './cuesheet-table/CuesheetTable';
|
||||
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
|
||||
import { FOLLOW_LOADED_RUNDOWN_ID } from './useCuesheetRundownSelection';
|
||||
|
||||
import styles from './CuesheetPage.module.scss';
|
||||
|
||||
interface CuesheetTableWrapperProps {
|
||||
source: RundownSource;
|
||||
selectedRundownId: MaybeString;
|
||||
loadedRundownId: string;
|
||||
setSelectedRundownId: (rundownId: string) => void;
|
||||
projectRundowns: ProjectRundown[];
|
||||
}
|
||||
|
||||
export default memo(CuesheetTableWrapper);
|
||||
function CuesheetTableWrapper() {
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
function CuesheetTableWrapper({
|
||||
source,
|
||||
selectedRundownId,
|
||||
setSelectedRundownId,
|
||||
loadedRundownId,
|
||||
projectRundowns,
|
||||
}: CuesheetTableWrapperProps) {
|
||||
const preset = use(PresetContext);
|
||||
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset);
|
||||
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
|
||||
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
|
||||
const columns = useMemo(
|
||||
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
|
||||
@@ -28,11 +50,66 @@ function CuesheetTableWrapper() {
|
||||
) : (
|
||||
<CuesheetTable
|
||||
columns={columns}
|
||||
source={source}
|
||||
cuesheetMode={cuesheetMode}
|
||||
tableRoot='cuesheet'
|
||||
setCuesheetMode={setCuesheetMode}
|
||||
isCurrentRundown={isCurrentRundown}
|
||||
insertElement={
|
||||
<>
|
||||
<RundownSelect
|
||||
cuesheetMode={cuesheetMode}
|
||||
selectedRundownId={selectedRundownId}
|
||||
loadedRundownId={loadedRundownId}
|
||||
setSelectedRundownId={setSelectedRundownId}
|
||||
projectRundowns={projectRundowns}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CuesheetDnd>
|
||||
);
|
||||
}
|
||||
|
||||
interface RundownSelectProps {
|
||||
cuesheetMode: AppMode;
|
||||
selectedRundownId: MaybeString;
|
||||
loadedRundownId: string;
|
||||
setSelectedRundownId: (rundownId: string) => void;
|
||||
projectRundowns: ProjectRundown[];
|
||||
}
|
||||
|
||||
function RundownSelect({
|
||||
cuesheetMode,
|
||||
projectRundowns,
|
||||
loadedRundownId,
|
||||
selectedRundownId,
|
||||
setSelectedRundownId,
|
||||
}: RundownSelectProps) {
|
||||
'use memo';
|
||||
const options = projectRundowns.map(({ id, title }) => ({
|
||||
value: id,
|
||||
label: loadedRundownId === id ? `${title} (loaded)` : title,
|
||||
}));
|
||||
options.unshift({
|
||||
value: FOLLOW_LOADED_RUNDOWN_ID,
|
||||
label: 'Follow loaded', // TODO: Better wording and maybe icon? and translation
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.rundownSelect}>
|
||||
<Select
|
||||
value={selectedRundownId ?? undefined}
|
||||
options={options}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setSelectedRundownId(value);
|
||||
}
|
||||
}}
|
||||
disabled={cuesheetMode === AppMode.Run}
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AppMode } from '../../../ontimeConfig';
|
||||
import { getEffectiveCuesheetMode } from '../useApplyCuesheetPolicy';
|
||||
|
||||
describe('getEffectiveCuesheetMode()', () => {
|
||||
it('honors the stored mode when the user can change mode on the current rundown', () => {
|
||||
const permissions = { canChangeMode: true };
|
||||
|
||||
expect(getEffectiveCuesheetMode(permissions, AppMode.Edit, { canRunMode: true })).toBe(AppMode.Edit);
|
||||
expect(getEffectiveCuesheetMode(permissions, AppMode.Run, { canRunMode: true })).toBe(AppMode.Run);
|
||||
});
|
||||
|
||||
it('forces Run mode when the preset forbids mode changes', () => {
|
||||
const permissions = { canChangeMode: false };
|
||||
|
||||
expect(getEffectiveCuesheetMode(permissions, AppMode.Edit, { canRunMode: true })).toBe(AppMode.Run);
|
||||
});
|
||||
|
||||
it('forces Edit mode for background rundowns without changing the permission policy', () => {
|
||||
const permissions = { canChangeMode: true };
|
||||
|
||||
expect(getEffectiveCuesheetMode(permissions, AppMode.Run, { canRunMode: false })).toBe(AppMode.Edit);
|
||||
});
|
||||
});
|
||||
@@ -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,11 +1,16 @@
|
||||
import { Rundown } from 'ontime-types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import Modal from '../../../common/components/modal/Modal';
|
||||
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
|
||||
import { useEditModal } from './useEditModal';
|
||||
|
||||
interface EntryEditModalProps {
|
||||
rundown: Rundown;
|
||||
}
|
||||
|
||||
export default memo(EntryEditModal);
|
||||
function EntryEditModal() {
|
||||
function EntryEditModal({ rundown }: EntryEditModalProps) {
|
||||
const entryId = useEditModal((state) => state.selectedEntryId);
|
||||
const closeModal = useEditModal((state) => state.clearSelection);
|
||||
|
||||
@@ -19,7 +24,7 @@ function EntryEditModal() {
|
||||
onClose={closeModal}
|
||||
title='Edit entry'
|
||||
showCloseButton
|
||||
bodyElements={<CuesheetEntryEditor entryId={entryId} />}
|
||||
bodyElements={<CuesheetEntryEditor entryId={entryId} rundown={rundown} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTableNav } from '@table-nav/react';
|
||||
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { OntimeEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
ContextProp,
|
||||
ItemProps,
|
||||
@@ -14,10 +14,8 @@ import {
|
||||
import EmptyPage from '../../../common/components/state/EmptyPage';
|
||||
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import { useFlatRundownWithMetadata } from '../../../common/hooks-query/useRundown';
|
||||
import { useSelectedEventId } from '../../../common/hooks/useSocket';
|
||||
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import EditorTableSettings from '../../../features/rundown/rundown-table/EditorTableSettings';
|
||||
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
|
||||
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
||||
import { AppMode } from '../../../ontimeConfig';
|
||||
@@ -28,7 +26,7 @@ import EventRow from './cuesheet-table-elements/EventRow';
|
||||
import GroupRow from './cuesheet-table-elements/GroupRow';
|
||||
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
|
||||
import TableMenu from './cuesheet-table-menu/TableMenu';
|
||||
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
||||
import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar';
|
||||
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
|
||||
|
||||
import style from './CuesheetTable.module.scss';
|
||||
@@ -36,30 +34,40 @@ import style from './CuesheetTable.module.scss';
|
||||
type CuesheetTableBaseProps = {
|
||||
columns: ColumnDef<ExtendedEntry>[];
|
||||
cuesheetMode: AppMode;
|
||||
source: RundownSource;
|
||||
insertElement?: ReactNode;
|
||||
};
|
||||
|
||||
type EditorCuesheetTableProps = CuesheetTableBaseProps & {
|
||||
tableRoot: 'editor';
|
||||
setCuesheetMode?: undefined;
|
||||
isCurrentRundown?: undefined;
|
||||
};
|
||||
|
||||
type ViewCuesheetTableProps = CuesheetTableBaseProps & {
|
||||
tableRoot: 'cuesheet';
|
||||
setCuesheetMode: (mode: AppMode) => void;
|
||||
isCurrentRundown?: boolean;
|
||||
};
|
||||
|
||||
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
|
||||
|
||||
export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCuesheetMode }: CuesheetTableProps) {
|
||||
const { data, status } = useFlatRundownWithMetadata();
|
||||
export default function CuesheetTable({
|
||||
columns,
|
||||
cuesheetMode,
|
||||
source,
|
||||
tableRoot,
|
||||
setCuesheetMode,
|
||||
isCurrentRundown,
|
||||
insertElement,
|
||||
}: CuesheetTableProps) {
|
||||
const { flatRundown, status, selectedEventId } = source;
|
||||
const { updateEntry, updateTimer } = useEntryActionsContext();
|
||||
|
||||
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
|
||||
const showDelayedTimes = useOptions((state) => state.showDelayedTimes);
|
||||
const hideTableSeconds = useOptions((state) => state.hideTableSeconds);
|
||||
const hideIndexColumn = useOptions((state) => state.hideIndexColumn);
|
||||
const optionsStore = useOptions();
|
||||
const { showDelayedTimes, hideTableSeconds, hideIndexColumn } = optionsStore;
|
||||
|
||||
const selectedEventId = useSelectedEventId();
|
||||
const cursor = useEventSelection((state) => state.cursor);
|
||||
const setScrollHandler = useEventSelection((state) => state.setScrollHandler);
|
||||
|
||||
@@ -70,7 +78,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
() => ({
|
||||
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => {
|
||||
// check if value is the same
|
||||
const event = data[rowIndex];
|
||||
const event = flatRundown[rowIndex];
|
||||
|
||||
if (!event) {
|
||||
return;
|
||||
@@ -101,7 +109,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
hideIndexColumn,
|
||||
},
|
||||
}),
|
||||
[cuesheetMode, data, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
|
||||
[cuesheetMode, flatRundown, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
|
||||
);
|
||||
|
||||
const { columnOrder, resetColumnOrder } = useColumnOrder(columns, tableRoot);
|
||||
@@ -109,7 +117,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
data: flatRundown,
|
||||
columns,
|
||||
columnResizeMode: 'onChange',
|
||||
state: {
|
||||
@@ -137,13 +145,13 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
return;
|
||||
}
|
||||
|
||||
const eventIndex = data.findIndex((event) => event.id === selectedEventId);
|
||||
const eventIndex = flatRundown.findIndex((event) => event.id === selectedEventId);
|
||||
if (eventIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
|
||||
}, [cuesheetMode, data, selectedEventId]);
|
||||
}, [cuesheetMode, flatRundown, selectedEventId]);
|
||||
|
||||
// Provide an imperative scroll handler for explicit jumps (finder/keyboard)
|
||||
useEffect(() => {
|
||||
@@ -152,7 +160,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
return;
|
||||
}
|
||||
|
||||
const eventIndex = data.findIndex((event) => event.id === entryId);
|
||||
const eventIndex = flatRundown.findIndex((event) => event.id === entryId);
|
||||
if (eventIndex === -1) {
|
||||
return;
|
||||
}
|
||||
@@ -165,7 +173,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
return () => {
|
||||
setScrollHandler(null);
|
||||
};
|
||||
}, [data, setScrollHandler]);
|
||||
}, [flatRundown, setScrollHandler]);
|
||||
|
||||
/**
|
||||
* To improve performance on resizing, we memoise the column sizes
|
||||
@@ -217,7 +225,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
});
|
||||
}, [cuesheetMode, hideIndexColumn, table]);
|
||||
|
||||
const isLoading = !data || status === 'pending';
|
||||
const isLoading = !flatRundown || status === 'pending';
|
||||
|
||||
if (isLoading) {
|
||||
return <EmptyPage text='Loading...' />;
|
||||
@@ -225,26 +233,27 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
|
||||
return (
|
||||
<>
|
||||
{tableRoot === 'editor' ? (
|
||||
<EditorTableSettings
|
||||
columns={allLeafColumns}
|
||||
handleResetResizing={resetColumnResizing}
|
||||
handleResetReordering={resetColumnOrder}
|
||||
handleClearToggles={setAllVisible}
|
||||
/>
|
||||
) : (
|
||||
<CuesheetTableSettings
|
||||
columns={allLeafColumns}
|
||||
cuesheetMode={cuesheetMode}
|
||||
setCuesheetMode={setCuesheetMode}
|
||||
handleResetResizing={resetColumnResizing}
|
||||
handleResetReordering={resetColumnOrder}
|
||||
handleClearToggles={setAllVisible}
|
||||
/>
|
||||
)}
|
||||
<CuesheetTableHeaderToolbar
|
||||
columns={allLeafColumns}
|
||||
optionsStore={optionsStore}
|
||||
handleResetResizing={resetColumnResizing}
|
||||
handleResetReordering={resetColumnOrder}
|
||||
handleClearToggles={setAllVisible}
|
||||
insertElement={insertElement}
|
||||
modeControls={
|
||||
tableRoot === 'cuesheet'
|
||||
? {
|
||||
cuesheetMode,
|
||||
setCuesheetMode,
|
||||
isCurrentRundown,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
showShare={tableRoot === 'cuesheet'}
|
||||
/>
|
||||
<TableVirtuoso
|
||||
ref={virtuosoRef}
|
||||
data={data}
|
||||
data={flatRundown}
|
||||
context={virtuosoContext}
|
||||
style={tableRoot === 'editor' ? { paddingLeft: '1rem' } : undefined}
|
||||
computeItemKey={computeItemKey}
|
||||
|
||||
+4
-3
@@ -30,15 +30,16 @@ function EditableImage({ initialValue, readOnly, updateValue }: EditableImagePro
|
||||
}
|
||||
};
|
||||
|
||||
if (!initialValue && readOnly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!initialValue) {
|
||||
return (
|
||||
<Input
|
||||
variant='ghosted'
|
||||
className={style.imageInput}
|
||||
fluid
|
||||
readOnly={readOnly}
|
||||
// we disable the field to prevent receiving focus
|
||||
disabled={readOnly}
|
||||
placeholder='Paste image URL'
|
||||
onBlur={(event) => handleUpdate(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
|
||||
+72
-48
@@ -11,78 +11,92 @@ import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||
import PopoverContents from '../../../../common/components/popover/Popover';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { CuesheetOptions, usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||
import { useCuesheetPermissions } from '../../useTablePermissions';
|
||||
import CuesheetShareModal from './CuesheetShareModal';
|
||||
|
||||
import style from './CuesheetTableSettings.module.scss';
|
||||
|
||||
interface CuesheetTableSettingsProps {
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
type TableHeaderOptionsStore = {
|
||||
hideTableSeconds: boolean;
|
||||
hideIndexColumn: boolean;
|
||||
showDelayedTimes: boolean;
|
||||
hideDelays: boolean;
|
||||
setOption: <K extends keyof TableHeaderOptionValues>(key: K, value: TableHeaderOptionValues[K]) => void;
|
||||
};
|
||||
|
||||
type TableHeaderOptionValues = Pick<
|
||||
TableHeaderOptionsStore,
|
||||
'hideTableSeconds' | 'hideIndexColumn' | 'showDelayedTimes' | 'hideDelays'
|
||||
>;
|
||||
|
||||
type TableModeControls = {
|
||||
cuesheetMode: AppMode;
|
||||
setCuesheetMode: (mode: AppMode) => void;
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
handleClearToggles: () => void;
|
||||
}
|
||||
isCurrentRundown?: boolean;
|
||||
};
|
||||
|
||||
export interface ViewSettingsProps {
|
||||
optionsStore: CuesheetOptions;
|
||||
}
|
||||
|
||||
export interface ColumnSettingsProps {
|
||||
interface CuesheetTableHeaderToolbarProps {
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
optionsStore: TableHeaderOptionsStore;
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
handleClearToggles: () => void;
|
||||
insertElement?: ReactNode;
|
||||
modeControls?: TableModeControls;
|
||||
showShare?: boolean;
|
||||
}
|
||||
|
||||
export default function CuesheetTableSettings({
|
||||
export default function CuesheetTableHeaderToolbar({
|
||||
columns,
|
||||
cuesheetMode,
|
||||
setCuesheetMode,
|
||||
optionsStore,
|
||||
handleResetResizing,
|
||||
handleResetReordering,
|
||||
handleClearToggles,
|
||||
}: CuesheetTableSettingsProps) {
|
||||
insertElement,
|
||||
modeControls,
|
||||
showShare = false,
|
||||
}: CuesheetTableHeaderToolbarProps) {
|
||||
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode);
|
||||
const canShare = useCuesheetPermissions((state) => state.canShare);
|
||||
const options = usePersistedCuesheetOptions();
|
||||
|
||||
const toggleCuesheetMode = (mode: AppMode[]) => {
|
||||
// we need to stop user from deselecting a mode
|
||||
const newValue = mode.at(0);
|
||||
if (!newValue) return;
|
||||
setCuesheetMode(newValue);
|
||||
if (!newValue || !modeControls) return;
|
||||
modeControls.setCuesheetMode(newValue);
|
||||
};
|
||||
|
||||
const isBackground = !(modeControls?.isCurrentRundown ?? true);
|
||||
|
||||
return (
|
||||
<Toolbar.Root className={style.tableSettings}>
|
||||
<ViewSettings optionsStore={options} />
|
||||
<Toolbar.Root className={style.tableSettings} data-background-rundown={isBackground}>
|
||||
<ViewSettings optionsStore={optionsStore} />
|
||||
<ColumnSettings
|
||||
columns={columns}
|
||||
handleResetResizing={handleResetResizing}
|
||||
handleResetReordering={handleResetReordering}
|
||||
handleClearToggles={handleClearToggles}
|
||||
/>
|
||||
{canChangeMode && (
|
||||
<ToggleGroup
|
||||
value={[cuesheetMode]}
|
||||
onValueChange={toggleCuesheetMode}
|
||||
className={cx([style.group, style.apart])}
|
||||
>
|
||||
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
|
||||
Run
|
||||
</Toolbar.Button>
|
||||
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
|
||||
Edit
|
||||
</Toolbar.Button>
|
||||
</ToggleGroup>
|
||||
{modeControls && canChangeMode && (
|
||||
<div className={style.apart}>
|
||||
{insertElement}
|
||||
<ToggleGroup
|
||||
value={[modeControls.cuesheetMode]}
|
||||
onValueChange={toggleCuesheetMode}
|
||||
className={style.group}
|
||||
disabled={!modeControls.isCurrentRundown}
|
||||
>
|
||||
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
|
||||
Run
|
||||
</Toolbar.Button>
|
||||
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
|
||||
Edit
|
||||
</Toolbar.Button>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canShare && (
|
||||
{showShare && canShare && (
|
||||
<>
|
||||
<Editor.Separator orientation='vertical' />
|
||||
<CuesheetShareModal />
|
||||
@@ -92,9 +106,18 @@ export default function CuesheetTableSettings({
|
||||
);
|
||||
}
|
||||
|
||||
export function ViewSettings({ optionsStore }: ViewSettingsProps) {
|
||||
const options = optionsStore;
|
||||
interface ViewSettingsProps {
|
||||
optionsStore: TableHeaderOptionsStore;
|
||||
}
|
||||
|
||||
interface ColumnSettingsProps {
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
handleClearToggles: () => void;
|
||||
}
|
||||
|
||||
function ViewSettings({ optionsStore }: ViewSettingsProps) {
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
@@ -115,15 +138,15 @@ export function ViewSettings({ optionsStore }: ViewSettingsProps) {
|
||||
<Editor.Label className={style.sectionTitle}>Element visibility</Editor.Label>
|
||||
<Editor.Label className={style.option}>
|
||||
<Checkbox
|
||||
defaultChecked={options.hideTableSeconds}
|
||||
onCheckedChange={(checked) => options.setOption('hideTableSeconds', checked)}
|
||||
defaultChecked={optionsStore.hideTableSeconds}
|
||||
onCheckedChange={(checked) => optionsStore.setOption('hideTableSeconds', checked)}
|
||||
/>
|
||||
Hide seconds in table
|
||||
</Editor.Label>
|
||||
<Editor.Label className={style.option}>
|
||||
<Checkbox
|
||||
defaultChecked={options.hideIndexColumn}
|
||||
onCheckedChange={(checked) => options.setOption('hideIndexColumn', checked)}
|
||||
defaultChecked={optionsStore.hideIndexColumn}
|
||||
onCheckedChange={(checked) => optionsStore.setOption('hideIndexColumn', checked)}
|
||||
/>
|
||||
Hide index column
|
||||
</Editor.Label>
|
||||
@@ -133,15 +156,15 @@ export function ViewSettings({ optionsStore }: ViewSettingsProps) {
|
||||
<Editor.Label className={style.sectionTitle}>Table Behaviour</Editor.Label>
|
||||
<Editor.Label className={style.option}>
|
||||
<Checkbox
|
||||
defaultChecked={options.showDelayedTimes}
|
||||
onCheckedChange={(checked) => options.setOption('showDelayedTimes', checked)}
|
||||
defaultChecked={optionsStore.showDelayedTimes}
|
||||
onCheckedChange={(checked) => optionsStore.setOption('showDelayedTimes', checked)}
|
||||
/>
|
||||
Show delayed times
|
||||
</Editor.Label>
|
||||
<Editor.Label className={style.option}>
|
||||
<Checkbox
|
||||
defaultChecked={options.hideDelays}
|
||||
onCheckedChange={(checked) => options.setOption('hideDelays', checked)}
|
||||
defaultChecked={optionsStore.hideDelays}
|
||||
onCheckedChange={(checked) => optionsStore.setOption('hideDelays', checked)}
|
||||
/>
|
||||
Hide delay entries
|
||||
</Editor.Label>
|
||||
@@ -151,7 +174,7 @@ export function ViewSettings({ optionsStore }: ViewSettingsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ColumnSettings({
|
||||
function ColumnSettings({
|
||||
columns,
|
||||
handleResetResizing,
|
||||
handleResetReordering,
|
||||
@@ -177,6 +200,7 @@ export function ColumnSettings({
|
||||
{columns.map((column) => {
|
||||
const columnHeader = column.columnDef.header;
|
||||
const visible = column.getIsVisible();
|
||||
|
||||
return (
|
||||
<Editor.Label key={`${column.id}-${visible}`} className={style.option}>
|
||||
<Checkbox defaultChecked={visible} onCheckedChange={column.toggleVisibility} />
|
||||
+28
-2
@@ -8,6 +8,24 @@
|
||||
gap: 1rem;
|
||||
font-size: $inner-section-text-size;
|
||||
border-radius: 3px 3px 0 0;
|
||||
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
&[data-background-rundown='true'] {
|
||||
background-color: rgba($ontime-color, 0.5);
|
||||
|
||||
.apart::before {
|
||||
content: 'BACKGROUND EDIT';
|
||||
color: $ui-white;
|
||||
animation: blinker 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blinker {
|
||||
50% {
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
@@ -43,6 +61,9 @@
|
||||
|
||||
.apart {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/** styles should match RundownHeader.module.scss */
|
||||
@@ -70,12 +91,17 @@
|
||||
font-size: calc(1rem - 2px);
|
||||
font-weight: 600;
|
||||
|
||||
&[data-disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
background: transparent;
|
||||
outline: 1px solid $blue-500;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
&:hover:not([data-disabled]):not(:active) {
|
||||
background: $gray-1000;
|
||||
}
|
||||
|
||||
@@ -87,7 +113,7 @@
|
||||
background: $blue-700;
|
||||
color: $ui-white;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
&:hover:not([data-disabled]):not(:active) {
|
||||
background: $blue-600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,33 @@ import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { sessionScope } from '../../externals';
|
||||
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
||||
import { getCuesheetPermissionsPolicy } from './cuesheet.policies';
|
||||
import type { CuesheetPermissions } from './useTablePermissions';
|
||||
import { useCuesheetPermissions } from './useTablePermissions';
|
||||
|
||||
type CuesheetPolicyOptions = {
|
||||
// Run mode is only meaningful for the loaded rundown — background rundowns force Edit.
|
||||
canRunMode: boolean;
|
||||
};
|
||||
|
||||
export function getEffectiveCuesheetMode(
|
||||
permissions: Pick<CuesheetPermissions, 'canChangeMode'>,
|
||||
storedMode: AppMode,
|
||||
{ canRunMode }: CuesheetPolicyOptions,
|
||||
) {
|
||||
if (!permissions.canChangeMode) {
|
||||
return AppMode.Run;
|
||||
}
|
||||
|
||||
return canRunMode ? storedMode : AppMode.Edit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies cuesheet permissions to shared state and exposes the effective mode for the UI.
|
||||
*/
|
||||
export function useApplyCuesheetPolicy(preset: URLPreset | undefined): {
|
||||
export function useApplyCuesheetPolicy(
|
||||
preset: URLPreset | undefined,
|
||||
{ canRunMode }: CuesheetPolicyOptions,
|
||||
): {
|
||||
cuesheetMode: AppMode;
|
||||
setCuesheetMode: (mode: AppMode) => void;
|
||||
} {
|
||||
@@ -26,16 +47,14 @@ export function useApplyCuesheetPolicy(preset: URLPreset | undefined): {
|
||||
defaultValue: preset ? AppMode.Run : AppMode.Edit,
|
||||
});
|
||||
|
||||
const cuesheetMode = permissions.canChangeMode ? storedCuesheetMode : AppMode.Run;
|
||||
const cuesheetMode = getEffectiveCuesheetMode(permissions, storedCuesheetMode, { canRunMode });
|
||||
|
||||
const setCuesheetMode = useCallback(
|
||||
(mode: AppMode) => {
|
||||
if (!permissions.canChangeMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!permissions.canChangeMode || !canRunMode) return;
|
||||
setStoredCuesheetMode(mode);
|
||||
},
|
||||
[permissions.canChangeMode, setStoredCuesheetMode],
|
||||
[canRunMode, permissions.canChangeMode, setStoredCuesheetMode],
|
||||
);
|
||||
|
||||
// Keep the shared permissions store aligned with the active preset policy.
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user