mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-10 09:53:48 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69aeb4aabb | |||
| 34ca715fb7 | |||
| b981bae63f |
@@ -6,7 +6,8 @@ import SwatchPicker from './SwatchPicker';
|
||||
import style from './SwatchSelect.module.scss';
|
||||
|
||||
interface ColourInputProps {
|
||||
value: string;
|
||||
/** undefined represents values which do not agree, no swatch is highlighted */
|
||||
value: string | undefined;
|
||||
name: 'colour';
|
||||
handleChange: (newValue: 'colour', name: string) => void;
|
||||
}
|
||||
@@ -44,7 +45,11 @@ export default function SwatchSelect(props: ColourInputProps) {
|
||||
{colours.map((colour) => (
|
||||
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
|
||||
))}
|
||||
<SwatchPicker color={value} onChange={setColour} isSelected={!colours.includes(value)} />
|
||||
<SwatchPicker
|
||||
color={value ?? ''}
|
||||
onChange={setColour}
|
||||
isSelected={value !== undefined && !colours.includes(value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { shouldSubmitValue } from '../useReactiveTextInput';
|
||||
|
||||
describe('shouldSubmitValue()', () => {
|
||||
it('submits a value which differs from the initial value', () => {
|
||||
expect(shouldSubmitValue('new', 'old')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not submit a value which has not changed', () => {
|
||||
expect(shouldSubmitValue('same', 'same')).toBe(false);
|
||||
});
|
||||
|
||||
it('submits an empty value when the initial value is known', () => {
|
||||
// clearing a field which has a value is a valid edit
|
||||
expect(shouldSubmitValue('', 'old')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not submit an empty value when the initial value is unknown', () => {
|
||||
// an unknown value renders as an empty field, submitting it on blur
|
||||
// would overwrite every entry being edited with an empty value
|
||||
expect(shouldSubmitValue('', undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('submits a typed value when the initial value is unknown', () => {
|
||||
expect(shouldSubmitValue('typed', undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('submits an unchanged value when the caller opts in', () => {
|
||||
expect(shouldSubmitValue('same', 'same', true)).toBe(true);
|
||||
expect(shouldSubmitValue('', undefined, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,23 @@
|
||||
import { HotkeyItem, getHotkeyHandler } from '@mantine/hooks';
|
||||
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Whether a value should be sent to the server
|
||||
* An undefined initial value means that the value is unknown, for example when several
|
||||
* entries are being edited and they do not agree on a value
|
||||
* It is shown as an empty field, so submitting it would overwrite data the user has not edited
|
||||
*/
|
||||
export function shouldSubmitValue(
|
||||
valueToSubmit: string,
|
||||
initialText: string | undefined,
|
||||
allowSubmitSameValue?: boolean,
|
||||
): boolean {
|
||||
if (allowSubmitSameValue) {
|
||||
return true;
|
||||
}
|
||||
return valueToSubmit !== (initialText ?? '');
|
||||
}
|
||||
|
||||
interface UseReactiveTextInputReturn {
|
||||
value: string;
|
||||
onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
||||
@@ -9,7 +26,8 @@ interface UseReactiveTextInputReturn {
|
||||
}
|
||||
|
||||
export default function useReactiveTextInput(
|
||||
initialText: string,
|
||||
/** undefined represents an unknown value, the field is shown empty and blurring it submits nothing */
|
||||
initialText: string | undefined,
|
||||
submitCallback: (newValue: string) => void,
|
||||
ref: RefObject<HTMLInputElement | HTMLTextAreaElement | null>,
|
||||
options?: {
|
||||
@@ -22,7 +40,7 @@ export default function useReactiveTextInput(
|
||||
allowKeyboardNavigation?: boolean;
|
||||
},
|
||||
): UseReactiveTextInputReturn {
|
||||
const [text, setText] = useState<string>(initialText);
|
||||
const [text, setText] = useState<string>(initialText ?? '');
|
||||
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
|
||||
const isKeyboardSubmitting = useRef(false);
|
||||
// track escape to prevent the subsequent blur from submitting
|
||||
@@ -57,8 +75,7 @@ export default function useReactiveTextInput(
|
||||
*/
|
||||
const handleSubmit = useCallback(
|
||||
(valueToSubmit: string) => {
|
||||
// No need to update if it hasn't changed
|
||||
if (valueToSubmit === initialText && !options?.allowSubmitSameValue) {
|
||||
if (!shouldSubmitValue(valueToSubmit, initialText, options?.allowSubmitSameValue)) {
|
||||
options?.onCancelUpdate?.();
|
||||
} else {
|
||||
const cleanVal = valueToSubmit.trim();
|
||||
@@ -86,10 +103,10 @@ export default function useReactiveTextInput(
|
||||
const handleEscape = useCallback(() => {
|
||||
isEscaping.current = true;
|
||||
// No need to update if it hasn't changed
|
||||
setText(initialText);
|
||||
setText(initialText ?? '');
|
||||
// force the text to be the initial value
|
||||
if (ref.current) {
|
||||
ref.current.value = initialText;
|
||||
ref.current.value = initialText ?? '';
|
||||
}
|
||||
options?.onCancelUpdate?.();
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring
|
||||
|
||||
@@ -41,7 +41,10 @@ export default function TimeInput<T extends string>({
|
||||
* @description Resets input value to given
|
||||
*/
|
||||
const resetValue = useCallback(() => {
|
||||
if (typeof time !== 'number' || isNaN(time)) {
|
||||
if (time === undefined) {
|
||||
// there is no value to show, we leave the field empty so that the placeholder is visible
|
||||
setValue('');
|
||||
} else if (typeof time !== 'number' || isNaN(time)) {
|
||||
setValue('00:00:00');
|
||||
} else if (shouldFormat) {
|
||||
setValue(formatTime(time));
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
.wide {
|
||||
top: 4vh;
|
||||
min-width: min(1280px, 96vw);
|
||||
max-width: min(1800px, 98vw);
|
||||
max-width: min(1600px, 96vw);
|
||||
height: 88vh;
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
|
||||
@@ -16,13 +16,21 @@ interface SelectProps<T> extends Omit<BaseSelect.Root.Props<T>, 'items'> {
|
||||
options: SelectOption<T>[];
|
||||
fluid?: boolean;
|
||||
size?: 'medium' | 'large';
|
||||
/** shown when the value is null, used to represent values which do not agree */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export default function Select<T>({ options, fluid, size = 'medium', ...selectRootProps }: SelectProps<T>) {
|
||||
export default function Select<T>({
|
||||
options,
|
||||
fluid,
|
||||
size = 'medium',
|
||||
placeholder,
|
||||
...selectRootProps
|
||||
}: SelectProps<T>) {
|
||||
return (
|
||||
<BaseSelect.Root items={options} {...selectRootProps}>
|
||||
<BaseSelect.Trigger className={cx([styles.select, styles[size], fluid && styles.fluid])}>
|
||||
<BaseSelect.Value />
|
||||
<BaseSelect.Value placeholder={placeholder} />
|
||||
<BaseSelect.Icon className={styles.selectIcon}>
|
||||
<LuChevronsUpDown />
|
||||
</BaseSelect.Icon>
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* values do not agree, we show an empty track instead of a thumb position */
|
||||
.mixed .thumb {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.medium {
|
||||
padding: 2px;
|
||||
--width: calc(2.5rem + 4px);
|
||||
|
||||
@@ -6,11 +6,13 @@ import style from './Switch.module.scss';
|
||||
|
||||
interface SwitchProps extends BaseSwitch.Root.Props {
|
||||
size?: 'medium' | 'large';
|
||||
/** the switch represents several values which do not agree, we hide the thumb */
|
||||
mixed?: boolean;
|
||||
}
|
||||
|
||||
export default function Switch({ size = 'medium', ...switchProps }: SwitchProps) {
|
||||
export default function Switch({ size = 'medium', mixed, ...switchProps }: SwitchProps) {
|
||||
return (
|
||||
<BaseSwitch.Root className={cx([style.switch, style[size]])} {...switchProps}>
|
||||
<BaseSwitch.Root className={cx([style.switch, style[size], mixed && style.mixed])} {...switchProps}>
|
||||
<BaseSwitch.Thumb className={style.thumb} />
|
||||
</BaseSwitch.Root>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { PropsWithChildren, createContext, useContext } from 'react';
|
||||
|
||||
import { useEntryActions, useScopedEntryActions } from '../hooks/useEntryAction';
|
||||
import { useRundownSelectionContext } from './RundownSelectionContext';
|
||||
import { useEntryActions } from '../hooks/useEntryAction';
|
||||
|
||||
type EntryActionsContextValue = ReturnType<typeof useEntryActions>;
|
||||
const EntryActionsContext = createContext<EntryActionsContextValue | null>(null);
|
||||
|
||||
export function EntryActionsProvider({ children }: PropsWithChildren) {
|
||||
const { effectiveRundownId } = useRundownSelectionContext();
|
||||
const actions = useScopedEntryActions(effectiveRundownId);
|
||||
interface EntryActionsProviderProps extends PropsWithChildren {
|
||||
actions: EntryActionsContextValue;
|
||||
}
|
||||
|
||||
export function EntryActionsProvider({ children, actions }: EntryActionsProviderProps) {
|
||||
return <EntryActionsContext.Provider value={actions}>{children}</EntryActionsContext.Provider>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { Maybe, ProjectRundown } from 'ontime-types';
|
||||
import { PropsWithChildren, createContext, startTransition, useCallback, useContext, useEffect, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
|
||||
|
||||
export type RundownScopeValue = {
|
||||
loadedRundownId: string;
|
||||
selectedRundownId: Maybe<string>;
|
||||
isLoadedRundown: boolean;
|
||||
effectiveRundownId: string;
|
||||
selectRundownId: (val: Maybe<string>) => void;
|
||||
rundowns: ProjectRundown[];
|
||||
};
|
||||
|
||||
const RundownScopeContext = createContext<RundownScopeValue | null>(null);
|
||||
|
||||
export function RundownSelectionContextProvider({ children }: PropsWithChildren) {
|
||||
'use memo';
|
||||
const { data } = useProjectRundowns();
|
||||
const { loaded, rundowns } = data;
|
||||
|
||||
const [selectedRundownId, setSelectedRundownId] = useSelectRundownFromParams();
|
||||
|
||||
const selectRundownId = useCallback(
|
||||
(rundownId: Maybe<string>) => {
|
||||
startTransition(() => {
|
||||
if (rundowns.find((entry) => entry.id === rundownId)) setSelectedRundownId(rundownId);
|
||||
else setSelectedRundownId(null);
|
||||
});
|
||||
},
|
||||
[rundowns, setSelectedRundownId],
|
||||
);
|
||||
|
||||
const effectiveRundownId = selectedRundownId ? selectedRundownId : loaded;
|
||||
const isLoadedRundown = effectiveRundownId === loaded;
|
||||
|
||||
useEffect(() => {
|
||||
if (!rundowns.find((entry) => entry.id === effectiveRundownId)) setSelectedRundownId(null);
|
||||
}, [rundowns, effectiveRundownId, setSelectedRundownId]);
|
||||
|
||||
const value = useMemo(
|
||||
(): RundownScopeValue => ({
|
||||
loadedRundownId: loaded,
|
||||
isLoadedRundown,
|
||||
selectedRundownId,
|
||||
effectiveRundownId,
|
||||
selectRundownId,
|
||||
rundowns,
|
||||
}),
|
||||
[loaded, isLoadedRundown, selectedRundownId, effectiveRundownId, selectRundownId, rundowns],
|
||||
);
|
||||
|
||||
return <RundownScopeContext.Provider value={value}>{children}</RundownScopeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useRundownSelectionContext() {
|
||||
const context = useContext(RundownScopeContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useRundownScopeSelection requires a RundownSelectionContextProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const rundownParam = 'rundownId';
|
||||
|
||||
export function useSelectRundownFromParams(): [Maybe<string>, (id: Maybe<string>) => void] {
|
||||
'use memo';
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const selectedRundownId = searchParams.get(rundownParam);
|
||||
const setSelectedRundownId = useCallback(
|
||||
(id: Maybe<string>) => {
|
||||
if (id === null) {
|
||||
setSearchParams((searchParams) => {
|
||||
searchParams.delete(rundownParam);
|
||||
return searchParams;
|
||||
});
|
||||
} else {
|
||||
setSearchParams((searchParams) => {
|
||||
searchParams.set(rundownParam, id);
|
||||
return searchParams;
|
||||
});
|
||||
}
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
return [selectedRundownId, setSelectedRundownId];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* mutates the provided `searchParams`
|
||||
*/
|
||||
export function setSelectRundownInParams(id: Maybe<string>, searchParams: URLSearchParams): void {
|
||||
if (id === null) {
|
||||
searchParams.delete(rundownParam);
|
||||
} else {
|
||||
searchParams.set(rundownParam, id);
|
||||
}
|
||||
}
|
||||
|
||||
export function useDirectLinkToBackgroundEdit() {
|
||||
const navigate = useNavigate();
|
||||
const [search] = useSearchParams();
|
||||
return useCallback(
|
||||
async (rundownId: string) => {
|
||||
setSelectRundownInParams(rundownId, search);
|
||||
navigate({
|
||||
pathname: '/cuesheet',
|
||||
search: search.toString(),
|
||||
});
|
||||
},
|
||||
[navigate, search],
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useRundownSelectionContext } from '../context/RundownSelectionContext';
|
||||
import { useSelectedEventId } from '../hooks/useSocket';
|
||||
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
|
||||
import { useFlatRundown, useRundown } from './useRundown';
|
||||
|
||||
export function useContextRundownEditModal() {
|
||||
'use memo';
|
||||
const { effectiveRundownId } = useRundownSelectionContext();
|
||||
const { data: rundown } = useRundown(effectiveRundownId);
|
||||
return { rundown };
|
||||
}
|
||||
|
||||
export function useContextRundownCueRenumberModal() {
|
||||
'use memo';
|
||||
const { effectiveRundownId } = useRundownSelectionContext();
|
||||
const { data } = useRundown(effectiveRundownId);
|
||||
const { flatOrder } = data;
|
||||
return { flatOrder };
|
||||
}
|
||||
|
||||
export function useContextRundownList() {
|
||||
'use memo';
|
||||
const { effectiveRundownId, isLoadedRundown } = useRundownSelectionContext();
|
||||
const loadedEventId = useSelectedEventId();
|
||||
const effectiveSelectedEventId = isLoadedRundown ? loadedEventId : null;
|
||||
const { data: rundown, status } = useRundown(effectiveRundownId);
|
||||
const rundownMetadata = useMemo(
|
||||
() => getRundownMetadata(rundown, effectiveSelectedEventId),
|
||||
[effectiveSelectedEventId, rundown],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
rundown,
|
||||
rundownMetadata,
|
||||
status,
|
||||
isLoadedRundown,
|
||||
}),
|
||||
[rundown, rundownMetadata, status, isLoadedRundown],
|
||||
);
|
||||
}
|
||||
|
||||
export function useContextRundownTitleList() {
|
||||
'use memo';
|
||||
const { effectiveRundownId, isLoadedRundown } = useRundownSelectionContext();
|
||||
const loadedEventId = useSelectedEventId();
|
||||
const effectiveSelectedEventId = isLoadedRundown ? loadedEventId : null;
|
||||
const { data: rundown, status } = useRundown(effectiveRundownId);
|
||||
const flatRundown = useMemo(() => {
|
||||
const flatData = getFlatRundownMetadata(rundown, effectiveSelectedEventId);
|
||||
return flatData.filter(isOntimeEvent) as ExtendedEntry<OntimeEvent>[];
|
||||
}, [effectiveSelectedEventId, rundown]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
flatRundown,
|
||||
status,
|
||||
isLoadedRundown,
|
||||
}),
|
||||
[status, isLoadedRundown, flatRundown],
|
||||
);
|
||||
}
|
||||
|
||||
export function useContextRundownTable() {
|
||||
'use memo';
|
||||
const { effectiveRundownId, isLoadedRundown } = useRundownSelectionContext();
|
||||
const loadedEventId = useSelectedEventId();
|
||||
const effectiveSelectedEventId = isLoadedRundown ? loadedEventId : null;
|
||||
const { data: rundown, status } = useRundown(effectiveRundownId);
|
||||
const flatRundown = useMemo(
|
||||
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
|
||||
[effectiveSelectedEventId, rundown],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
flatRundown,
|
||||
status,
|
||||
loadedEventId,
|
||||
}),
|
||||
[flatRundown, status, loadedEventId],
|
||||
);
|
||||
}
|
||||
|
||||
export function useContextRundownFinder() {
|
||||
'use memo';
|
||||
const { effectiveRundownId } = useRundownSelectionContext();
|
||||
const { data: rundown, status } = useFlatRundown(effectiveRundownId);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
rundown,
|
||||
rundownId: effectiveRundownId,
|
||||
status,
|
||||
}),
|
||||
[rundown, status, effectiveRundownId],
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,24 @@
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { ProjectFile, ProjectFileList } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { PROJECT_LIST } from '../api/constants';
|
||||
import { getProjects } from '../api/db';
|
||||
|
||||
export function useProjectList() {
|
||||
const { data, status, refetch } = useSuspenseQuery({
|
||||
const placeholderProjectList: ProjectFileListResponse = {
|
||||
files: [],
|
||||
lastLoadedProject: '',
|
||||
};
|
||||
|
||||
function useProjectList() {
|
||||
const { data, status, refetch } = useQuery({
|
||||
queryKey: PROJECT_LIST,
|
||||
queryFn: ({ signal }) => getProjects({ signal }),
|
||||
staleTime: MILLIS_PER_HOUR,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
return { data, status, refetch };
|
||||
return { data: data ?? placeholderProjectList, status, refetch };
|
||||
}
|
||||
|
||||
export type ProjectSortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'modified-asc' | 'modified-desc';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ProjectRundownsList } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { PROJECT_RUNDOWNS } from '../api/constants';
|
||||
import {
|
||||
createRundown,
|
||||
@@ -11,21 +11,24 @@ import {
|
||||
loadRundown,
|
||||
renameRundown,
|
||||
} from '../api/rundown';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
|
||||
//TODO: make suspends so we don't have to deal with no value all over
|
||||
/**
|
||||
* Project rundowns
|
||||
*/
|
||||
export function useProjectRundowns() {
|
||||
const { data, status, isError, refetch, isFetching } = useSuspenseQuery<ProjectRundownsList>({
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
|
||||
queryKey: PROJECT_RUNDOWNS,
|
||||
queryFn: ({ signal }) => fetchProjectRundownList({ signal }),
|
||||
staleTime: MILLIS_PER_HOUR,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
return { data, status, isError, refetch, isFetching };
|
||||
return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching };
|
||||
}
|
||||
|
||||
export function useMutateProjectRundowns() {
|
||||
const ontimeQueryClient = useQueryClient();
|
||||
|
||||
const { mutateAsync: create } = useMutation({
|
||||
mutationFn: createRundown,
|
||||
onMutate: () => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { EntryId, Maybe, OntimeEntry, Rundown } from 'ontime-types';
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { getRundownQueryKey } from '../api/constants';
|
||||
import { fetchRundown } from '../api/rundown';
|
||||
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
|
||||
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
|
||||
import { useSelectedEventId } from '../hooks/useSocket';
|
||||
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
|
||||
import { useProjectRundowns } from './useProjectRundowns';
|
||||
@@ -20,36 +20,43 @@ const cachedRundownPlaceholder: Rundown = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides access to a specific rundown by ID.
|
||||
* When rundownId is not provided the loaded rundown is provided
|
||||
* Normalised rundown data for the currently loaded rundown.
|
||||
*
|
||||
* Bootstraps via the `/current` alias so the first paint is a single round-trip,
|
||||
* independent of the project rundown list. Once the loaded id is known, the
|
||||
* query key swaps to the id-keyed cache that is shared with `useRundownById`.
|
||||
*/
|
||||
export function useRundown(rundownId: Maybe<string>) {
|
||||
'use memo';
|
||||
|
||||
export default function useRundown() {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
data: { loaded: loadedRundownId },
|
||||
} = useProjectRundowns();
|
||||
|
||||
const effectiveRundownId = rundownId !== null ? rundownId : loadedRundownId;
|
||||
const isLoadedRundown = effectiveRundownId === loadedRundownId;
|
||||
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
|
||||
queryKey: getRundownQueryKey(effectiveRundownId),
|
||||
queryFn: ({ signal }) => fetchRundown(effectiveRundownId, { signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
|
||||
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
|
||||
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching, isLoadedRundown };
|
||||
// Seed the id-keyed cache when fetching via the bootstrap alias
|
||||
useEffect(() => {
|
||||
if (!data || loadedRundownId) return;
|
||||
queryClient.setQueryData(getRundownQueryKey(data.id), data);
|
||||
}, [data, loadedRundownId, queryClient]);
|
||||
|
||||
// Once we have the ID, drop the temporary current cache
|
||||
useEffect(() => {
|
||||
if (!loadedRundownId) return;
|
||||
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
||||
}, [loadedRundownId, queryClient]);
|
||||
|
||||
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
|
||||
}
|
||||
|
||||
export function useRundownWithMetadata(rundownId: Maybe<string>) {
|
||||
'use memo';
|
||||
|
||||
const { data, status, isLoadedRundown } = useRundown(rundownId);
|
||||
export function useRundownWithMetadata() {
|
||||
const { data, status } = useRundown();
|
||||
const selectedEventId = useSelectedEventId();
|
||||
const effectiveSelectedEventId = isLoadedRundown ? selectedEventId : null;
|
||||
const rundownMetadata = getRundownMetadata(data, effectiveSelectedEventId);
|
||||
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
|
||||
return { data, status, rundownMetadata };
|
||||
}
|
||||
|
||||
@@ -57,8 +64,8 @@ export function useRundownWithMetadata(rundownId: Maybe<string>) {
|
||||
* Provides access to a flat rundown
|
||||
* built from the order and rundown fields
|
||||
*/
|
||||
export function useFlatRundown(rundownId: Maybe<string>) {
|
||||
const { data, status } = useRundown(rundownId);
|
||||
export function useFlatRundown() {
|
||||
const { data, status } = useRundown();
|
||||
|
||||
const flatRundown = useMemo(() => {
|
||||
if (data.revision === -1) {
|
||||
@@ -70,13 +77,11 @@ export function useFlatRundown(rundownId: Maybe<string>) {
|
||||
return { data: flatRundown, rundownId: data.id, status };
|
||||
}
|
||||
|
||||
export function useFlatRundownWithMetadata(rundownId: Maybe<string>) {
|
||||
'use memo';
|
||||
|
||||
const { data, status, isLoadedRundown } = useRundown(rundownId);
|
||||
export function useFlatRundownWithMetadata() {
|
||||
const { data, status } = useRundown();
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -87,8 +92,8 @@ export function useFlatRundownWithMetadata(rundownId: Maybe<string>) {
|
||||
* re-filtering on every render.
|
||||
*
|
||||
*/
|
||||
export function usePartialRundown(rundownId: Maybe<string>, cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
|
||||
const { data, status } = useFlatRundownWithMetadata(rundownId);
|
||||
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
|
||||
const { data, status } = useFlatRundownWithMetadata();
|
||||
const filteredData = useMemo(() => {
|
||||
return data.filter(cb);
|
||||
}, [data, cb]);
|
||||
@@ -98,20 +103,37 @@ export function usePartialRundown(rundownId: Maybe<string>, cb: (event: Extended
|
||||
|
||||
/**
|
||||
* Hook to get a specific entry by ID from the rundown
|
||||
* @deprecated
|
||||
*/
|
||||
export function useEntry(rundownId: Maybe<string>, entryId: EntryId | null): OntimeEntry | null {
|
||||
const { data: rundown } = useRundown(rundownId);
|
||||
export function useEntry(entryId: EntryId | null): OntimeEntry | null {
|
||||
const { data: rundown } = useRundown();
|
||||
|
||||
if (entryId === null) return null;
|
||||
return rundown.entries[entryId] ?? null;
|
||||
}
|
||||
|
||||
export function useRundownAuxData(rundownId: Maybe<string>) {
|
||||
const { data, status } = useRundown(rundownId);
|
||||
export function useRundownAuxData() {
|
||||
const { data, status } = useRundown();
|
||||
const filteredData = useMemo(() => {
|
||||
const { title, id } = data;
|
||||
return { title, id };
|
||||
}, [data]);
|
||||
return { data: filteredData, status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides access to a specific rundown by ID.
|
||||
* When rundownId is null/undefined the query is disabled and returns the placeholder.
|
||||
*/
|
||||
export function useRundownById(rundownId: string | null | undefined) {
|
||||
const enabled = Boolean(rundownId);
|
||||
|
||||
const { data, status, isError, 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],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { OntimeEvent, OntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { applyPatchToEvents, canPredictBatchResult } from '../entryAction.utils';
|
||||
|
||||
function makeEntries(): RundownEntries {
|
||||
return {
|
||||
'1': { type: SupportedEntry.Event, id: '1', title: 'one', custom: { lx: 'a', sound: 'x' } } as OntimeEvent,
|
||||
'2': { type: SupportedEntry.Event, id: '2', title: 'two', custom: { lx: 'b' } } as OntimeEvent,
|
||||
'3': { type: SupportedEntry.Event, id: '3', title: 'three', custom: {} } as OntimeEvent,
|
||||
group: { type: SupportedEntry.Group, id: 'group', title: 'a group', custom: {} } as OntimeGroup,
|
||||
};
|
||||
}
|
||||
|
||||
describe('applyPatchToEvents()', () => {
|
||||
it('applies the patch to the given events only', () => {
|
||||
const patched = applyPatchToEvents(makeEntries(), ['1', '2'], { title: 'patched' });
|
||||
|
||||
expect((patched['1'] as OntimeEvent).title).toBe('patched');
|
||||
expect((patched['2'] as OntimeEvent).title).toBe('patched');
|
||||
expect((patched['3'] as OntimeEvent).title).toBe('three');
|
||||
});
|
||||
|
||||
it('patches custom fields instead of replacing them', () => {
|
||||
const patched = applyPatchToEvents(makeEntries(), ['1', '2'], { custom: { lx: 'new' } });
|
||||
|
||||
// the field which was not part of the patch must survive
|
||||
expect((patched['1'] as OntimeEvent).custom).toStrictEqual({ lx: 'new', sound: 'x' });
|
||||
expect((patched['2'] as OntimeEvent).custom).toStrictEqual({ lx: 'new' });
|
||||
});
|
||||
|
||||
it('keeps the existing custom fields when the patch has none', () => {
|
||||
const patched = applyPatchToEvents(makeEntries(), ['1'], { title: 'patched' });
|
||||
|
||||
expect((patched['1'] as OntimeEvent).custom).toStrictEqual({ lx: 'a', sound: 'x' });
|
||||
});
|
||||
|
||||
it('ignores ids which are not in the rundown', () => {
|
||||
const patched = applyPatchToEvents(makeEntries(), ['1', 'does-not-exist'], { title: 'patched' });
|
||||
|
||||
expect(Object.keys(patched)).toStrictEqual(['1', '2', '3', 'group']);
|
||||
expect((patched['1'] as OntimeEvent).title).toBe('patched');
|
||||
});
|
||||
|
||||
it('ignores entries which are not events', () => {
|
||||
const patched = applyPatchToEvents(makeEntries(), ['group'], { title: 'patched' });
|
||||
|
||||
expect((patched.group as OntimeGroup).title).toBe('a group');
|
||||
});
|
||||
|
||||
it('does not mutate the given entries', () => {
|
||||
const entries = makeEntries();
|
||||
applyPatchToEvents(entries, ['1'], { title: 'patched', custom: { lx: 'new' } });
|
||||
|
||||
expect((entries['1'] as OntimeEvent).title).toBe('one');
|
||||
expect((entries['1'] as OntimeEvent).custom).toStrictEqual({ lx: 'a', sound: 'x' });
|
||||
});
|
||||
|
||||
it('handles repeated ids', () => {
|
||||
const patched = applyPatchToEvents(makeEntries(), ['1', '1'], { title: 'patched' });
|
||||
|
||||
expect((patched['1'] as OntimeEvent).title).toBe('patched');
|
||||
});
|
||||
});
|
||||
|
||||
describe('canPredictBatchResult()', () => {
|
||||
it('allows resolving patches which do not affect the schedule', () => {
|
||||
expect(canPredictBatchResult({ title: 'a title' })).toBe(true);
|
||||
expect(canPredictBatchResult({ colour: 'red', flag: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('defers to the server when the duration changes', () => {
|
||||
// a duration cascades into the start and end of every linked event
|
||||
expect(canPredictBatchResult({ duration: 1000 })).toBe(false);
|
||||
expect(canPredictBatchResult({ title: 'a title', duration: 1000 })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { EntryId, OntimeEvent, RundownEntries, isOntimeEvent } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* Applies a patch to a set of events, used to optimistically resolve a batch edit
|
||||
* Entries which are missing or are not events are left untouched, mirroring the server
|
||||
* @returns a new entries object, the given entries are not mutated
|
||||
*/
|
||||
export function applyPatchToEvents(
|
||||
entries: RundownEntries,
|
||||
ids: EntryId[],
|
||||
patch: Partial<OntimeEvent>,
|
||||
): RundownEntries {
|
||||
const patchedEntries = { ...entries };
|
||||
|
||||
for (const id of new Set(ids)) {
|
||||
const entry = patchedEntries[id];
|
||||
if (!isOntimeEvent(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
patchedEntries[id] = {
|
||||
...entry,
|
||||
...patch,
|
||||
// custom fields are patched, not replaced
|
||||
custom: patch.custom ? { ...entry.custom, ...patch.custom } : entry.custom,
|
||||
};
|
||||
}
|
||||
|
||||
return patchedEntries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the result of a batch edit can be resolved without the server
|
||||
* Changing the duration cascades through the rundown, so the resulting
|
||||
* schedule is only known once the server has recalculated it
|
||||
*/
|
||||
export function canPredictBatchResult(patch: Partial<OntimeEvent>): boolean {
|
||||
return !('duration' in patch);
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from '../api/rundown';
|
||||
import { logAxiosError } from '../api/utils';
|
||||
import { useEditorSettings } from '../stores/editorSettings';
|
||||
import { applyPatchToEvents, canPredictBatchResult } from './entryAction.utils';
|
||||
|
||||
export type EventOptions = Partial<{
|
||||
// options of any new entries (event / delay / group)
|
||||
@@ -483,21 +484,9 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||
// Snapshot the previous value
|
||||
const previousRundown = queryClient.getQueryData<Rundown>(queryKey);
|
||||
|
||||
if (previousRundown) {
|
||||
const eventIds = new Set(data.ids);
|
||||
const newRundown = { ...previousRundown.entries };
|
||||
|
||||
eventIds.forEach((eventId) => {
|
||||
if (Object.hasOwn(newRundown, eventId)) {
|
||||
const event = newRundown[eventId];
|
||||
if (isOntimeEvent(event)) {
|
||||
newRundown[eventId] = {
|
||||
...event,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
// when the result cannot be resolved here we wait for the recalculated rundown from the server
|
||||
if (previousRundown && canPredictBatchResult(data.data)) {
|
||||
const newRundown = applyPatchToEvents(previousRundown.entries, data.ids, data.data);
|
||||
|
||||
queryClient.setQueryData<Rundown>(queryKey, {
|
||||
id: previousRundown.id,
|
||||
|
||||
@@ -90,7 +90,6 @@ describe('initRundownMetadata()', () => {
|
||||
eventIndex: 0,
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
isParentToLoaded: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
@@ -108,7 +107,6 @@ describe('initRundownMetadata()', () => {
|
||||
eventIndex: 1, // UI indexes are 1 based
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
isParentToLoaded: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
@@ -126,7 +124,6 @@ describe('initRundownMetadata()', () => {
|
||||
eventIndex: 1,
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
isParentToLoaded: true,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
@@ -143,7 +140,6 @@ describe('initRundownMetadata()', () => {
|
||||
eventIndex: 2,
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
isParentToLoaded: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
@@ -160,7 +156,6 @@ describe('initRundownMetadata()', () => {
|
||||
eventIndex: 2,
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
isParentToLoaded: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
@@ -177,7 +172,6 @@ describe('initRundownMetadata()', () => {
|
||||
eventIndex: 3,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
isParentToLoaded: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: true,
|
||||
isLoaded: true,
|
||||
@@ -194,7 +188,6 @@ describe('initRundownMetadata()', () => {
|
||||
eventIndex: 4,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
isParentToLoaded: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: true,
|
||||
isLoaded: false,
|
||||
@@ -211,7 +204,6 @@ describe('initRundownMetadata()', () => {
|
||||
eventIndex: 5,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
isParentToLoaded: false,
|
||||
totalGap: 7,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
@@ -264,7 +256,6 @@ describe('initRundownMetadata()', () => {
|
||||
eventIndex: 0,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
isParentToLoaded: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
@@ -282,7 +273,6 @@ describe('initRundownMetadata()', () => {
|
||||
eventIndex: 1,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
isParentToLoaded: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
@@ -300,7 +290,6 @@ describe('initRundownMetadata()', () => {
|
||||
eventIndex: 2,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
isParentToLoaded: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
|
||||
@@ -27,7 +27,6 @@ export type RundownMetadata = {
|
||||
groupColour: string | undefined;
|
||||
groupEntries: number | undefined;
|
||||
isFirstAfterGroup: boolean;
|
||||
isParentToLoaded: boolean; // if the group contains the loaded event
|
||||
};
|
||||
|
||||
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata;
|
||||
@@ -95,7 +94,6 @@ export function initRundownMetadata(selectedEventId: MaybeString) {
|
||||
groupColour: undefined,
|
||||
groupEntries: undefined,
|
||||
isFirstAfterGroup: false,
|
||||
isParentToLoaded: false,
|
||||
};
|
||||
|
||||
function process(entry: OntimeEntry): Readonly<RundownMetadata> {
|
||||
@@ -119,7 +117,6 @@ function processEntry(
|
||||
// initialise data to be overridden below
|
||||
processedData.isNextDay = false;
|
||||
processedData.isLoaded = false;
|
||||
processedData.isParentToLoaded = false;
|
||||
|
||||
processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration
|
||||
processedData.thisId = entry.id; // we reassign thisId
|
||||
@@ -135,7 +132,6 @@ function processEntry(
|
||||
processedData.groupId = entry.id;
|
||||
processedData.groupColour = entry.colour;
|
||||
processedData.groupEntries = entry.entries.length;
|
||||
processedData.isParentToLoaded = selectedEventId ? entry.entries.includes(selectedEventId) : false;
|
||||
} else {
|
||||
// for delays and groups, we insert the group metadata
|
||||
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
APP_SETTINGS,
|
||||
CLIENT_LIST,
|
||||
CSS_OVERRIDE,
|
||||
CURRENT_RUNDOWN_QUERY_KEY,
|
||||
CUSTOM_FIELDS,
|
||||
PROJECT_DATA,
|
||||
REPORT,
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
VIEW_SETTINGS,
|
||||
getRundownQueryKey,
|
||||
PROJECT_RUNDOWNS,
|
||||
PROJECT_LIST,
|
||||
} from '../api/constants';
|
||||
import { invalidateAllCaches } from '../api/utils';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
@@ -216,9 +216,6 @@ export const connectSocket = () => {
|
||||
case RefetchKey.ProjectRundowns:
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS });
|
||||
break;
|
||||
case RefetchKey.ProjectFiles:
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_LIST });
|
||||
break;
|
||||
default: {
|
||||
target satisfies never;
|
||||
break;
|
||||
@@ -241,6 +238,7 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
|
||||
if (!rundownId) {
|
||||
// we omit rundownId to signify invalidate all rundowns
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -254,6 +252,12 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
|
||||
}
|
||||
|
||||
ontimeQueryClient.invalidateQueries({ queryKey, exact: true });
|
||||
|
||||
// keep current alias in sync with the ID-based cache
|
||||
const loadedRundownId = ontimeQueryClient.getQueryData<{ loaded: string }>(PROJECT_RUNDOWNS)?.loaded;
|
||||
if (!loadedRundownId || loadedRundownId === rundownId) {
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function sendSocket<T extends MessageTag | ApiActionTag>(
|
||||
|
||||
@@ -5,7 +5,7 @@ import { deleteAllReport } from '../../../../common/api/report';
|
||||
import { createBlob, downloadBlob } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import useReport from '../../../../common/hooks-query/useReport';
|
||||
import { useRundown } from '../../../../common/hooks-query/useRundown';
|
||||
import useRundown from '../../../../common/hooks-query/useRundown';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../../common/utils/time';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
@@ -15,7 +15,7 @@ import style from './ReportSettings.module.scss';
|
||||
|
||||
export default function ReportSettings() {
|
||||
const { data: reportData } = useReport();
|
||||
const { data } = useRundown(null);
|
||||
const { data } = useRundown();
|
||||
|
||||
const clearReport = async () => await deleteAllReport();
|
||||
const downloadCSV = (combinedReport: CombinedReport[]) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { Suspense, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
IoAdd,
|
||||
IoDocumentOutline,
|
||||
@@ -18,9 +18,9 @@ import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Dialog from '../../../../common/components/dialog/Dialog';
|
||||
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import { useDirectLinkToBackgroundEdit } from '../../../../common/context/RundownSelectionContext';
|
||||
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import RundownRenameForm from './composite/RundownRenameForm';
|
||||
import { ManageRundownForm } from './ManageRundownForm';
|
||||
@@ -28,20 +28,6 @@ import { ManageRundownForm } from './ManageRundownForm';
|
||||
import style from './ManagePanel.module.scss';
|
||||
|
||||
export default function ManageRundowns() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className={style.empty}>
|
||||
<Panel.Loader isLoading />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ManageRundownsSuspense />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ManageRundownsSuspense() {
|
||||
const { data } = useProjectRundowns();
|
||||
const { duplicate, remove, load, rename } = useMutateProjectRundowns();
|
||||
const [isOpenDelete, deleteHandlers] = useDisclosure();
|
||||
|
||||
-7
@@ -25,13 +25,6 @@
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.finishActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sourceGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
+10
-24
@@ -8,7 +8,6 @@ import type {
|
||||
import { getErrorMessage, ImportMap } from 'ontime-utils';
|
||||
import { ChangeEvent, useCallback, useRef, useState } from 'react';
|
||||
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import {
|
||||
getWorksheetMetadata as getExcelWorksheetMetadata,
|
||||
@@ -24,7 +23,7 @@ import Button from '../../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../../common/components/info/Info';
|
||||
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
|
||||
import Modal from '../../../../../common/components/modal/Modal';
|
||||
import { useRundown } from '../../../../../common/hooks-query/useRundown';
|
||||
import useRundown from '../../../../../common/hooks-query/useRundown';
|
||||
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
import GSheetSetup from './GSheetSetup';
|
||||
@@ -57,11 +56,9 @@ export default function SourcesPanel() {
|
||||
const [error, setError] = useState('');
|
||||
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
|
||||
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
|
||||
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
|
||||
|
||||
const { data: currentRundown } = useRundown(null);
|
||||
const { data: currentRundown } = useRundown();
|
||||
const { applyImport } = useSpreadsheetImport();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -104,7 +101,6 @@ export default function SourcesPanel() {
|
||||
setHasFile('none');
|
||||
setActiveSource(null);
|
||||
setError('');
|
||||
setCompletedRundownTitle('');
|
||||
};
|
||||
|
||||
const openGSheetFlow = () => {
|
||||
@@ -127,12 +123,11 @@ export default function SourcesPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinished = (rundownTitle: string) => {
|
||||
const handleFinished = () => {
|
||||
setImportFlow('finished');
|
||||
setHasFile('none');
|
||||
setActiveSource(null);
|
||||
setError('');
|
||||
setCompletedRundownTitle(rundownTitle);
|
||||
};
|
||||
|
||||
const handleApplyImport = async (
|
||||
@@ -144,7 +139,7 @@ export default function SourcesPanel() {
|
||||
if (mode === 'new') {
|
||||
const title = newRundownTitle.trim() || preview.rundown.title;
|
||||
await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields });
|
||||
handleFinished(title);
|
||||
handleFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -161,7 +156,7 @@ export default function SourcesPanel() {
|
||||
customFields: preview.customFields,
|
||||
providedFields,
|
||||
});
|
||||
handleFinished(currentRundown.title);
|
||||
handleFinished();
|
||||
};
|
||||
|
||||
const loadWorksheetMetadata = useCallback(
|
||||
@@ -294,20 +289,11 @@ export default function SourcesPanel() {
|
||||
{showCompleted && (
|
||||
<div className={style.finishSection}>
|
||||
<span className={style.finishBadge}>Import complete</span>
|
||||
<div className={style.finishTitle}>
|
||||
Spreadsheet data applied to {completedRundownTitle || 'your rundown'}.
|
||||
</div>
|
||||
<div className={style.finishDescription}>
|
||||
Review the imported rundown in the editor or start another import.
|
||||
</div>
|
||||
<div className={style.finishActions}>
|
||||
<Button variant='primary' onClick={() => navigate('/editor')}>
|
||||
Open editor
|
||||
</Button>
|
||||
<Button variant='subtle-white' onClick={resetFlow}>
|
||||
Import another
|
||||
</Button>
|
||||
</div>
|
||||
<div className={style.finishTitle}>Spreadsheet data applied.</div>
|
||||
<div className={style.finishDescription}>You can close this flow or start another import.</div>
|
||||
<Button variant='subtle-white' onClick={resetFlow}>
|
||||
Reset flow
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isGSheetFlow && (
|
||||
|
||||
-4
@@ -92,10 +92,6 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toolbarWarning {
|
||||
color: $orange-400;
|
||||
}
|
||||
|
||||
.mappingPaneTitle {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
+2
-17
@@ -132,16 +132,7 @@ export default function SheetImportEditor({
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{toolbarStatus && (
|
||||
<Panel.Description>
|
||||
{toolbarStatus.entries === '–'
|
||||
? 'No import preview yet'
|
||||
: `${toolbarStatus.entries} entries · ${toolbarStatus.groups} groups · ${toolbarStatus.milestones} milestones · ${toolbarStatus.start}–${toolbarStatus.end} · ${toolbarStatus.duration}`}
|
||||
{toolbarStatus.warnings > 0 && (
|
||||
<span className={style.toolbarWarning}> · {toolbarStatus.warnings} warnings</span>
|
||||
)}
|
||||
</Panel.Description>
|
||||
)}
|
||||
{toolbarStatus && <Panel.Description>{toolbarStatus}</Panel.Description>}
|
||||
</Panel.InlineElements>
|
||||
|
||||
<div className={style.editorBody}>
|
||||
@@ -159,19 +150,13 @@ export default function SheetImportEditor({
|
||||
|
||||
<section className={style.previewPane}>
|
||||
<div className={style.previewPaneHeader}>
|
||||
<div className={style.previewPaneHeading}>
|
||||
<span className={style.previewPaneTitle}>Import preview</span>
|
||||
</div>
|
||||
<span className={style.previewPaneTitle}>Import preview</span>
|
||||
</div>
|
||||
<div className={style.tableShell}>
|
||||
<PreviewTable
|
||||
preview={state.preview}
|
||||
columnLabels={columnLabels}
|
||||
canRefresh={canPreview}
|
||||
isLoadingMetadata={isLoadingMetadata}
|
||||
isRefreshing={state.loading === 'preview'}
|
||||
needsPreviewRefresh={state.needsPreviewRefresh}
|
||||
onRefresh={handlePreviewSubmit}
|
||||
worksheetHeaders={worksheetHeaders}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+24
-102
@@ -1,35 +1,27 @@
|
||||
.emptyState {
|
||||
padding: 3rem 1.5rem;
|
||||
height: 100%;
|
||||
min-height: 16rem;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyMessage {
|
||||
width: min(30rem, 100%);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.emptyTitle {
|
||||
margin-bottom: 0.25rem;
|
||||
color: rgba($gray-200, 0.72);
|
||||
font-size: calc(1rem + 2px);
|
||||
font-weight: 400;
|
||||
color: $ui-white;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.emptyBody {
|
||||
color: rgba($gray-200, 0.55);
|
||||
font-size: calc(1rem - 3px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.emptyAction {
|
||||
margin: 1rem auto 0;
|
||||
color: $gray-400;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
color: $ui-white;
|
||||
border-collapse: collapse;
|
||||
font-size: calc(1rem - 2px);
|
||||
text-align: left;
|
||||
table-layout: auto;
|
||||
@@ -42,101 +34,31 @@
|
||||
}
|
||||
|
||||
th {
|
||||
color: $gray-300;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
vertical-align: bottom;
|
||||
font-weight: 400;
|
||||
color: $gray-400;
|
||||
text-transform: capitalize;
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem;
|
||||
min-width: 8rem;
|
||||
max-width: 20rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-bottom: 1px solid $white-10;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
--entry-colour: transparent;
|
||||
background-color: color-mix(in srgb, $gray-1300 96%, var(--entry-colour) 4%);
|
||||
box-shadow: inset 3px 0 var(--entry-colour);
|
||||
tr:nth-child(even) {
|
||||
background-color: $white-1;
|
||||
}
|
||||
|
||||
td[data-empty='true'] {
|
||||
color: $gray-600;
|
||||
}
|
||||
}
|
||||
|
||||
.rowNumber,
|
||||
.rowType {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.rowNumber {
|
||||
left: 0;
|
||||
width: 3.25rem;
|
||||
min-width: 3.25rem !important;
|
||||
color: $gray-400;
|
||||
text-align: right;
|
||||
width: 4.5rem;
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
|
||||
.rowType {
|
||||
left: 3.25rem;
|
||||
width: 6.25rem;
|
||||
min-width: 6.25rem !important;
|
||||
color: $gray-400;
|
||||
font-size: 0.8rem;
|
||||
box-shadow: 1px 0 $white-10;
|
||||
}
|
||||
|
||||
thead .rowNumber,
|
||||
thead .rowType {
|
||||
z-index: 2;
|
||||
background-color: $gray-1350;
|
||||
}
|
||||
|
||||
.numericCell {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.multilineCell {
|
||||
max-width: 30rem !important;
|
||||
overflow-wrap: anywhere;
|
||||
text-overflow: clip !important;
|
||||
white-space: pre-wrap !important;
|
||||
}
|
||||
|
||||
.eventRow {
|
||||
.rowNumber {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.groupRow {
|
||||
background-color: color-mix(in srgb, $gray-1300 88%, var(--entry-colour) 12%) !important;
|
||||
box-shadow: inset 4px 0 var(--entry-colour) !important;
|
||||
font-weight: 600;
|
||||
|
||||
td {
|
||||
min-height: 3.25rem;
|
||||
border-top: 0.75rem solid $gray-1350;
|
||||
}
|
||||
}
|
||||
|
||||
.milestoneRow {
|
||||
background-color: color-mix(in srgb, $gray-1300 93%, var(--entry-colour) 7%) !important;
|
||||
box-shadow: inset 3px 0 var(--entry-colour) !important;
|
||||
color: $gray-300;
|
||||
font-style: italic;
|
||||
width: 7rem;
|
||||
min-width: 7rem;
|
||||
}
|
||||
|
||||
+14
-126
@@ -1,11 +1,7 @@
|
||||
import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types';
|
||||
import { isOntimeDelay, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Button from '../../../../../../../common/components/buttons/Button';
|
||||
import Tag from '../../../../../../../common/components/tag/Tag';
|
||||
import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata';
|
||||
import { getCellValue } from './previewTableUtils';
|
||||
|
||||
import style from './PreviewTable.module.scss';
|
||||
@@ -13,85 +9,14 @@ import style from './PreviewTable.module.scss';
|
||||
interface PreviewTableProps {
|
||||
preview: SpreadsheetPreviewResponse | null;
|
||||
columnLabels: string[];
|
||||
canRefresh: boolean;
|
||||
isLoadingMetadata: boolean;
|
||||
isRefreshing: boolean;
|
||||
needsPreviewRefresh: boolean;
|
||||
onRefresh: () => void;
|
||||
worksheetHeaders: string[];
|
||||
}
|
||||
|
||||
const priorityColumns = ['Title', 'Cue', 'Start', 'End', 'Duration'];
|
||||
const numericColumns = new Set(['Start', 'End', 'Duration', 'Time warning', 'Time danger']);
|
||||
const transparentColour = 'transparent';
|
||||
|
||||
type PreviewEntry = SpreadsheetPreviewResponse['rundown']['entries'][string];
|
||||
|
||||
function getEntryDisplay(entry: PreviewEntry, groupColour?: string) {
|
||||
if (isOntimeGroup(entry)) {
|
||||
return {
|
||||
rowClassName: style.groupRow,
|
||||
entryColour: entry.colour,
|
||||
entryType: 'Group',
|
||||
};
|
||||
}
|
||||
|
||||
const entryColour = groupColour ?? transparentColour;
|
||||
|
||||
if (isOntimeMilestone(entry)) {
|
||||
return {
|
||||
rowClassName: style.milestoneRow,
|
||||
entryColour,
|
||||
entryType: 'Milestone',
|
||||
};
|
||||
}
|
||||
|
||||
if (isOntimeDelay(entry)) {
|
||||
return {
|
||||
rowClassName: style.eventRow,
|
||||
entryColour,
|
||||
entryType: 'Delay',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
rowClassName: style.eventRow,
|
||||
entryColour,
|
||||
entryType: 'Event',
|
||||
};
|
||||
}
|
||||
|
||||
function getCellClassName(label: string, value: string) {
|
||||
if (value.includes('\n')) {
|
||||
return style.multilineCell;
|
||||
}
|
||||
|
||||
if (numericColumns.has(label)) {
|
||||
return style.numericCell;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getDisplayColumns(columnLabels: string[]) {
|
||||
return [...columnLabels].sort((left, right) => {
|
||||
const leftPriority = priorityColumns.indexOf(left);
|
||||
const rightPriority = priorityColumns.indexOf(right);
|
||||
return (
|
||||
(leftPriority === -1 ? priorityColumns.length : leftPriority) -
|
||||
(rightPriority === -1 ? priorityColumns.length : rightPriority)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default function PreviewTable({
|
||||
preview,
|
||||
columnLabels,
|
||||
canRefresh,
|
||||
isLoadingMetadata,
|
||||
isRefreshing,
|
||||
needsPreviewRefresh,
|
||||
onRefresh,
|
||||
worksheetHeaders,
|
||||
}: PreviewTableProps) {
|
||||
const customFieldKeyByLabel = useMemo(() => {
|
||||
@@ -99,57 +24,33 @@ export default function PreviewTable({
|
||||
return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId]));
|
||||
}, [preview]);
|
||||
|
||||
const displayColumns = useMemo(() => getDisplayColumns(columnLabels), [columnLabels]);
|
||||
|
||||
const previewMetadata = useMemo(() => {
|
||||
if (!preview) return null;
|
||||
return getRundownMetadata(preview.rundown, null);
|
||||
}, [preview]);
|
||||
|
||||
if (!preview) {
|
||||
let emptyTitle = 'Preview not generated';
|
||||
let emptyContent = 'Select the fields you want to import, then click Preview import.';
|
||||
|
||||
if (isLoadingMetadata) {
|
||||
emptyTitle = 'Loading worksheet';
|
||||
emptyContent = 'Loading worksheet metadata...';
|
||||
} else if (worksheetHeaders.length === 0) {
|
||||
emptyTitle = 'No headers found';
|
||||
emptyContent =
|
||||
'No column headers detected in this worksheet. Try a different worksheet or ensure the first row contains column headers.';
|
||||
} else if (needsPreviewRefresh) {
|
||||
emptyTitle = 'Preview needs updating';
|
||||
emptyContent = 'Your column mapping changed. Preview the import again to update this table.';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.emptyState}>
|
||||
<div className={style.emptyMessage}>
|
||||
<div className={style.emptyTitle}>{emptyTitle}</div>
|
||||
<div className={style.emptyBody}>{emptyContent}</div>
|
||||
{needsPreviewRefresh && (
|
||||
<Button
|
||||
className={style.emptyAction}
|
||||
variant='primary'
|
||||
onClick={onRefresh}
|
||||
disabled={!canRefresh}
|
||||
loading={isRefreshing}
|
||||
>
|
||||
Refresh preview
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={style.emptyTitle}>Preview not generated</div>
|
||||
<div className={style.emptyBody}>{emptyContent}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let eventIndex = 0;
|
||||
|
||||
return (
|
||||
<table className={style.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={style.rowNumber}>#</th>
|
||||
<th className={style.rowType}>Type</th>
|
||||
{displayColumns.map((label, index) => (
|
||||
{columnLabels.map((label, index) => (
|
||||
<th key={`${label}-${index}`}>{label}</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -158,29 +59,16 @@ export default function PreviewTable({
|
||||
{preview.rundown.flatOrder.map((entryId) => {
|
||||
const entry = preview.rundown.entries[entryId];
|
||||
const isEvent = isOntimeEvent(entry);
|
||||
const entryMetadata = previewMetadata?.[entryId];
|
||||
const { rowClassName, entryColour, entryType } = getEntryDisplay(entry, entryMetadata?.groupColour);
|
||||
if (isEvent) eventIndex++;
|
||||
const hasType = isEvent || isOntimeGroup(entry) || isOntimeMilestone(entry);
|
||||
|
||||
return (
|
||||
<tr key={entryId} className={rowClassName} style={{ '--entry-colour': entryColour } as CSSProperties}>
|
||||
<td className={style.rowNumber}>{isEvent ? entryMetadata?.eventIndex : ''}</td>
|
||||
<td className={style.rowType}>
|
||||
<Tag>{entryType}</Tag>
|
||||
</td>
|
||||
{displayColumns.map((label, colIndex) => {
|
||||
const value = getCellValue(label, entry, customFieldKeyByLabel);
|
||||
const cellClassName = getCellClassName(label, value);
|
||||
return (
|
||||
<td
|
||||
key={`${entryId}-${colIndex}`}
|
||||
className={cellClassName}
|
||||
data-empty={value === ''}
|
||||
title={value || undefined}
|
||||
>
|
||||
{value}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<tr key={entryId}>
|
||||
<td className={style.rowNumber}>{isEvent ? eventIndex : ''}</td>
|
||||
<td className={style.rowType}>{hasType ? entry.type : ''}</td>
|
||||
{columnLabels.map((label, colIndex) => (
|
||||
<td key={`${entryId}-${colIndex}`}>{getCellValue(label, entry, customFieldKeyByLabel)}</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
+11
-36
@@ -5,8 +5,7 @@ import type {
|
||||
SpreadsheetPreviewResponse,
|
||||
SpreadsheetWorksheetMetadata,
|
||||
} from 'ontime-types';
|
||||
import { isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { millisToString, removeTrailingZero } from 'ontime-utils';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
@@ -33,7 +32,7 @@ type ImportAction =
|
||||
| { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse }
|
||||
| { type: 'applySuccess' }
|
||||
| { type: 'exportSuccess' }
|
||||
| { type: 'clearPreview'; error?: string; needsRefresh?: boolean }
|
||||
| { type: 'clearPreview'; error?: string }
|
||||
| { type: 'failure'; error: string }
|
||||
| { type: 'reset' };
|
||||
|
||||
@@ -41,14 +40,12 @@ type ImportState = {
|
||||
loading: '' | 'preview' | 'apply' | 'export';
|
||||
error: string;
|
||||
preview: SpreadsheetPreviewResponse | null;
|
||||
needsPreviewRefresh: boolean;
|
||||
};
|
||||
|
||||
const initialImportState: ImportState = {
|
||||
loading: '',
|
||||
error: '',
|
||||
preview: null,
|
||||
needsPreviewRefresh: false,
|
||||
};
|
||||
|
||||
function importReducer(state: ImportState, action: ImportAction): ImportState {
|
||||
@@ -60,15 +57,15 @@ function importReducer(state: ImportState, action: ImportAction): ImportState {
|
||||
case 'startExport':
|
||||
return { ...state, loading: 'export', error: '' };
|
||||
case 'previewSuccess':
|
||||
return { loading: '', error: '', preview: action.preview, needsPreviewRefresh: false };
|
||||
return { loading: '', error: '', preview: action.preview };
|
||||
case 'applySuccess':
|
||||
case 'exportSuccess':
|
||||
return { ...state, loading: '' };
|
||||
case 'clearPreview':
|
||||
return { ...state, error: action.error ?? '', preview: null, needsPreviewRefresh: action.needsRefresh ?? false };
|
||||
return { ...state, error: action.error ?? '', preview: null };
|
||||
case 'failure': {
|
||||
if (state.loading === 'preview') {
|
||||
return { loading: '', error: action.error, preview: null, needsPreviewRefresh: false };
|
||||
return { loading: '', error: action.error, preview: null };
|
||||
}
|
||||
return { ...state, loading: '', error: action.error };
|
||||
}
|
||||
@@ -224,7 +221,7 @@ export function useSheetImportForm({
|
||||
const sub = watch(() => {
|
||||
if (!previewRef.current) return;
|
||||
previewRef.current = null;
|
||||
dispatch({ type: 'clearPreview', needsRefresh: true });
|
||||
dispatch({ type: 'clearPreview' });
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
}, [watch]);
|
||||
@@ -300,37 +297,15 @@ export function useSheetImportForm({
|
||||
}, [append]);
|
||||
|
||||
const toolbarStatus = (() => {
|
||||
const warningText = warningCount > 0 ? ` | warnings: ${warningCount}` : '';
|
||||
|
||||
if (!state.preview) {
|
||||
return {
|
||||
entries: '–',
|
||||
groups: '–',
|
||||
milestones: '–',
|
||||
start: '–',
|
||||
end: '–',
|
||||
duration: '–',
|
||||
warnings: warningCount,
|
||||
};
|
||||
return `entries: – | start: – | end: – | duration: –${warningText}`;
|
||||
}
|
||||
|
||||
const { entries, flatOrder } = state.preview.rundown;
|
||||
const { flatOrder } = state.preview.rundown;
|
||||
const { start, end, duration } = state.preview.summary;
|
||||
let groups = 0;
|
||||
let milestones = 0;
|
||||
for (const entryId of flatOrder) {
|
||||
const entry = entries[entryId];
|
||||
if (isOntimeGroup(entry)) groups++;
|
||||
else if (isOntimeMilestone(entry)) milestones++;
|
||||
}
|
||||
|
||||
return {
|
||||
entries: flatOrder.length,
|
||||
groups,
|
||||
milestones,
|
||||
start: removeTrailingZero(millisToString(start)),
|
||||
end: removeTrailingZero(millisToString(end)),
|
||||
duration: formatDuration(duration),
|
||||
warnings: warningCount,
|
||||
};
|
||||
return `entries: ${flatOrder.length} | start: ${millisToString(start)} | end: ${millisToString(end)} | duration: ${formatDuration(duration)}${warningText}`;
|
||||
})();
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Suspense, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { IoArrowDown, IoArrowUp } from 'react-icons/io5';
|
||||
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
@@ -11,25 +11,11 @@ import style from './ProjectPanel.module.scss';
|
||||
type SortParameter = 'alphabetical' | 'modified';
|
||||
|
||||
export default function ProjectList() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className={style.empty}>
|
||||
<Panel.Loader isLoading />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ProjectListSuspend />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectListSuspend() {
|
||||
const [editingMode, setEditingMode] = useState<EditMode | null>(null);
|
||||
const [editingFilename, setEditingFilename] = useState<string | null>(null);
|
||||
const [sortMode, setSortMode] = useState<ProjectSortMode>('modified-desc');
|
||||
|
||||
const { data, refetch } = useOrderedProjectList(sortMode);
|
||||
const { data, refetch, status } = useOrderedProjectList(sortMode);
|
||||
|
||||
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
|
||||
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
|
||||
@@ -52,6 +38,14 @@ function ProjectListSuspend() {
|
||||
});
|
||||
};
|
||||
|
||||
if (status === 'pending') {
|
||||
return (
|
||||
<div className={style.empty}>
|
||||
<Panel.Loader isLoading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const numProjects = data.reorderedProjectFiles.length;
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface OperatorData {
|
||||
}
|
||||
|
||||
export function useOperatorData(): ViewData<OperatorData> {
|
||||
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata(null);
|
||||
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
const { data: settings, status: settingsStatus } = useSettings();
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ErrorBoundary } from '@sentry/react';
|
||||
import { PropsWithChildren, ReactNode, Suspense } from 'react';
|
||||
import { PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
import ScrollArea from '../../common/components/scroll-area/ScrollArea';
|
||||
import { useIsOnline } from '../../common/hooks/useSocket';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import Loader from '../../views/common/loader/Loader';
|
||||
|
||||
import style from './Overview.module.scss';
|
||||
|
||||
@@ -16,37 +15,18 @@ export function OverviewWrapper({ navElements, children }: PropsWithChildren<Ove
|
||||
const isOnline = useIsOnline();
|
||||
|
||||
return (
|
||||
<Suspense fallback={<OverviewFallback navElements={navElements} />}>
|
||||
<div className={cx([style.overview, !isOnline && style.isOffline])}>
|
||||
<ErrorBoundary>
|
||||
<div className={style.nav}>{navElements}</div>
|
||||
<ScrollArea
|
||||
className={style.infoScroll}
|
||||
contentClassName={style.info}
|
||||
contentStyle={{ minWidth: '100%' }}
|
||||
orientation='horizontal'
|
||||
>
|
||||
{children}
|
||||
</ScrollArea>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewFallback({ navElements }: OverviewWrapperProps) {
|
||||
return (
|
||||
<div className={style.overview}>
|
||||
<div className={style.nav}>{navElements}</div>
|
||||
<ScrollArea
|
||||
className={style.infoScroll}
|
||||
contentClassName={style.info}
|
||||
contentStyle={{ minWidth: '100%' }}
|
||||
orientation='horizontal'
|
||||
>
|
||||
{/* TODO: this could be alined in a nicer way */}
|
||||
<Loader />
|
||||
</ScrollArea>
|
||||
<div className={cx([style.overview, !isOnline && style.isOffline])}>
|
||||
<ErrorBoundary>
|
||||
<div className={style.nav}>{navElements}</div>
|
||||
<ScrollArea
|
||||
className={style.infoScroll}
|
||||
contentClassName={style.info}
|
||||
contentStyle={{ minWidth: '100%' }}
|
||||
orientation='horizontal'
|
||||
>
|
||||
{children}
|
||||
</ScrollArea>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ export function MetadataTimes() {
|
||||
function GroupTimes() {
|
||||
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
|
||||
const currentGroupId = useCurrentGroupId();
|
||||
const group = useEntry(null, currentGroupId) as OntimeGroup | null;
|
||||
const group = useEntry(currentGroupId) as OntimeGroup | null;
|
||||
|
||||
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
|
||||
|
||||
@@ -266,7 +266,7 @@ function GroupTimes() {
|
||||
function FlagTimes() {
|
||||
const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView();
|
||||
const { id, expectedStart } = useNextFlag();
|
||||
const entry = useEntry(null, id) as OntimeEvent | null;
|
||||
const entry = useEntry(id) as OntimeEvent | null;
|
||||
|
||||
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import style from './TitleOverview.module.scss';
|
||||
export default function TitleOverview() {
|
||||
'use memo';
|
||||
const { data: projectData } = useProjectData();
|
||||
const { data: rundownData } = useRundownAuxData(null);
|
||||
const { data: rundownData } = useRundownAuxData();
|
||||
|
||||
if (!projectData.title && !rundownData.title) {
|
||||
return null;
|
||||
|
||||
@@ -6,7 +6,8 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
|
||||
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
|
||||
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
||||
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
|
||||
import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext';
|
||||
import { useLoadedRundownSource } from '../../common/hooks-query/useScopedRundown';
|
||||
import { useEntryActions } from '../../common/hooks/useEntryAction';
|
||||
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
|
||||
import { handleLinks } from '../../common/utils/linkUtils';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
@@ -38,29 +39,28 @@ function RundownExport() {
|
||||
defaultValue: RundownViewMode.List,
|
||||
});
|
||||
const isSmallDevice = useIsSmallDevice();
|
||||
const entryActions = useEntryActions();
|
||||
|
||||
if (isSmallDevice && isExtracted) {
|
||||
return (
|
||||
<RundownSelectionContextProvider>
|
||||
<EntryActionsProvider>
|
||||
<ProtectRoute permission='editor'>
|
||||
<div
|
||||
className={cx([style.rundownExport, style.extracted])}
|
||||
data-target='small-device'
|
||||
data-testid='panel-rundown'
|
||||
>
|
||||
<FinderPlacement />
|
||||
<ViewNavigationMenu suppressSettings />
|
||||
<div className={style.rundown}>
|
||||
<ErrorBoundary>
|
||||
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
|
||||
<RundownContextMenu />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<EntryActionsProvider actions={entryActions}>
|
||||
<ProtectRoute permission='editor'>
|
||||
<div
|
||||
className={cx([style.rundownExport, style.extracted])}
|
||||
data-target='small-device'
|
||||
data-testid='panel-rundown'
|
||||
>
|
||||
<FinderPlacement />
|
||||
<ViewNavigationMenu suppressSettings />
|
||||
<div className={style.rundown}>
|
||||
<ErrorBoundary>
|
||||
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
|
||||
<RundownContextMenu />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</ProtectRoute>
|
||||
</EntryActionsProvider>
|
||||
</RundownSelectionContextProvider>
|
||||
</div>
|
||||
</ProtectRoute>
|
||||
</EntryActionsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,32 +70,30 @@ function RundownExport() {
|
||||
viewMode === RundownViewMode.Table;
|
||||
|
||||
return (
|
||||
<RundownSelectionContextProvider>
|
||||
<EntryActionsProvider>
|
||||
<ProtectRoute permission='editor'>
|
||||
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
|
||||
<FinderPlacement />
|
||||
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
|
||||
<div className={style.rundown}>
|
||||
<Editor.Panel className={style.list}>
|
||||
<EntryActionsProvider actions={entryActions}>
|
||||
<ProtectRoute permission='editor'>
|
||||
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
|
||||
<FinderPlacement />
|
||||
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
|
||||
<div className={style.rundown}>
|
||||
<Editor.Panel className={style.list}>
|
||||
<ErrorBoundary>
|
||||
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
|
||||
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
|
||||
<RundownContextMenu />
|
||||
</ErrorBoundary>
|
||||
</Editor.Panel>
|
||||
{!hideSideBar && (
|
||||
<div className={style.side}>
|
||||
<ErrorBoundary>
|
||||
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
|
||||
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
|
||||
<RundownContextMenu />
|
||||
<RundownEntryEditor />
|
||||
</ErrorBoundary>
|
||||
</Editor.Panel>
|
||||
{!hideSideBar && (
|
||||
<div className={style.side}>
|
||||
<ErrorBoundary>
|
||||
<RundownEntryEditor />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ProtectRoute>
|
||||
</EntryActionsProvider>
|
||||
</RundownSelectionContextProvider>
|
||||
</div>
|
||||
</ProtectRoute>
|
||||
</EntryActionsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,16 +105,17 @@ interface RundownRootProps {
|
||||
}
|
||||
|
||||
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
|
||||
const source = useLoadedRundownSource();
|
||||
|
||||
return (
|
||||
<div className={style.rundownRoot}>
|
||||
{isSmallDevice ? (
|
||||
<RundownHeaderMobile viewMode={viewMode} setViewMode={setViewMode} />
|
||||
) : (
|
||||
// TODO: add data-background-rundown={!isLoadedRundown} styling
|
||||
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
|
||||
)}
|
||||
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
|
||||
{viewMode === RundownViewMode.Table && <EntryEditModal />}
|
||||
{viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />}
|
||||
<RenumberCuesDialog />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import { Playback } from 'ontime-types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
import { useContextRundownList } from '../../common/hooks-query/useContextRundown';
|
||||
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
|
||||
import { useRundownEditor } from '../../common/hooks/useSocket';
|
||||
import Rundown from './Rundown';
|
||||
|
||||
const backgroundFeatureData = {
|
||||
playback: Playback.Stop,
|
||||
selectedEventId: null,
|
||||
nextEventId: null,
|
||||
};
|
||||
|
||||
export default memo(RundownList);
|
||||
function RundownList() {
|
||||
const { rundown, status, rundownMetadata, isLoadedRundown } = useContextRundownList();
|
||||
const { data, status, rundownMetadata } = useRundownWithMetadata();
|
||||
const featureData = useRundownEditor();
|
||||
|
||||
const isLoading = status !== 'success' || !rundown || !rundownMetadata;
|
||||
const isLoading = status !== 'success' || !data || !rundownMetadata;
|
||||
|
||||
if (isLoading) {
|
||||
return <Empty text='Connecting to server' />;
|
||||
@@ -25,12 +18,12 @@ function RundownList() {
|
||||
|
||||
return (
|
||||
<Rundown
|
||||
order={rundown.order}
|
||||
flatOrder={rundown.flatOrder}
|
||||
entries={rundown.entries}
|
||||
id={rundown.id}
|
||||
order={data.order}
|
||||
flatOrder={data.flatOrder}
|
||||
entries={data.entries}
|
||||
id={data.id}
|
||||
rundownMetadata={rundownMetadata}
|
||||
featureData={isLoadedRundown ? featureData : backgroundFeatureData}
|
||||
featureData={featureData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.rundownSelect {
|
||||
min-width: min(20rem, calc(100vw - 6rem));
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import Select from '../../../common/components/select/Select';
|
||||
import { useRundownSelectionContext } from '../../../common/context/RundownSelectionContext';
|
||||
import { AppMode } from '../../../ontimeConfig';
|
||||
|
||||
import styles from './RundownSelect.module.scss';
|
||||
|
||||
const FOLLOW = '___null___';
|
||||
|
||||
interface RundownSelectProps {
|
||||
appMode: AppMode;
|
||||
}
|
||||
|
||||
export function RundownSelect({ appMode }: RundownSelectProps) {
|
||||
'use memo';
|
||||
const { selectRundownId, rundowns, loadedRundownId, selectedRundownId } = useRundownSelectionContext();
|
||||
|
||||
const options = rundowns.map(({ id, title }) => ({
|
||||
value: id,
|
||||
label: loadedRundownId === id ? `${title} (loaded)` : title,
|
||||
}));
|
||||
|
||||
// add a follow option
|
||||
options.unshift({
|
||||
value: FOLLOW,
|
||||
label: 'Follow loaded',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.rundownSelect}>
|
||||
<Select
|
||||
value={selectedRundownId ?? FOLLOW}
|
||||
options={options}
|
||||
onValueChange={(value) => {
|
||||
if (value === FOLLOW) selectRundownId(null);
|
||||
else selectRundownId(value);
|
||||
}}
|
||||
disabled={appMode === AppMode.Run}
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,10 +22,13 @@ export default function CuesheetEntryEditor({ entryId, rundown }: CuesheetEntryE
|
||||
return event ?? null;
|
||||
}, [entryId, rundown.entries, rundown.order.length]);
|
||||
|
||||
if (isOntimeEvent(entry)) {
|
||||
// the cuesheet always edits a single entry
|
||||
const events = useMemo(() => (isOntimeEvent(entry) ? [entry] : null), [entry]);
|
||||
|
||||
if (events) {
|
||||
return (
|
||||
<div className={style.entryEditor} data-testid='editor-container'>
|
||||
<EventEditor event={entry} />
|
||||
<EventEditor events={events} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,26 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
// a label with an action aligned to the end of the row
|
||||
.labelRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.clearAction {
|
||||
font-size: $aux-text-size;
|
||||
color: $blue-400;
|
||||
margin-bottom: $element-inner-spacing; // match the label
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
|
||||
&:hover {
|
||||
color: $blue-500;
|
||||
}
|
||||
}
|
||||
|
||||
.splitTwo {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
||||
import Info from '../../../common/components/info/Info';
|
||||
import AppLink from '../../../common/components/link/app-link/AppLink';
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||
import EventEditorBatchSchedule from './composite/EventEditorBatchSchedule';
|
||||
import EntryEditorCustomFields from './composite/EventEditorCustomFields';
|
||||
import EventEditorSchedule from './composite/EventEditorSchedule';
|
||||
import EventEditorTimes from './composite/EventEditorTimes';
|
||||
import EventEditorTitles from './composite/EventEditorTitles';
|
||||
import EventEditorTriggers from './composite/EventEditorTriggers';
|
||||
import { mixedPlaceholder } from './entryEditor.utils';
|
||||
import { mergeEvents, resolveConflict } from './mergeEvents';
|
||||
|
||||
import style from './EntryEditor.module.scss';
|
||||
|
||||
@@ -16,66 +21,115 @@ import style from './EntryEditor.module.scss';
|
||||
export type EventEditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | string;
|
||||
|
||||
interface EventEditorProps {
|
||||
event: OntimeEvent;
|
||||
/** events being edited, editing several events at once shows a merged view of their values */
|
||||
events: OntimeEvent[];
|
||||
}
|
||||
|
||||
export default function EventEditor({ event }: EventEditorProps) {
|
||||
export default function EventEditor({ events }: EventEditorProps) {
|
||||
const { data: customFields } = useCustomFields();
|
||||
const { updateEntry } = useEntryActionsContext();
|
||||
const { updateEntry, batchUpdateEvents } = useEntryActionsContext();
|
||||
|
||||
const isEditor = window.location.pathname.includes('editor');
|
||||
|
||||
const ids = useMemo(() => events.map((event) => event.id), [events]);
|
||||
const merged = useMemo(() => mergeEvents(events), [events]);
|
||||
|
||||
// when editing a single event, we can show the values which are unique to it
|
||||
const singleEvent = events.length === 1 ? events[0] : null;
|
||||
|
||||
/**
|
||||
* Applies a patch to every event being edited
|
||||
*/
|
||||
const submit = useCallback(
|
||||
(patch: Partial<OntimeEvent>) => {
|
||||
if (ids.length === 1) {
|
||||
updateEntry({ id: ids[0], ...patch });
|
||||
return;
|
||||
}
|
||||
batchUpdateEvents(patch, ids);
|
||||
},
|
||||
[batchUpdateEvents, ids, updateEntry],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(field: EventEditorUpdateFields, value: string) => {
|
||||
if (field.startsWith('custom-')) {
|
||||
const fieldLabel = field.split('custom-')[1];
|
||||
updateEntry({ id: event.id, custom: { [fieldLabel]: value } });
|
||||
submit({ custom: { [fieldLabel]: value } });
|
||||
} else {
|
||||
updateEntry({ id: event.id, [field]: value });
|
||||
submit({ [field]: value });
|
||||
}
|
||||
},
|
||||
[event.id, updateEntry],
|
||||
[submit],
|
||||
);
|
||||
|
||||
// inputs keep local state, we remount them when the edited entries change
|
||||
const editorKey = ids.join();
|
||||
|
||||
return (
|
||||
<div className={style.content}>
|
||||
{singleEvent ? (
|
||||
<EventEditorSchedule
|
||||
key={`${editorKey}-schedule`}
|
||||
eventId={singleEvent.id}
|
||||
timeStart={singleEvent.timeStart}
|
||||
timeEnd={singleEvent.timeEnd}
|
||||
duration={singleEvent.duration}
|
||||
timeStrategy={singleEvent.timeStrategy}
|
||||
linkStart={singleEvent.linkStart}
|
||||
delay={singleEvent.delay}
|
||||
/>
|
||||
) : (
|
||||
<EventEditorBatchSchedule
|
||||
key={`${editorKey}-schedule`}
|
||||
duration={resolveConflict(merged.duration)}
|
||||
submit={submit}
|
||||
/>
|
||||
)}
|
||||
<EventEditorTimes
|
||||
key={`${event.id}-times`}
|
||||
eventId={event.id}
|
||||
timeStart={event.timeStart}
|
||||
timeEnd={event.timeEnd}
|
||||
duration={event.duration}
|
||||
timeStrategy={event.timeStrategy}
|
||||
linkStart={event.linkStart}
|
||||
countToEnd={event.countToEnd}
|
||||
delay={event.delay}
|
||||
endAction={event.endAction}
|
||||
timerType={event.timerType}
|
||||
timeWarning={event.timeWarning}
|
||||
timeDanger={event.timeDanger}
|
||||
key={`${editorKey}-times`}
|
||||
countToEnd={resolveConflict(merged.countToEnd)}
|
||||
endAction={resolveConflict(merged.endAction)}
|
||||
timerType={resolveConflict(merged.timerType)}
|
||||
timeWarning={resolveConflict(merged.timeWarning)}
|
||||
timeDanger={resolveConflict(merged.timeDanger)}
|
||||
submit={submit}
|
||||
/>
|
||||
<EventEditorTitles
|
||||
key={`${event.id}-titles`}
|
||||
eventId={event.id}
|
||||
cue={event.cue}
|
||||
flag={event.flag}
|
||||
title={event.title}
|
||||
note={event.note}
|
||||
colour={event.colour}
|
||||
key={`${editorKey}-titles`}
|
||||
eventId={singleEvent?.id ?? null}
|
||||
eventCount={events.length}
|
||||
cue={singleEvent?.cue ?? ''}
|
||||
flag={resolveConflict(merged.flag)}
|
||||
title={resolveConflict(merged.title)}
|
||||
note={resolveConflict(merged.note)}
|
||||
colour={resolveConflict(merged.colour)}
|
||||
submit={submit}
|
||||
/>
|
||||
<div className={style.column}>
|
||||
<Editor.Title>
|
||||
Custom Fields
|
||||
{isEditor && <AppLink search='settings=manage__custom'>Manage Custom Fields</AppLink>}
|
||||
</Editor.Title>
|
||||
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={event} />
|
||||
<EntryEditorCustomFields
|
||||
key={`${editorKey}-custom`}
|
||||
fields={customFields}
|
||||
custom={merged.custom}
|
||||
idKey={editorKey}
|
||||
mixedPlaceholder={mixedPlaceholder}
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.column}>
|
||||
<Editor.Title>
|
||||
Automations
|
||||
{isEditor && <AppLink search='settings=automation'>Manage Automations</AppLink>}
|
||||
{isEditor && singleEvent && <AppLink search='settings=automation'>Manage Automations</AppLink>}
|
||||
</Editor.Title>
|
||||
<EventEditorTriggers triggers={event.triggers} eventId={event.id} />
|
||||
{singleEvent ? (
|
||||
<EventEditorTriggers triggers={singleEvent.triggers} eventId={singleEvent.id} />
|
||||
) : (
|
||||
<Info>Automations are not available when editing multiple events</Info>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -112,7 +112,12 @@ export default function GroupEditor({ group }: GroupEditorProps) {
|
||||
Custom Fields
|
||||
{isEditor && <AppLink search='settings=manage__custom'>Manage Custom Fields</AppLink>}
|
||||
</Editor.Title>
|
||||
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={group} />
|
||||
<EntryEditorCustomFields
|
||||
fields={customFields}
|
||||
handleSubmit={handleSubmit}
|
||||
custom={group.custom}
|
||||
idKey={group.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -69,7 +69,12 @@ export default function MilestoneEditor({ milestone }: MilestoneEditorProps) {
|
||||
Custom Fields
|
||||
{isEditor && <AppLink search='settings=manage__custom'>Manage Custom Fields</AppLink>}
|
||||
</Editor.Title>
|
||||
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={milestone} />
|
||||
<EntryEditorCustomFields
|
||||
fields={customFields}
|
||||
handleSubmit={handleSubmit}
|
||||
custom={milestone.custom}
|
||||
idKey={milestone.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { OntimeEntry, OntimeEvent, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useRundown } from '../../../common/hooks-query/useRundown';
|
||||
import useRundown from '../../../common/hooks-query/useRundown';
|
||||
import { useEventSelection } from '../useEventSelection';
|
||||
import EventEditorFooter from './composite/EventEditorFooter';
|
||||
import EventEditor from './EventEditor';
|
||||
@@ -13,7 +13,26 @@ import style from './EntryEditor.module.scss';
|
||||
|
||||
export default function RundownEntryEditor() {
|
||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||
const { data } = useRundown(null);
|
||||
const { data } = useRundown();
|
||||
|
||||
/**
|
||||
* Events in the current selection
|
||||
* Only events can be multi selected, groups and milestones are always selected on their own
|
||||
*/
|
||||
const events = useMemo<OntimeEvent[]>(() => {
|
||||
if (data.order.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const selection: OntimeEvent[] = [];
|
||||
selectedEvents.forEach((entryId) => {
|
||||
const entry = data.entries[entryId];
|
||||
if (isOntimeEvent(entry)) {
|
||||
selection.push(entry);
|
||||
}
|
||||
});
|
||||
return selection;
|
||||
}, [data.order.length, data.entries, selectedEvents]);
|
||||
|
||||
const entry = useMemo<OntimeEntry | null>(() => {
|
||||
if (data.order.length === 0) {
|
||||
@@ -29,19 +48,20 @@ export default function RundownEntryEditor() {
|
||||
return event ?? null;
|
||||
}, [data.order.length, data.entries, selectedEvents]);
|
||||
|
||||
if (!entry) {
|
||||
return <EventEditorEmpty />;
|
||||
}
|
||||
|
||||
if (isOntimeEvent(entry)) {
|
||||
if (events.length > 0) {
|
||||
const singleEvent = events.length === 1 ? events[0] : null;
|
||||
return (
|
||||
<div className={style.rundownEditor} data-testid='editor-container'>
|
||||
<EventEditor event={entry} />
|
||||
<EventEditorFooter id={entry.id} cue={entry.cue} />
|
||||
<EventEditor events={events} />
|
||||
{singleEvent && <EventEditorFooter id={singleEvent.id} cue={singleEvent.cue} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
return <EventEditorEmpty />;
|
||||
}
|
||||
|
||||
if (isOntimeMilestone(entry)) {
|
||||
return (
|
||||
<div className={style.rundownEditor} data-testid='editor-container'>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { EndAction, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types';
|
||||
|
||||
import { conflict, isConflict, mergeEvents, resolveConflict } from '../mergeEvents';
|
||||
|
||||
function makeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
|
||||
return {
|
||||
type: SupportedEntry.Event,
|
||||
id: 'event',
|
||||
title: 'title',
|
||||
note: 'note',
|
||||
colour: '#FFCC78',
|
||||
flag: false,
|
||||
duration: 600000,
|
||||
endAction: EndAction.None,
|
||||
countToEnd: false,
|
||||
timerType: TimerType.CountDown,
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
linkStart: true,
|
||||
custom: {},
|
||||
...patch,
|
||||
} as OntimeEvent;
|
||||
}
|
||||
|
||||
describe('mergeEvents()', () => {
|
||||
it('returns the values of a single event', () => {
|
||||
const event = makeEvent({ id: '1', title: 'only event' });
|
||||
|
||||
expect(mergeEvents([event])).toStrictEqual({
|
||||
title: 'only event',
|
||||
note: 'note',
|
||||
colour: '#FFCC78',
|
||||
flag: false,
|
||||
duration: 600000,
|
||||
endAction: EndAction.None,
|
||||
countToEnd: false,
|
||||
timerType: TimerType.CountDown,
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
linkStart: true,
|
||||
custom: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps values which are shared by all events', () => {
|
||||
const merged = mergeEvents([makeEvent({ id: '1' }), makeEvent({ id: '2' }), makeEvent({ id: '3' })]);
|
||||
|
||||
expect(merged.title).toBe('title');
|
||||
expect(merged.colour).toBe('#FFCC78');
|
||||
expect(merged.timerType).toBe(TimerType.CountDown);
|
||||
expect(merged.duration).toBe(600000);
|
||||
});
|
||||
|
||||
it('marks only the fields which differ as conflicting', () => {
|
||||
const merged = mergeEvents([makeEvent({ id: '1', title: 'first' }), makeEvent({ id: '2', title: 'second' })]);
|
||||
|
||||
expect(merged.title).toBe(conflict);
|
||||
expect(merged.note).toBe('note');
|
||||
expect(merged.colour).toBe('#FFCC78');
|
||||
expect(merged.flag).toBe(false);
|
||||
});
|
||||
|
||||
it('handles boolean and numeric fields', () => {
|
||||
const merged = mergeEvents([
|
||||
makeEvent({ id: '1', flag: true, timeWarning: 1000, duration: 1000 }),
|
||||
makeEvent({ id: '2', flag: false, timeWarning: 1000, duration: 2000 }),
|
||||
]);
|
||||
|
||||
expect(merged.flag).toBe(conflict);
|
||||
expect(merged.timeWarning).toBe(1000);
|
||||
expect(merged.duration).toBe(conflict);
|
||||
});
|
||||
|
||||
describe('custom fields', () => {
|
||||
it('merges values under the same key', () => {
|
||||
const merged = mergeEvents([
|
||||
makeEvent({ id: '1', custom: { lx: 'same', sound: 'a' } }),
|
||||
makeEvent({ id: '2', custom: { lx: 'same', sound: 'b' } }),
|
||||
]);
|
||||
|
||||
expect(merged.custom).toStrictEqual({ lx: 'same', sound: conflict });
|
||||
});
|
||||
|
||||
it('treats a missing key as empty', () => {
|
||||
const merged = mergeEvents([makeEvent({ id: '1', custom: { lx: 'value' } }), makeEvent({ id: '2', custom: {} })]);
|
||||
|
||||
expect(merged.custom).toStrictEqual({ lx: conflict });
|
||||
});
|
||||
|
||||
it('collects keys from all events', () => {
|
||||
const merged = mergeEvents([
|
||||
makeEvent({ id: '1', custom: { lx: '' } }),
|
||||
makeEvent({ id: '2', custom: { sound: 'value' } }),
|
||||
]);
|
||||
|
||||
// lx is empty in both events, sound is only filled in one of them
|
||||
expect(merged.custom).toStrictEqual({ lx: '', sound: conflict });
|
||||
});
|
||||
|
||||
it('does not report a key which is absent from every event', () => {
|
||||
const merged = mergeEvents([makeEvent({ id: '1', custom: {} }), makeEvent({ id: '2', custom: {} })]);
|
||||
|
||||
expect(merged.custom).toStrictEqual({});
|
||||
// an absent key is distinguishable from a conflicting one
|
||||
expect(merged.custom.lx).toBeUndefined();
|
||||
expect(isConflict(merged.custom.lx)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveConflict()', () => {
|
||||
it('passes known values through', () => {
|
||||
expect(resolveConflict('value')).toBe('value');
|
||||
expect(resolveConflict(0)).toBe(0);
|
||||
expect(resolveConflict(false)).toBe(false);
|
||||
expect(resolveConflict('')).toBe('');
|
||||
});
|
||||
|
||||
it('resolves a conflict to undefined', () => {
|
||||
expect(resolveConflict(conflict)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
import { MILLIS_PER_SECOND, dayInMs, parseUserTime } from 'ontime-utils';
|
||||
import { memo } from 'react';
|
||||
|
||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
import { mixedPlaceholder } from '../entryEditor.utils';
|
||||
|
||||
import style from '../EntryEditor.module.scss';
|
||||
|
||||
interface EventEditorBatchScheduleProps {
|
||||
/** undefined when the events do not share a duration */
|
||||
duration: number | undefined;
|
||||
submit: (patch: Partial<OntimeEvent>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule fields which can be applied to several events at once
|
||||
* Start and end times are absolute points in time: giving several events the same
|
||||
* value would collapse their durations, so only the duration is offered here
|
||||
* The server infers the duration lock and recalculates the rundown once for the whole batch
|
||||
*/
|
||||
export default memo(EventEditorBatchSchedule);
|
||||
function EventEditorBatchSchedule({ duration, submit }: EventEditorBatchScheduleProps) {
|
||||
const handleSubmit = (_field: 'duration', value: string) => {
|
||||
// durations cannot exceed a day
|
||||
submit({ duration: Math.min(parseUserTime(value), dayInMs - MILLIS_PER_SECOND) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.column}>
|
||||
<Editor.Title>Event schedule</Editor.Title>
|
||||
<div className={style.splitTwo}>
|
||||
<div>
|
||||
<Editor.Label htmlFor='duration'>Duration</Editor.Label>
|
||||
<TimeInput
|
||||
id='duration'
|
||||
name='duration'
|
||||
submitHandler={handleSubmit}
|
||||
time={duration}
|
||||
placeholder={duration === undefined ? mixedPlaceholder : 'Duration'}
|
||||
align='left'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+17
-7
@@ -1,8 +1,9 @@
|
||||
import { CustomFields, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
|
||||
import { CustomFields, EntryCustomFields } from 'ontime-types';
|
||||
import { CSSProperties, Fragment } from 'react';
|
||||
|
||||
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { EventEditorUpdateFields } from '../EventEditor';
|
||||
import { MergedCustomFields, resolveConflict } from '../mergeEvents';
|
||||
import EventEditorImage from './EventEditorImage';
|
||||
import EventTextArea from './EventTextArea';
|
||||
import EntryEditorTextInput from './EventTextInput';
|
||||
@@ -11,21 +12,29 @@ import style from '../EntryEditor.module.scss';
|
||||
|
||||
interface EntryEditorCustomFieldsProps {
|
||||
fields: CustomFields;
|
||||
entry: OntimeEvent | OntimeGroup | OntimeMilestone;
|
||||
/** values of the entries being edited, undefined when the entries do not agree */
|
||||
custom: EntryCustomFields | MergedCustomFields;
|
||||
/** used to generate stable keys for the fields */
|
||||
idKey: string;
|
||||
mixedPlaceholder?: string;
|
||||
handleSubmit: (field: EventEditorUpdateFields, value: string) => void;
|
||||
}
|
||||
|
||||
export default function EntryEditorCustomFields({
|
||||
fields: customFields,
|
||||
custom,
|
||||
idKey,
|
||||
mixedPlaceholder,
|
||||
handleSubmit,
|
||||
entry,
|
||||
}: EntryEditorCustomFieldsProps) {
|
||||
return (
|
||||
<Fragment>
|
||||
{Object.keys(customFields).map((fieldKey) => {
|
||||
const key = `${entry.id}-${fieldKey}`;
|
||||
const key = `${idKey}-${fieldKey}`;
|
||||
const fieldName = `custom-${fieldKey}`;
|
||||
const initialValue = entry.custom[fieldKey] ?? '';
|
||||
// a key which is absent from the merged view is not present in any of the entries
|
||||
const initialValue = resolveConflict(custom[fieldKey] ?? '');
|
||||
const placeholder = initialValue === undefined ? mixedPlaceholder : undefined;
|
||||
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
|
||||
const labelText = customFields[fieldKey].label;
|
||||
|
||||
@@ -36,6 +45,7 @@ export default function EntryEditorCustomFields({
|
||||
field={fieldName}
|
||||
label={labelText}
|
||||
initialValue={initialValue}
|
||||
placeholder={placeholder}
|
||||
submitHandler={handleSubmit}
|
||||
className={style.decorated}
|
||||
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
||||
@@ -51,13 +61,13 @@ export default function EntryEditorCustomFields({
|
||||
field={fieldName}
|
||||
label={labelText}
|
||||
initialValue={initialValue}
|
||||
placeholder='Paste image URL'
|
||||
placeholder={placeholder ?? 'Paste image URL'}
|
||||
submitHandler={handleSubmit}
|
||||
className={style.decorated}
|
||||
maxLength={255}
|
||||
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
||||
/>
|
||||
<EventEditorImage src={initialValue} />
|
||||
<EventEditorImage src={initialValue ?? ''} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { TimeStrategy } from 'ontime-types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
||||
import { formatTime, normaliseWallClock } from '../../../../common/utils/time';
|
||||
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
|
||||
|
||||
import style from '../EntryEditor.module.scss';
|
||||
|
||||
interface EventEditorScheduleProps {
|
||||
eventId: string;
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: boolean;
|
||||
delay: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule of a single event
|
||||
* Schedule values cascade through the rundown, so they are not editable across a selection
|
||||
*/
|
||||
export default memo(EventEditorSchedule);
|
||||
function EventEditorSchedule({
|
||||
eventId,
|
||||
timeStart,
|
||||
timeEnd,
|
||||
duration,
|
||||
timeStrategy,
|
||||
linkStart,
|
||||
delay,
|
||||
}: EventEditorScheduleProps) {
|
||||
const hasDelay = delay !== 0;
|
||||
const delayedStart = normaliseWallClock(timeStart + delay);
|
||||
const delayedEnd = normaliseWallClock(timeEnd + delay);
|
||||
const delayLabel = hasDelay
|
||||
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(delayedStart)} → ${formatTime(delayedEnd)}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className={style.column}>
|
||||
<Editor.Title>Event schedule</Editor.Title>
|
||||
<div>
|
||||
<div className={style.inline}>
|
||||
<TimeInputFlow
|
||||
eventId={eventId}
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
duration={duration}
|
||||
timeStrategy={timeStrategy}
|
||||
linkStart={linkStart}
|
||||
delay={delay}
|
||||
showLabels
|
||||
/>
|
||||
</div>
|
||||
<div className={style.delayLabel}>{delayLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EndAction, TimeStrategy, TimerType } from 'ontime-types';
|
||||
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
|
||||
import { parseUserTime } from 'ontime-utils';
|
||||
import { memo } from 'react';
|
||||
import { IoInformationCircle } from 'react-icons/io5';
|
||||
@@ -8,103 +8,47 @@ import TimeInput from '../../../../common/components/input/time-input/TimeInput'
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import Switch from '../../../../common/components/switch/Switch';
|
||||
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
||||
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
||||
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
||||
import { formatTime, normaliseWallClock } from '../../../../common/utils/time';
|
||||
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
|
||||
import { mixedPlaceholder, switchLabel } from '../entryEditor.utils';
|
||||
|
||||
import style from '../EntryEditor.module.scss';
|
||||
|
||||
interface EventEditorTimesProps {
|
||||
eventId: string;
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: boolean;
|
||||
countToEnd: boolean;
|
||||
delay: number;
|
||||
endAction: EndAction;
|
||||
timerType: TimerType;
|
||||
timeWarning: number;
|
||||
timeDanger: number;
|
||||
countToEnd: boolean | undefined;
|
||||
endAction: EndAction | undefined;
|
||||
timerType: TimerType | undefined;
|
||||
timeWarning: number | undefined;
|
||||
timeDanger: number | undefined;
|
||||
submit: (patch: Partial<OntimeEvent>) => void;
|
||||
}
|
||||
|
||||
type HandledActions = 'countToEnd' | 'timerType' | 'endAction' | 'timeWarning' | 'timeDanger';
|
||||
type TimeFields = 'timeWarning' | 'timeDanger';
|
||||
|
||||
export default memo(EventEditorTimes);
|
||||
function EventEditorTimes({
|
||||
eventId,
|
||||
timeStart,
|
||||
timeEnd,
|
||||
duration,
|
||||
timeStrategy,
|
||||
linkStart,
|
||||
countToEnd,
|
||||
delay,
|
||||
endAction,
|
||||
timerType,
|
||||
timeWarning,
|
||||
timeDanger,
|
||||
submit,
|
||||
}: EventEditorTimesProps) {
|
||||
const { updateEntry } = useEntryActionsContext();
|
||||
|
||||
const handleSubmit = (field: HandledActions, value: string | boolean) => {
|
||||
if (field === 'countToEnd') {
|
||||
updateEntry({ id: eventId, countToEnd: value as boolean });
|
||||
return;
|
||||
}
|
||||
|
||||
if (field === 'timeWarning' || field === 'timeDanger') {
|
||||
const newTime = parseUserTime(value as string);
|
||||
updateEntry({ id: eventId, [field]: newTime });
|
||||
return;
|
||||
}
|
||||
|
||||
if (field === 'timerType' || field === 'endAction') {
|
||||
updateEntry({ id: eventId, [field]: value });
|
||||
return;
|
||||
}
|
||||
const handleTimeSubmit = (field: TimeFields, value: string) => {
|
||||
submit({ [field]: parseUserTime(value) });
|
||||
};
|
||||
|
||||
const hasDelay = delay !== 0;
|
||||
const delayedStart = normaliseWallClock(timeStart + delay);
|
||||
const delayedEnd = normaliseWallClock(timeEnd + delay);
|
||||
const delayLabel = hasDelay
|
||||
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(delayedStart)} → ${formatTime(delayedEnd)}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={style.column}>
|
||||
<Editor.Title>Event schedule</Editor.Title>
|
||||
<div>
|
||||
<div className={style.inline}>
|
||||
<TimeInputFlow
|
||||
eventId={eventId}
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
duration={duration}
|
||||
timeStrategy={timeStrategy}
|
||||
linkStart={linkStart}
|
||||
delay={delay}
|
||||
showLabels
|
||||
/>
|
||||
</div>
|
||||
<div className={style.delayLabel}>{delayLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.column}>
|
||||
<Editor.Title>Event Behaviour</Editor.Title>
|
||||
<div className={style.splitTwo}>
|
||||
<div>
|
||||
<Editor.Label htmlFor='endAction'>End Action</Editor.Label>
|
||||
<Select
|
||||
value={endAction}
|
||||
value={endAction ?? null}
|
||||
placeholder={mixedPlaceholder}
|
||||
onValueChange={(value: EndAction | null) => {
|
||||
if (value === null) return;
|
||||
handleSubmit('endAction', value);
|
||||
submit({ endAction: value });
|
||||
}}
|
||||
options={[
|
||||
{ value: EndAction.None, label: 'None' },
|
||||
@@ -118,10 +62,11 @@ function EventEditorTimes({
|
||||
<Editor.Label className={style.switchLabel}>
|
||||
<Switch
|
||||
id='countToEnd'
|
||||
checked={countToEnd}
|
||||
onCheckedChange={(value) => handleSubmit('countToEnd', value)}
|
||||
checked={countToEnd ?? false}
|
||||
mixed={countToEnd === undefined}
|
||||
onCheckedChange={(value) => submit({ countToEnd: value })}
|
||||
/>
|
||||
{countToEnd ? 'On' : 'Off'}
|
||||
{switchLabel(countToEnd)}
|
||||
</Editor.Label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,10 +86,11 @@ function EventEditorTimes({
|
||||
<div>
|
||||
<Editor.Label htmlFor='timerType'>Timer Type</Editor.Label>
|
||||
<Select
|
||||
value={timerType}
|
||||
value={timerType ?? null}
|
||||
placeholder={mixedPlaceholder}
|
||||
onValueChange={(value: TimerType | null) => {
|
||||
if (value === null) return;
|
||||
handleSubmit('timerType', value);
|
||||
submit({ timerType: value });
|
||||
}}
|
||||
options={[
|
||||
{ value: TimerType.CountDown, label: 'Count down' },
|
||||
@@ -161,9 +107,9 @@ function EventEditorTimes({
|
||||
<TimeInput
|
||||
id='timeWarning'
|
||||
name='timeWarning'
|
||||
submitHandler={handleSubmit}
|
||||
submitHandler={handleTimeSubmit}
|
||||
time={timeWarning}
|
||||
placeholder='Duration'
|
||||
placeholder={timeWarning === undefined ? mixedPlaceholder : 'Duration'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -171,9 +117,9 @@ function EventEditorTimes({
|
||||
<TimeInput
|
||||
id='timeDanger'
|
||||
name='timeDanger'
|
||||
submitHandler={handleSubmit}
|
||||
submitHandler={handleTimeSubmit}
|
||||
time={timeDanger}
|
||||
placeholder='Duration'
|
||||
placeholder={timeDanger === undefined ? mixedPlaceholder : 'Duration'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,34 +1,36 @@
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Switch from '../../../../common/components/switch/Switch';
|
||||
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
||||
import { enDash } from '../../../../common/utils/styleUtils';
|
||||
import { mixedPlaceholder, switchLabel } from '../entryEditor.utils';
|
||||
import EventTextArea from './EventTextArea';
|
||||
import EntryEditorTextInput from './EventTextInput';
|
||||
|
||||
import style from '../EntryEditor.module.scss';
|
||||
|
||||
interface EventEditorTitlesProps {
|
||||
eventId: string;
|
||||
/** id of the event being edited, null when editing several events */
|
||||
eventId: string | null;
|
||||
/** amount of events being edited */
|
||||
eventCount: number;
|
||||
cue: string;
|
||||
flag: boolean;
|
||||
title: string;
|
||||
note: string;
|
||||
colour: string;
|
||||
flag: boolean | undefined;
|
||||
title: string | undefined;
|
||||
note: string | undefined;
|
||||
colour: string | undefined;
|
||||
submit: (patch: Partial<OntimeEvent>) => void;
|
||||
}
|
||||
|
||||
export default memo(EventEditorTitles);
|
||||
function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEditorTitlesProps) {
|
||||
const { updateEntry } = useEntryActionsContext();
|
||||
|
||||
const flagSubmitHandler = (newValue: boolean) => {
|
||||
updateEntry({ id: eventId, flag: newValue });
|
||||
};
|
||||
function EventEditorTitles({ eventId, eventCount, cue, flag, title, note, colour, submit }: EventEditorTitlesProps) {
|
||||
const isMulti = eventId === null;
|
||||
|
||||
const textSubmitHandler = (field: string, newValue: string) => {
|
||||
updateEntry({ id: eventId, [field]: newValue });
|
||||
submit({ [field]: newValue });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -36,21 +38,39 @@ function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEdi
|
||||
<Editor.Title>Event Data</Editor.Title>
|
||||
<div className={style.splitThree}>
|
||||
<div>
|
||||
<Editor.Label htmlFor='eventId'>Event ID (read only)</Editor.Label>
|
||||
<Input id='eventId' data-testid='input-textfield' value={eventId} readOnly fluid />
|
||||
<Editor.Label htmlFor='eventId'>{isMulti ? 'Selection (read only)' : 'Event ID (read only)'}</Editor.Label>
|
||||
<Input
|
||||
id='eventId'
|
||||
data-testid='input-textfield'
|
||||
value={isMulti ? `${eventCount} events selected` : eventId}
|
||||
readOnly
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
<EntryEditorTextInput
|
||||
field='cue'
|
||||
label='Cue'
|
||||
initialValue={cue}
|
||||
submitHandler={textSubmitHandler}
|
||||
maxLength={10}
|
||||
/>
|
||||
{isMulti ? (
|
||||
<div>
|
||||
<Editor.Label htmlFor='cue'>Cue (not available)</Editor.Label>
|
||||
<Input id='cue' value={enDash} readOnly fluid />
|
||||
</div>
|
||||
) : (
|
||||
<EntryEditorTextInput
|
||||
field='cue'
|
||||
label='Cue'
|
||||
initialValue={cue}
|
||||
submitHandler={textSubmitHandler}
|
||||
maxLength={10}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<Editor.Label htmlFor='flag'>Flag</Editor.Label>
|
||||
<Editor.Label className={style.switchLabel}>
|
||||
<Switch id='flag' checked={flag} onCheckedChange={flagSubmitHandler} />
|
||||
{flag ? 'On' : 'Off'}
|
||||
<Switch
|
||||
id='flag'
|
||||
checked={flag ?? false}
|
||||
mixed={flag === undefined}
|
||||
onCheckedChange={(newValue) => submit({ flag: newValue })}
|
||||
/>
|
||||
{switchLabel(flag)}
|
||||
</Editor.Label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,8 +78,20 @@ function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEdi
|
||||
<Editor.Label>Colour</Editor.Label>
|
||||
<SwatchSelect name='colour' value={colour} handleChange={textSubmitHandler} />
|
||||
</div>
|
||||
<EntryEditorTextInput field='title' label='Title' initialValue={title} submitHandler={textSubmitHandler} />
|
||||
<EventTextArea field='note' label='Note' initialValue={note} submitHandler={textSubmitHandler} />
|
||||
<EntryEditorTextInput
|
||||
field='title'
|
||||
label='Title'
|
||||
initialValue={title}
|
||||
placeholder={title === undefined ? mixedPlaceholder : undefined}
|
||||
submitHandler={textSubmitHandler}
|
||||
/>
|
||||
<EventTextArea
|
||||
field='note'
|
||||
label='Note'
|
||||
initialValue={note}
|
||||
placeholder={note === undefined ? mixedPlaceholder : undefined}
|
||||
submitHandler={textSubmitHandler}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,15 @@ import { AutoTextarea } from '../../../../common/components/input/auto-textarea/
|
||||
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
||||
import { EventEditorUpdateFields } from '../EventEditor';
|
||||
|
||||
import style from '../EntryEditor.module.scss';
|
||||
|
||||
interface CountedTextAreaProps {
|
||||
className?: string;
|
||||
field: EventEditorUpdateFields;
|
||||
label: string;
|
||||
initialValue: string;
|
||||
/** undefined represents values which do not agree across the edited entries */
|
||||
initialValue: string | undefined;
|
||||
placeholder?: string;
|
||||
style?: CSSProperties;
|
||||
submitHandler: (field: EventEditorUpdateFields, value: string) => void;
|
||||
}
|
||||
@@ -19,6 +23,7 @@ export default function EventTextArea({
|
||||
field,
|
||||
label,
|
||||
initialValue,
|
||||
placeholder,
|
||||
style: givenStyles,
|
||||
submitHandler,
|
||||
}: CountedTextAreaProps) {
|
||||
@@ -29,16 +34,27 @@ export default function EventTextArea({
|
||||
submitOnCtrlEnter: true,
|
||||
});
|
||||
|
||||
// an unknown value cannot be cleared by emptying the field, we offer an explicit action
|
||||
const canClear = initialValue === undefined;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
|
||||
{label}
|
||||
</Editor.Label>
|
||||
<div className={style.labelRow}>
|
||||
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
|
||||
{label}
|
||||
</Editor.Label>
|
||||
{canClear && (
|
||||
<button type='button' className={style.clearAction} onClick={() => submitCallback('')}>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<AutoTextarea
|
||||
id={field}
|
||||
inputref={ref}
|
||||
rows={1}
|
||||
data-testid='input-textarea'
|
||||
placeholder={placeholder}
|
||||
fluid
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
|
||||
@@ -6,10 +6,13 @@ import useReactiveTextInput from '../../../../common/components/input/text-input
|
||||
import { EventEditorUpdateFields } from '../EventEditor';
|
||||
import { GroupEditorUpdateTextFields } from '../GroupEditor';
|
||||
|
||||
import style from '../EntryEditor.module.scss';
|
||||
|
||||
interface EntryEditorTextInputProps extends InputProps {
|
||||
field: EventEditorUpdateFields | GroupEditorUpdateTextFields;
|
||||
label: string;
|
||||
initialValue: string;
|
||||
/** undefined represents values which do not agree across the edited entries */
|
||||
initialValue: string | undefined;
|
||||
placeholder?: string;
|
||||
submitHandler: (field: EventEditorUpdateFields, value: string) => void;
|
||||
}
|
||||
@@ -31,11 +34,21 @@ export default function EntryEditorTextInput({
|
||||
submitOnEnter: true,
|
||||
});
|
||||
|
||||
// an unknown value cannot be cleared by emptying the field, we offer an explicit action
|
||||
const canClear = initialValue === undefined;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
|
||||
{label}
|
||||
</Editor.Label>
|
||||
<div className={style.labelRow}>
|
||||
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
|
||||
{label}
|
||||
</Editor.Label>
|
||||
{canClear && (
|
||||
<button type='button' className={style.clearAction} onClick={() => submitCallback('')}>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id={field}
|
||||
ref={ref}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Shown in place of a value when the entries being edited do not agree */
|
||||
export const mixedPlaceholder = 'Mixed';
|
||||
|
||||
/** Returns the label of a switch which may represent values that do not agree */
|
||||
export function switchLabel(value: boolean | undefined): string {
|
||||
if (value === undefined) {
|
||||
return mixedPlaceholder;
|
||||
}
|
||||
return value ? 'On' : 'Off';
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { CustomFieldKey, OntimeEvent } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* Marks a value which is not the same across the entries being edited
|
||||
* This is a distinct value from undefined, which means that the field is absent
|
||||
* Being a symbol, it also cannot be assigned to a patch by accident
|
||||
*/
|
||||
export const conflict: unique symbol = Symbol('conflict');
|
||||
export type Conflict = typeof conflict;
|
||||
|
||||
/** A value which may not be the same across the entries being edited */
|
||||
export type MergedValue<T> = T | Conflict;
|
||||
|
||||
export function isConflict<T>(value: MergedValue<T>): value is Conflict {
|
||||
return value === conflict;
|
||||
}
|
||||
|
||||
/** Resolves a merged value for the UI, where an unknown value is represented by undefined */
|
||||
export function resolveConflict<T>(value: MergedValue<T>): T | undefined {
|
||||
return isConflict(value) ? undefined : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields which can be edited across a selection of events
|
||||
* timeStart and timeEnd are absolute points in time and are excluded:
|
||||
* giving several events the same start or end collapses their durations
|
||||
*/
|
||||
export const batchEditableFields = [
|
||||
'title',
|
||||
'note',
|
||||
'colour',
|
||||
'flag',
|
||||
'duration',
|
||||
'endAction',
|
||||
'countToEnd',
|
||||
'timerType',
|
||||
'timeWarning',
|
||||
'timeDanger',
|
||||
'linkStart',
|
||||
] as const;
|
||||
|
||||
type BatchEditableField = (typeof batchEditableFields)[number];
|
||||
|
||||
/**
|
||||
* Custom fields of the entries being edited
|
||||
* A missing key means that none of the entries have a value for that field
|
||||
*/
|
||||
export type MergedCustomFields = Record<CustomFieldKey, MergedValue<string>>;
|
||||
|
||||
/**
|
||||
* A merged view over a set of events
|
||||
* A field holds the conflict symbol when the events do not agree on its value
|
||||
*/
|
||||
export type MergedEvent = {
|
||||
[K in BatchEditableField]: MergedValue<OntimeEvent[K]>;
|
||||
} & {
|
||||
custom: MergedCustomFields;
|
||||
};
|
||||
|
||||
/**
|
||||
* Merges a list of events into a single view
|
||||
* For a single event, no field is in conflict and every value matches the event
|
||||
* @param events - events to merge, must contain at least one element
|
||||
*/
|
||||
export function mergeEvents(events: OntimeEvent[]): MergedEvent {
|
||||
return {
|
||||
title: mergeField(events, 'title'),
|
||||
note: mergeField(events, 'note'),
|
||||
colour: mergeField(events, 'colour'),
|
||||
flag: mergeField(events, 'flag'),
|
||||
duration: mergeField(events, 'duration'),
|
||||
endAction: mergeField(events, 'endAction'),
|
||||
countToEnd: mergeField(events, 'countToEnd'),
|
||||
timerType: mergeField(events, 'timerType'),
|
||||
timeWarning: mergeField(events, 'timeWarning'),
|
||||
timeDanger: mergeField(events, 'timeDanger'),
|
||||
linkStart: mergeField(events, 'linkStart'),
|
||||
custom: mergeCustomFields(events),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the shared value of a field, or the conflict symbol if the events disagree
|
||||
*/
|
||||
function mergeField<K extends BatchEditableField>(events: OntimeEvent[], field: K): MergedValue<OntimeEvent[K]> {
|
||||
const value = events[0][field];
|
||||
return events.some((event) => event[field] !== value) ? conflict : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the custom fields of a list of events
|
||||
* Fields missing from an entry are considered empty
|
||||
*/
|
||||
function mergeCustomFields(events: OntimeEvent[]): MergedCustomFields {
|
||||
const merged: MergedCustomFields = {};
|
||||
|
||||
for (const event of events) {
|
||||
for (const key of Object.keys(event.custom)) {
|
||||
if (key in merged) {
|
||||
continue;
|
||||
}
|
||||
const value = event.custom[key] ?? '';
|
||||
merged[key] = events.some((other) => (other.custom[key] ?? '') !== value) ? conflict : value;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import Button from '../../../common/components/buttons/Button';
|
||||
import Dialog from '../../../common/components/dialog/Dialog';
|
||||
import Input from '../../../common/components/input/input/Input';
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import { useContextRundownCueRenumberModal } from '../../../common/hooks-query/useContextRundown';
|
||||
import useRundown from '../../../common/hooks-query/useRundown';
|
||||
import { orderEntries } from '../rundown.utils';
|
||||
import { useEventSelection } from '../useEventSelection';
|
||||
|
||||
@@ -17,7 +17,8 @@ type RenumberCueData = Pick<RenumberCues, 'increment' | 'prefix' | 'start'>;
|
||||
|
||||
export default function RenumberCuesDialog() {
|
||||
'use memo';
|
||||
const { flatOrder } = useContextRundownCueRenumberModal();
|
||||
const { data } = useRundown();
|
||||
const { flatOrder } = data;
|
||||
const { onClose, isOpen } = useRenumberCuesDialogStore();
|
||||
const { renumberCues } = useEntryActionsContext();
|
||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||
|
||||
@@ -8,7 +8,6 @@ import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
|
||||
import { AppMode } from '../../../ontimeConfig';
|
||||
import { EditorLayoutMode, useEditorLayout } from '../../../views/editor/useEditorLayout';
|
||||
import { RundownSelect } from '../common/RundownSelect';
|
||||
import { RundownViewMode } from '../rundown.options';
|
||||
import { useEditorFollowMode } from '../useEditorFollowMode';
|
||||
import RundownMenu from './RundownMenu';
|
||||
@@ -25,7 +24,6 @@ interface HeaderControlsConfig {
|
||||
showRunEditToggle: boolean;
|
||||
showOffsetToggle: boolean;
|
||||
showOverflowMenu: boolean;
|
||||
showRundownSelect: boolean;
|
||||
}
|
||||
|
||||
export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConfig> = {
|
||||
@@ -33,19 +31,16 @@ export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConf
|
||||
showRunEditToggle: true,
|
||||
showOffsetToggle: true,
|
||||
showOverflowMenu: true,
|
||||
showRundownSelect: false,
|
||||
},
|
||||
[EditorLayoutMode.PLANNING]: {
|
||||
showRunEditToggle: false,
|
||||
showOffsetToggle: false,
|
||||
showOverflowMenu: true,
|
||||
showRundownSelect: true,
|
||||
},
|
||||
[EditorLayoutMode.TRACKING]: {
|
||||
showRunEditToggle: false,
|
||||
showOffsetToggle: true,
|
||||
showOverflowMenu: false,
|
||||
showRundownSelect: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -55,8 +50,7 @@ function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProp
|
||||
const offsetMode = useOffsetMode();
|
||||
const { layoutMode } = useEditorLayout();
|
||||
|
||||
const { showRunEditToggle, showOffsetToggle, showOverflowMenu, showRundownSelect } =
|
||||
HEADER_CONTROLS_CONFIG[layoutMode];
|
||||
const { showRunEditToggle, showOffsetToggle, showOverflowMenu } = HEADER_CONTROLS_CONFIG[layoutMode];
|
||||
|
||||
const toggleAppMode = (mode: AppMode[]) => {
|
||||
// we need to stop user from deselecting a mode
|
||||
@@ -128,8 +122,6 @@ function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProp
|
||||
</ToggleGroup>
|
||||
)}
|
||||
|
||||
{showRundownSelect && <RundownSelect appMode={editorMode} />}
|
||||
|
||||
{showOverflowMenu && <RundownMenu allowNavigation={!isExtracted} />}
|
||||
</Toolbar.Root>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function useBackstageData(): ViewData<BackstageData> {
|
||||
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
||||
|
||||
// HTTP API data
|
||||
const { data: rundownData, status: rundownStatus } = useFlatRundown(null);
|
||||
const { data: rundownData, status: rundownStatus } = useFlatRundown();
|
||||
const { data: projectData, status: projectDataStatus } = useProjectData();
|
||||
const { data: settings, status: settingsStatus } = useSettings();
|
||||
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
||||
|
||||
@@ -45,7 +45,7 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
|
||||
[filter],
|
||||
);
|
||||
|
||||
const { data: events } = usePartialRundown(null, filterCallback);
|
||||
const { data: events } = usePartialRundown(filterCallback);
|
||||
|
||||
const [firstIndex, setFirstIndex] = useState(-1);
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
|
||||
@@ -21,7 +21,7 @@ export function useCountdownData(): ViewData<CountdownData> {
|
||||
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
||||
|
||||
// HTTP API data
|
||||
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata(null);
|
||||
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
|
||||
const { data: projectData, status: projectDataStatus } = useProjectData();
|
||||
const { data: settings, status: settingsStatus } = useSettings();
|
||||
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
||||
|
||||
@@ -14,3 +14,7 @@
|
||||
'table';
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.rundownSelect {
|
||||
min-width: min(20rem, calc(100vw - 6rem));
|
||||
}
|
||||
|
||||
@@ -4,46 +4,51 @@ import { IoApps } from 'react-icons/io5';
|
||||
import IconButton from '../../common/components/buttons/IconButton';
|
||||
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
||||
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
|
||||
import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext';
|
||||
import { useScopedRundown } from '../../common/hooks-query/useScopedRundown';
|
||||
import { useScopedEntryActions } from '../../common/hooks/useEntryAction';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { getIsNavigationLocked } from '../../externals';
|
||||
import CuesheetOverview from '../../features/overview/CuesheetOverview';
|
||||
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
|
||||
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
||||
import CuesheetTableWrapper from './CuesheetTableWrapper';
|
||||
import { FOLLOW_LOADED_RUNDOWN_ID, useCuesheetRundownSelection } from './useCuesheetRundownSelection';
|
||||
|
||||
import styles from './CuesheetPage.module.scss';
|
||||
|
||||
export default function CuesheetPage() {
|
||||
'use memo';
|
||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||
const { selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = useCuesheetRundownSelection();
|
||||
const source = useScopedRundown(selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? loadedRundownId : selectedRundownId);
|
||||
|
||||
const actions = useScopedEntryActions(source.rundownId);
|
||||
|
||||
useWindowTitle('Cuesheet');
|
||||
|
||||
const isLocked = getIsNavigationLocked();
|
||||
|
||||
return (
|
||||
<RundownSelectionContextProvider>
|
||||
<EntryActionsProvider>
|
||||
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
||||
<EntryEditModal />
|
||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||
<CuesheetOverview>
|
||||
{!isLocked && (
|
||||
<IconButton
|
||||
aria-label='Toggle navigation'
|
||||
variant='subtle-white'
|
||||
size='xlarge'
|
||||
onClick={menuHandler.open}
|
||||
>
|
||||
<IoApps />
|
||||
</IconButton>
|
||||
)}
|
||||
</CuesheetOverview>
|
||||
<CuesheetProgress />
|
||||
<CuesheetTableWrapper />
|
||||
</div>
|
||||
</EntryActionsProvider>
|
||||
</RundownSelectionContextProvider>
|
||||
<EntryActionsProvider actions={actions}>
|
||||
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
||||
<EntryEditModal rundown={source.rundown} />
|
||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||
<CuesheetOverview>
|
||||
{!isLocked && (
|
||||
<IconButton aria-label='Toggle navigation' variant='subtle-white' size='xlarge' onClick={menuHandler.open}>
|
||||
<IoApps />
|
||||
</IconButton>
|
||||
)}
|
||||
</CuesheetOverview>
|
||||
<CuesheetProgress />
|
||||
<CuesheetTableWrapper
|
||||
source={source}
|
||||
selectedRundownId={selectedRundownId}
|
||||
loadedRundownId={loadedRundownId}
|
||||
setSelectedRundownId={setSelectedRundownId}
|
||||
projectRundowns={projectRundowns}
|
||||
/>
|
||||
</div>
|
||||
</EntryActionsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import { MaybeString, ProjectRundown } from 'ontime-types';
|
||||
import { memo, use, useMemo } from 'react';
|
||||
|
||||
import Select from '../../common/components/select/Select';
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import { PresetContext } from '../../common/context/PresetContext';
|
||||
import { useRundownSelectionContext } from '../../common/context/RundownSelectionContext';
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
|
||||
import { AppMode } from '../../ontimeConfig';
|
||||
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
|
||||
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
|
||||
import CuesheetTable from './cuesheet-table/CuesheetTable';
|
||||
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
|
||||
import { FOLLOW_LOADED_RUNDOWN_ID } from './useCuesheetRundownSelection';
|
||||
|
||||
import styles from './CuesheetPage.module.scss';
|
||||
|
||||
interface CuesheetTableWrapperProps {
|
||||
source: RundownSource;
|
||||
selectedRundownId: MaybeString;
|
||||
loadedRundownId: string;
|
||||
setSelectedRundownId: (rundownId: string) => void;
|
||||
projectRundowns: ProjectRundown[];
|
||||
}
|
||||
|
||||
export default memo(CuesheetTableWrapper);
|
||||
function CuesheetTableWrapper() {
|
||||
function CuesheetTableWrapper({
|
||||
source,
|
||||
selectedRundownId,
|
||||
setSelectedRundownId,
|
||||
loadedRundownId,
|
||||
projectRundowns,
|
||||
}: CuesheetTableWrapperProps) {
|
||||
const preset = use(PresetContext);
|
||||
const { isLoadedRundown } = useRundownSelectionContext();
|
||||
|
||||
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isLoadedRundown });
|
||||
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
|
||||
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
|
||||
const columns = useMemo(
|
||||
@@ -31,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,21 @@
|
||||
import {
|
||||
FOLLOW_LOADED_RUNDOWN_ID,
|
||||
getCuesheetRundownStorageKey,
|
||||
resolveSelectedRundownId,
|
||||
} from '../useCuesheetRundownSelection';
|
||||
|
||||
describe('useCuesheetRundownSelection helpers', () => {
|
||||
it('builds a project-scoped storage key', () => {
|
||||
expect(getCuesheetRundownStorageKey('http://localhost:4001', 'My Project')).toBe(
|
||||
'cuesheet-selected-rundown:http://localhost:4001:My Project',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the follow loaded rundown when the stored selection is missing', () => {
|
||||
expect(resolveSelectedRundownId('missing', new Set(['loaded', 'other']))).toBe(FOLLOW_LOADED_RUNDOWN_ID);
|
||||
});
|
||||
|
||||
it('keeps the stored selection when it still exists in the current project', () => {
|
||||
expect(resolveSelectedRundownId('other', new Set(['loaded', 'other']))).toBe('other');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Rundown } from 'ontime-types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import Modal from '../../../common/components/modal/Modal';
|
||||
import { useContextRundownEditModal } from '../../../common/hooks-query/useContextRundown';
|
||||
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
|
||||
import { useEditModal } from './useEditModal';
|
||||
|
||||
interface EntryEditModalProps {
|
||||
rundown: Rundown;
|
||||
}
|
||||
|
||||
export default memo(EntryEditModal);
|
||||
function EntryEditModal() {
|
||||
const { rundown } = useContextRundownEditModal();
|
||||
function EntryEditModal({ rundown }: EntryEditModalProps) {
|
||||
const entryId = useEditModal((state) => state.selectedEntryId);
|
||||
const closeModal = useEditModal((state) => state.clearSelection);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTableNav } from '@table-nav/react';
|
||||
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
ContextProp,
|
||||
ItemProps,
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import EmptyPage from '../../../common/components/state/EmptyPage';
|
||||
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import { useContextRundownTable } from '../../../common/hooks-query/useContextRundown';
|
||||
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
|
||||
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
||||
@@ -35,22 +35,34 @@ import style from './CuesheetTable.module.scss';
|
||||
type CuesheetTableBaseProps = {
|
||||
columns: ColumnDef<ExtendedEntry>[];
|
||||
cuesheetMode: AppMode;
|
||||
source: RundownSource;
|
||||
insertElement?: ReactNode;
|
||||
};
|
||||
|
||||
type EditorCuesheetTableProps = CuesheetTableBaseProps & {
|
||||
tableRoot: 'editor';
|
||||
setCuesheetMode?: undefined;
|
||||
isCurrentRundown?: undefined;
|
||||
};
|
||||
|
||||
type ViewCuesheetTableProps = CuesheetTableBaseProps & {
|
||||
tableRoot: 'cuesheet';
|
||||
setCuesheetMode: (mode: AppMode) => void;
|
||||
isCurrentRundown?: boolean;
|
||||
};
|
||||
|
||||
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
|
||||
|
||||
export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCuesheetMode }: CuesheetTableProps) {
|
||||
const { flatRundown, status, loadedEventId } = useContextRundownTable();
|
||||
export default function CuesheetTable({
|
||||
columns,
|
||||
cuesheetMode,
|
||||
source,
|
||||
tableRoot,
|
||||
setCuesheetMode,
|
||||
isCurrentRundown,
|
||||
insertElement,
|
||||
}: CuesheetTableProps) {
|
||||
const { flatRundown, status, selectedEventId } = source;
|
||||
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
|
||||
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
|
||||
|
||||
@@ -131,17 +143,17 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
|
||||
// in Run mode, follow the current event
|
||||
useEffect(() => {
|
||||
if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !loadedEventId) {
|
||||
if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !selectedEventId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventIndex = flatRundown.findIndex((event) => event.id === loadedEventId);
|
||||
const eventIndex = flatRundown.findIndex((event) => event.id === selectedEventId);
|
||||
if (eventIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
|
||||
}, [cuesheetMode, flatRundown, loadedEventId]);
|
||||
}, [cuesheetMode, flatRundown, selectedEventId]);
|
||||
|
||||
// Provide an imperative scroll handler for explicit jumps (finder/keyboard)
|
||||
useEffect(() => {
|
||||
@@ -230,9 +242,17 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
handleResetResizing={resetColumnResizing}
|
||||
handleResetReordering={resetColumnOrder}
|
||||
handleClearToggles={setAllVisible}
|
||||
appMode={cuesheetMode}
|
||||
tableRoot={tableRoot}
|
||||
setCuesheetMode={setCuesheetMode}
|
||||
insertElement={insertElement}
|
||||
modeControls={
|
||||
tableRoot === 'cuesheet'
|
||||
? {
|
||||
cuesheetMode,
|
||||
setCuesheetMode,
|
||||
isCurrentRundown,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
showShare={tableRoot === 'cuesheet'}
|
||||
/>
|
||||
<TableVirtuoso
|
||||
ref={virtuosoRef}
|
||||
|
||||
+27
-23
@@ -10,9 +10,7 @@ import Button from '../../../../common/components/buttons/Button';
|
||||
import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||
import PopoverContents from '../../../../common/components/popover/Popover';
|
||||
import { useRundownSelectionContext } from '../../../../common/context/RundownSelectionContext';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { RundownSelect } from '../../../../features/rundown/common/RundownSelect';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { useCuesheetPermissions } from '../../useTablePermissions';
|
||||
import CuesheetShareModal from './CuesheetShareModal';
|
||||
@@ -32,15 +30,21 @@ type TableHeaderOptionValues = Pick<
|
||||
'hideTableSeconds' | 'hideIndexColumn' | 'showDelayedTimes' | 'hideDelays'
|
||||
>;
|
||||
|
||||
type TableModeControls = {
|
||||
cuesheetMode: AppMode;
|
||||
setCuesheetMode: (mode: AppMode) => void;
|
||||
isCurrentRundown?: boolean;
|
||||
};
|
||||
|
||||
interface CuesheetTableHeaderToolbarProps {
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
optionsStore: TableHeaderOptionsStore;
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
handleClearToggles: () => void;
|
||||
setCuesheetMode?: (mode: AppMode) => void;
|
||||
appMode: AppMode;
|
||||
tableRoot: 'editor' | 'cuesheet';
|
||||
insertElement?: ReactNode;
|
||||
modeControls?: TableModeControls;
|
||||
showShare?: boolean;
|
||||
}
|
||||
|
||||
export default function CuesheetTableHeaderToolbar({
|
||||
@@ -49,23 +53,23 @@ export default function CuesheetTableHeaderToolbar({
|
||||
handleResetResizing,
|
||||
handleResetReordering,
|
||||
handleClearToggles,
|
||||
setCuesheetMode,
|
||||
tableRoot,
|
||||
appMode,
|
||||
insertElement,
|
||||
modeControls,
|
||||
showShare = false,
|
||||
}: CuesheetTableHeaderToolbarProps) {
|
||||
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode) && tableRoot === 'cuesheet';
|
||||
const canShare = useCuesheetPermissions((state) => state.canShare) && tableRoot === 'cuesheet';
|
||||
const showRundownSelect = tableRoot === 'cuesheet';
|
||||
const { isLoadedRundown } = useRundownSelectionContext();
|
||||
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode);
|
||||
const canShare = useCuesheetPermissions((state) => state.canShare);
|
||||
|
||||
const toggleCuesheetMode = (mode: AppMode[]) => {
|
||||
const newValue = mode.at(0);
|
||||
if (!newValue || !setCuesheetMode) return;
|
||||
setCuesheetMode(newValue);
|
||||
if (!newValue || !modeControls) return;
|
||||
modeControls.setCuesheetMode(newValue);
|
||||
};
|
||||
|
||||
const isBackground = !(modeControls?.isCurrentRundown ?? true);
|
||||
|
||||
return (
|
||||
<Toolbar.Root className={style.tableSettings} data-background-rundown={!isLoadedRundown}>
|
||||
<Toolbar.Root className={style.tableSettings} data-background-rundown={isBackground}>
|
||||
<ViewSettings optionsStore={optionsStore} />
|
||||
<ColumnSettings
|
||||
columns={columns}
|
||||
@@ -73,14 +77,14 @@ export default function CuesheetTableHeaderToolbar({
|
||||
handleResetReordering={handleResetReordering}
|
||||
handleClearToggles={handleClearToggles}
|
||||
/>
|
||||
<div className={style.apart}>
|
||||
{showRundownSelect && <RundownSelect appMode={appMode} />}
|
||||
{canChangeMode && (
|
||||
{modeControls && canChangeMode && (
|
||||
<div className={style.apart}>
|
||||
{insertElement}
|
||||
<ToggleGroup
|
||||
value={[appMode]}
|
||||
value={[modeControls.cuesheetMode]}
|
||||
onValueChange={toggleCuesheetMode}
|
||||
className={style.group}
|
||||
disabled={!isLoadedRundown}
|
||||
disabled={!modeControls.isCurrentRundown}
|
||||
>
|
||||
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
|
||||
Run
|
||||
@@ -89,10 +93,10 @@ export default function CuesheetTableHeaderToolbar({
|
||||
Edit
|
||||
</Toolbar.Button>
|
||||
</ToggleGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canShare && (
|
||||
{showShare && canShare && (
|
||||
<>
|
||||
<Editor.Separator orientation='vertical' />
|
||||
<CuesheetShareModal />
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useSessionStorage } from '@mantine/hooks';
|
||||
import { startTransition, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useOrderedProjectList } from '../../common/hooks-query/useProjectList';
|
||||
import { useProjectRundowns } from '../../common/hooks-query/useProjectRundowns';
|
||||
import { serverURL } from '../../externals';
|
||||
|
||||
export const FOLLOW_LOADED_RUNDOWN_ID = '__follow-loaded__' as const;
|
||||
|
||||
export function getCuesheetRundownStorageKey(server: string, projectFilename: string) {
|
||||
return `cuesheet-selected-rundown:${server}:${projectFilename}`;
|
||||
}
|
||||
|
||||
export function resolveSelectedRundownId(storedSelectedRundownId: string | null, availableRundownIds: Set<string>) {
|
||||
if (storedSelectedRundownId && availableRundownIds.has(storedSelectedRundownId)) return storedSelectedRundownId;
|
||||
return FOLLOW_LOADED_RUNDOWN_ID;
|
||||
}
|
||||
|
||||
export function useCuesheetRundownSelection() {
|
||||
'use memo';
|
||||
|
||||
const { data: projectRundowns } = useProjectRundowns();
|
||||
const {
|
||||
data: { lastLoadedProject },
|
||||
} = useOrderedProjectList();
|
||||
const storageKey = useMemo(() => getCuesheetRundownStorageKey(serverURL, lastLoadedProject), [lastLoadedProject]);
|
||||
const [storedSelectedRundownId, setStoredSelectedRundownId] = useSessionStorage<string | null>({
|
||||
key: storageKey,
|
||||
defaultValue: FOLLOW_LOADED_RUNDOWN_ID,
|
||||
});
|
||||
|
||||
const availableRundownIds = new Set(projectRundowns.rundowns.map(({ id }) => id)).add(FOLLOW_LOADED_RUNDOWN_ID);
|
||||
const { loaded: loadedRundownId } = projectRundowns;
|
||||
|
||||
const selectedRundownId = resolveSelectedRundownId(storedSelectedRundownId, availableRundownIds);
|
||||
|
||||
return {
|
||||
loadedRundownId,
|
||||
selectedRundownId,
|
||||
projectRundowns: projectRundowns.rundowns,
|
||||
setSelectedRundownId: (rundownId: string) => {
|
||||
startTransition(() => {
|
||||
setStoredSelectedRundownId(rundownId);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useDirectLinkToBackgroundEdit() {
|
||||
const {
|
||||
data: { lastLoadedProject },
|
||||
} = useOrderedProjectList();
|
||||
const navigate = useNavigate();
|
||||
const storageKey = getCuesheetRundownStorageKey(serverURL, lastLoadedProject);
|
||||
const [_, setStoredSelectedRundownId] = useSessionStorage<string | null>({ key: storageKey, defaultValue: null });
|
||||
|
||||
return useCallback(
|
||||
async (rundownId: string) => {
|
||||
await navigate('/cuesheet');
|
||||
startTransition(() => setStoredSelectedRundownId(rundownId));
|
||||
},
|
||||
[setStoredSelectedRundownId, navigate],
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext';
|
||||
import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar';
|
||||
import { AppMode } from '../../ontimeConfig';
|
||||
import TitleList from './title-list/TitleList';
|
||||
@@ -15,51 +14,44 @@ const MessageControl = lazy(() => import('../../features/control/message/Message
|
||||
export default function Editor() {
|
||||
const { layoutMode } = useEditorLayout();
|
||||
|
||||
switch (layoutMode) {
|
||||
case EditorLayoutMode.TRACKING: {
|
||||
return (
|
||||
<div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}>
|
||||
<div className={styles.rundownLayout}>
|
||||
<div className={styles.titlesPanel}>
|
||||
<RundownSelectionContextProvider>
|
||||
<TitleList mode={AppMode.Run} />
|
||||
</RundownSelectionContextProvider>
|
||||
</div>
|
||||
<div className={styles.rundownPanel}>
|
||||
<Rundown />
|
||||
</div>
|
||||
</div>
|
||||
<TrackingPlaybackBar />
|
||||
if (layoutMode === EditorLayoutMode.CONTROL) {
|
||||
return (
|
||||
<div id='panels' className={styles.panelContainer}>
|
||||
<div className={styles.left}>
|
||||
<TimerControl />
|
||||
<MessageControl />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case EditorLayoutMode.PLANNING: {
|
||||
return (
|
||||
<div id='panels' className={styles.panelContainer}>
|
||||
<div className={styles.rundownLayout}>
|
||||
<div className={styles.titlesPanel}>
|
||||
<RundownSelectionContextProvider>
|
||||
<TitleList mode={AppMode.Edit} />
|
||||
</RundownSelectionContextProvider>
|
||||
</div>
|
||||
<div className={styles.rundownPanel}>
|
||||
<Rundown />
|
||||
</div>
|
||||
<Rundown />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (layoutMode === EditorLayoutMode.TRACKING) {
|
||||
return (
|
||||
<div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}>
|
||||
<div className={styles.rundownLayout}>
|
||||
<div className={styles.titlesPanel}>
|
||||
<TitleList mode={AppMode.Run} />
|
||||
</div>
|
||||
<div className={styles.rundownPanel}>
|
||||
<Rundown />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case EditorLayoutMode.CONTROL:
|
||||
default: {
|
||||
return (
|
||||
<div id='panels' className={styles.panelContainer}>
|
||||
<div className={styles.left}>
|
||||
<TimerControl />
|
||||
<MessageControl />
|
||||
</div>
|
||||
<TrackingPlaybackBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div id='panels' className={styles.panelContainer}>
|
||||
<div className={styles.rundownLayout}>
|
||||
<div className={styles.titlesPanel}>
|
||||
<TitleList mode={AppMode.Edit} />
|
||||
</div>
|
||||
<div className={styles.rundownPanel}>
|
||||
<Rundown />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useContextRundownFinder } from '../../../common/hooks-query/useContextRundown';
|
||||
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
|
||||
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
|
||||
|
||||
const maxResults = 12;
|
||||
@@ -38,7 +38,7 @@ type FilterableMilestone = {
|
||||
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
|
||||
|
||||
export default function useFinder() {
|
||||
const { rundown: data, rundownId } = useContextRundownFinder();
|
||||
const { data, rundownId } = useFlatRundown();
|
||||
const [results, setResults] = useState<FilterableEntry[]>([]);
|
||||
const [error, setError] = useState<MaybeString>(null);
|
||||
const lastSearchString = useRef('');
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
|
||||
import ScrollArea from '../../../common/components/scroll-area/ScrollArea';
|
||||
import { useContextRundownList } from '../../../common/hooks-query/useContextRundown';
|
||||
import useRundown from '../../../common/hooks-query/useRundown';
|
||||
import { useSelectedEventId } from '../../../common/hooks/useSocket';
|
||||
import { ExtendedEntry, getFlatRundownMetadata } from '../../../common/utils/rundownMetadata';
|
||||
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
||||
@@ -20,7 +20,7 @@ interface TitleListProps {
|
||||
}
|
||||
|
||||
export default function TitleList({ mode }: TitleListProps) {
|
||||
const { rundown } = useContextRundownList();
|
||||
const { data: rundown } = useRundown();
|
||||
const selectedEventId = useSelectedEventId();
|
||||
const cursor = useEventSelection((state) => state.cursor);
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { isValueOfEnum } from 'ontime-utils';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { setSelectRundownInParams } from '../../common/context/RundownSelectionContext';
|
||||
|
||||
const layoutParam = 'layout';
|
||||
|
||||
export enum EditorLayoutMode {
|
||||
@@ -23,29 +20,14 @@ function getEditorLayout(value: string | null): EditorLayoutMode {
|
||||
}
|
||||
|
||||
export function useEditorLayout() {
|
||||
'use memo';
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const layoutMode = getEditorLayout(searchParams.get(layoutParam));
|
||||
|
||||
useEffect(() => {
|
||||
setSearchParams((searchParams) => {
|
||||
if (layoutMode !== EditorLayoutMode.PLANNING) setSelectRundownInParams(null, searchParams);
|
||||
return searchParams;
|
||||
});
|
||||
}, [setSearchParams, layoutMode]);
|
||||
|
||||
const setLayoutMode = useCallback(
|
||||
(mode: EditorLayoutMode) => {
|
||||
setSearchParams((searchParams) => {
|
||||
searchParams.set(layoutParam, mode);
|
||||
// Only the Planning layout is allowed to look at something other than the current rundown
|
||||
if (mode !== EditorLayoutMode.PLANNING) setSelectRundownInParams(null, searchParams);
|
||||
return searchParams;
|
||||
});
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
const setLayoutMode = (mode: EditorLayoutMode) => {
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
nextParams.set(layoutParam, mode);
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
};
|
||||
|
||||
return { layoutMode, setLayoutMode };
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface TimelineData {
|
||||
|
||||
export function useTimelineData(): ViewData<TimelineData> {
|
||||
// HTTP API data
|
||||
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata(null);
|
||||
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
|
||||
const { data: projectData, status: projectDataStatus } = useProjectData();
|
||||
const { data: settings, status: settingsStatus } = useSettings();
|
||||
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CustomFields, ProjectData, RundownEntries, Settings, ViewSettings } fro
|
||||
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import useProjectData from '../../common/hooks-query/useProjectData';
|
||||
import { useRundown } from '../../common/hooks-query/useRundown';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
import useSettings from '../../common/hooks-query/useSettings';
|
||||
import useViewSettings from '../../common/hooks-query/useViewSettings';
|
||||
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
||||
@@ -26,7 +26,7 @@ export function useTimerData(): ViewData<TimerData> {
|
||||
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
|
||||
const { data: settings, status: settingsStatus } = useSettings();
|
||||
const { data: customFields, status: customFieldsStatus } = useCustomFields();
|
||||
const { data: rundown, status: rundownStatus } = useRundown(null);
|
||||
const { data: rundown, status: rundownStatus } = useRundown();
|
||||
const { entries } = rundown;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { copyFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { DatabaseModel, LogOrigin, ProjectFileListResponse, RefetchKey } from 'ontime-types';
|
||||
import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types';
|
||||
import { getErrorMessage, getFirstRundown } from 'ontime-utils';
|
||||
|
||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
|
||||
import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
|
||||
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
|
||||
@@ -106,9 +105,6 @@ async function loadProject(projectData: DatabaseModel, fileName: string, rundown
|
||||
currentProjectName: fileName,
|
||||
};
|
||||
|
||||
setImmediate(() => {
|
||||
sendRefetch(RefetchKey.ProjectFiles);
|
||||
});
|
||||
return fileName;
|
||||
}
|
||||
|
||||
@@ -267,10 +263,6 @@ export async function duplicateProjectFile(originalFile: string, newFilename: st
|
||||
|
||||
const pathToDuplicate = getPathToProject(newFilename);
|
||||
await copyFile(projectFilePath, pathToDuplicate);
|
||||
|
||||
setImmediate(() => {
|
||||
sendRefetch(RefetchKey.ProjectFiles);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -301,10 +293,6 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
|
||||
const newFileName = await loadProject(projectData.data, newFilename);
|
||||
return newFileName;
|
||||
}
|
||||
|
||||
setImmediate(() => {
|
||||
sendRefetch(RefetchKey.ProjectFiles);
|
||||
});
|
||||
return newFilename;
|
||||
}
|
||||
|
||||
@@ -344,9 +332,6 @@ export async function deleteProjectFile(filename: string) {
|
||||
}
|
||||
|
||||
await deleteFile(projectFilePath);
|
||||
setImmediate(() => {
|
||||
sendRefetch(RefetchKey.ProjectFiles);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,7 +374,6 @@ export async function patchCurrentProject(data: Partial<DatabaseModel>) {
|
||||
}
|
||||
}
|
||||
|
||||
const updatedData = getDataProvider().getData();
|
||||
|
||||
const updatedData = await getDataProvider().getData();
|
||||
return updatedData;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('Editing multiple events', async ({ page }) => {
|
||||
await page.goto('/editor');
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
|
||||
// clear rundown
|
||||
await page.getByRole('button', { name: 'Rundown menu' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||
|
||||
// create two events with distinct titles
|
||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(page.getByTestId('rundown-event')).toHaveCount(2);
|
||||
|
||||
await page.getByTestId('entry-1').getByTestId('entry__title').fill('first');
|
||||
await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter');
|
||||
await page.getByTestId('entry-2').getByTestId('entry__title').fill('second');
|
||||
await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter');
|
||||
|
||||
const editor = page.getByTestId('editor-container');
|
||||
|
||||
// a single selection shows the full schedule and the event id
|
||||
await page.getByTestId('entry-1').getByTestId('rundown-event').click();
|
||||
await expect(editor.getByTestId('time-input-timeStart')).toBeVisible();
|
||||
await expect(editor.getByLabel('Title', { exact: true })).toHaveValue('first');
|
||||
|
||||
// selecting both events shows a merged view
|
||||
await page
|
||||
.getByTestId('entry-2')
|
||||
.getByTestId('rundown-event')
|
||||
.click({ modifiers: ['Shift'] });
|
||||
await expect(editor.locator('#eventId')).toHaveValue('2 events selected');
|
||||
await expect(editor.getByText('Automations are not available when editing multiple events')).toBeVisible();
|
||||
|
||||
// start and end times are unique to an event, only the duration can be batched
|
||||
await expect(editor.getByTestId('time-input-timeStart')).toBeHidden();
|
||||
await expect(editor.getByTestId('time-input-timeEnd')).toBeHidden();
|
||||
await expect(editor.getByTestId('time-input-duration')).toBeVisible();
|
||||
|
||||
// fields which do not agree are shown as mixed
|
||||
const title = editor.getByLabel('Title', { exact: true });
|
||||
await expect(title).toHaveValue('');
|
||||
await expect(title).toHaveAttribute('placeholder', 'Mixed');
|
||||
|
||||
// leaving a mixed field without editing it does not overwrite the events
|
||||
await title.click();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.getByTestId('entry-1').getByTestId('entry__title')).toHaveValue('first');
|
||||
await expect(page.getByTestId('entry-2').getByTestId('entry__title')).toHaveValue('second');
|
||||
|
||||
// a mixed field cannot be cleared by emptying it, so an explicit action is offered
|
||||
const clearTitle = editor.getByRole('button', { name: 'Clear', exact: true }).first();
|
||||
await expect(clearTitle).toBeVisible();
|
||||
await clearTitle.click();
|
||||
await expect(page.getByTestId('entry-1').getByTestId('entry__title')).toHaveValue('');
|
||||
await expect(page.getByTestId('entry-2').getByTestId('entry__title')).toHaveValue('');
|
||||
|
||||
// the events now agree, so the field can be cleared by hand and the action is withdrawn
|
||||
await expect(editor.getByLabel('Title', { exact: true })).not.toHaveAttribute('placeholder', 'Mixed');
|
||||
|
||||
// editing a field applies it to the whole selection
|
||||
await title.fill('shared title');
|
||||
await title.press('Enter');
|
||||
await expect(page.getByTestId('entry-1').getByTestId('entry__title')).toHaveValue('shared title');
|
||||
await expect(page.getByTestId('entry-2').getByTestId('entry__title')).toHaveValue('shared title');
|
||||
|
||||
// the value is no longer mixed
|
||||
await expect(editor.getByLabel('Title', { exact: true })).toHaveValue('shared title');
|
||||
|
||||
// a batched duration is applied to every event and the rundown is recalculated once
|
||||
const duration = editor.getByTestId('time-input-duration');
|
||||
await duration.click();
|
||||
await duration.fill('5m');
|
||||
await duration.press('Enter');
|
||||
await expect(page.getByTestId('entry-1').getByTestId('time-input-duration')).toHaveValue('00:05:00');
|
||||
await expect(page.getByTestId('entry-2').getByTestId('time-input-duration')).toHaveValue('00:05:00');
|
||||
// the second event is linked, so its start follows the new end of the first
|
||||
await expect(page.getByTestId('entry-2').getByTestId('time-input-timeStart')).toHaveValue('00:05:00');
|
||||
await expect(page.getByTestId('entry-2').getByTestId('time-input-timeEnd')).toHaveValue('00:10:00');
|
||||
|
||||
// going back to a single selection restores the full editor
|
||||
await page.getByTestId('entry-1').getByTestId('rundown-event').click();
|
||||
await expect(editor.getByTestId('time-input-timeStart')).toBeVisible();
|
||||
await expect(editor.locator('#eventId')).not.toHaveValue('2 events selected');
|
||||
});
|
||||
@@ -33,8 +33,8 @@ test('imports spreadsheet and applies imported rundown to editor', async ({ page
|
||||
await page.getByRole('button', { name: 'Preview import' }).click();
|
||||
await page.getByRole('button', { name: 'Apply import' }).click();
|
||||
await expect(page.getByText('Import complete')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Open editor' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Import another' }).click();
|
||||
await expect(page.getByText('Spreadsheet data applied.')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Reset flow' }).click();
|
||||
|
||||
// verify the data in the rundown
|
||||
await page.getByRole('button', { name: 'Close settings' }).scrollIntoViewIfNeeded();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export enum RefetchKey {
|
||||
All = 'all',
|
||||
CustomFields = 'custom-fields',
|
||||
ProjectFiles = 'project-files',
|
||||
ProjectData = 'project-data',
|
||||
ProjectRundowns = 'project-rundowns',
|
||||
Report = 'report',
|
||||
|
||||
Reference in New Issue
Block a user