Compare commits

..

8 Commits

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

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

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

Add deriveQueryStatus(), a small helper that only reports 'error' when a
query has never received data, and apply it in the five base data hooks
(useRundown, useRundownById, useCustomFields, useSettings, useProjectData,
useViewSettings). This fixes the class of bug for every consumer (Operator,
Timer, Backstage, Studio, Countdown, TimelinePage, ProjectInfo, RundownList,
CuesheetTable) at the source, so the Operator-specific revision check can
be reverted back to the plain status check.
2026-08-01 18:53:44 +00:00
Claude 65e1d3cfe9 fix(operator): only show error empty state when no rundown data is available
The operator view previously replaced its content with the full-page
error state on any query error, even when a previously fetched rundown
was still cached. Now it falls back to rendering the cached data and
only shows the error placeholder if a rundown has never been received.
2026-08-01 18:47:01 +00:00
Carlos Valente 94d54529ee refactor(views): consistent loading and empty states 2026-08-01 16:25:14 +02:00
Carlos Valente f7535651f6 refactor(info): improve UI consistency and polish 2026-08-01 14:41:39 +02:00
Carlos Valente 5cf36f049a refactor(modal): allow wider modals 2026-07-25 14:28:45 +02:00
Carlos Valente c6248b0c72 refactor(import): improve preview UI 2026-07-25 14:28:45 +02:00
73 changed files with 1027 additions and 1307 deletions
@@ -6,8 +6,7 @@ import SwatchPicker from './SwatchPicker';
import style from './SwatchSelect.module.scss';
interface ColourInputProps {
/** undefined represents values which do not agree, no swatch is highlighted */
value: string | undefined;
value: string;
name: 'colour';
handleChange: (newValue: 'colour', name: string) => void;
}
@@ -45,11 +44,7 @@ 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={value !== undefined && !colours.includes(value)}
/>
<SwatchPicker color={value} onChange={setColour} isSelected={!colours.includes(value)} />
</div>
);
}
@@ -1,31 +0,0 @@
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,23 +1,6 @@
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;
@@ -26,8 +9,7 @@ interface UseReactiveTextInputReturn {
}
export default function useReactiveTextInput(
/** undefined represents an unknown value, the field is shown empty and blurring it submits nothing */
initialText: string | undefined,
initialText: string,
submitCallback: (newValue: string) => void,
ref: RefObject<HTMLInputElement | HTMLTextAreaElement | null>,
options?: {
@@ -40,7 +22,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
@@ -75,7 +57,8 @@ export default function useReactiveTextInput(
*/
const handleSubmit = useCallback(
(valueToSubmit: string) => {
if (!shouldSubmitValue(valueToSubmit, initialText, options?.allowSubmitSameValue)) {
// No need to update if it hasn't changed
if (valueToSubmit === initialText && !options?.allowSubmitSameValue) {
options?.onCancelUpdate?.();
} else {
const cleanVal = valueToSubmit.trim();
@@ -103,10 +86,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,10 +41,7 @@ export default function TimeInput<T extends string>({
* @description Resets input value to given
*/
const resetValue = useCallback(() => {
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)) {
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(1600px, 96vw);
max-width: min(1800px, 98vw);
height: 88vh;
max-height: 88vh;
display: flex;
@@ -16,21 +16,13 @@ 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',
placeholder,
...selectRootProps
}: SelectProps<T>) {
export default function Select<T>({ options, fluid, size = 'medium', ...selectRootProps }: SelectProps<T>) {
return (
<BaseSelect.Root items={options} {...selectRootProps}>
<BaseSelect.Trigger className={cx([styles.select, styles[size], fluid && styles.fluid])}>
<BaseSelect.Value placeholder={placeholder} />
<BaseSelect.Value />
<BaseSelect.Icon className={styles.selectIcon}>
<LuChevronsUpDown />
</BaseSelect.Icon>
@@ -1,20 +1,43 @@
@use '@/theme/viewerDefs' as *;
.emptyContainer {
width: 100%;
text-align: center;
color: $white-10;
color: var(--secondary-color-override, $viewer-secondary-color);
.empty {
display: block;
width: min(100%, 24rem);
margin-inline: auto;
opacity: 0.8;
width: min(100%, 14rem);
margin: 0 auto -1.5rem;
opacity: 0.6;
}
.text {
display: block;
margin-inline: auto;
font-weight: 600;
font-size: 2em;
max-width: min(100%, 600px);
font-weight: 400;
font-size: clamp(1rem, 1.55vw, 1.5rem);
line-height: 1.35;
max-width: min(100%, 40rem);
}
&.error {
color: $error-red;
.empty {
opacity: 0.35;
filter: grayscale(1);
}
.text {
margin-top: 0.5rem;
}
}
.errorIcon {
display: block;
width: 1.5rem;
height: 1.5rem;
margin: -0.125rem auto 0;
}
}
@@ -1,4 +1,5 @@
import { CSSProperties } from 'react';
import { IoWarningOutline } from 'react-icons/io5';
import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils';
@@ -9,12 +10,18 @@ interface EmptyProps {
text?: string;
injectedStyles?: CSSProperties;
className?: string;
variant?: 'error';
}
export default function Empty({ text, className, injectedStyles }: EmptyProps) {
export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) {
return (
<div className={cx([style.emptyContainer, className])} style={injectedStyles}>
<div
className={cx([style.emptyContainer, variant === 'error' && style.error, className])}
style={injectedStyles}
role={variant === 'error' ? 'alert' : undefined}
>
<EmptyImage className={style.empty} />
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
{text && <span className={style.text}>{text}</span>}
</div>
);
@@ -0,0 +1,11 @@
.fill {
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
flex: 1;
display: grid;
place-items: start center;
padding: clamp(4rem, 20dvh, 12rem) 1.5rem 1rem;
}
@@ -0,0 +1,19 @@
import { cx } from '../../utils/styleUtils';
import Empty from './Empty';
import style from './EmptyFill.module.scss';
interface EmptyFillProps {
text?: string;
/** placed on the fill wrapper — e.g. to assign a grid-area in a grid parent */
className?: string;
}
/** Container-filling empty/loading state for panels and grid/flex cells. */
export default function EmptyFill({ text, className }: EmptyFillProps) {
return (
<div className={cx([style.fill, className])}>
<Empty text={text} />
</div>
);
}
@@ -6,7 +6,7 @@
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
height: 100dvh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
@@ -16,5 +16,6 @@
display: flex;
flex-direction: column;
align-items: center;
padding-top: 5rem;
justify-content: center;
padding-block: 5rem;
}
@@ -7,12 +7,13 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps {
text?: string;
injectedStyles?: CSSProperties;
variant?: 'error';
}
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) {
return (
<div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} />
<Empty text={text} injectedStyles={injectedStyles} variant={variant} />
</div>
);
}
@@ -15,9 +15,4 @@
gap: 1rem;
margin-top: 1em;
}
.text {
font-weight: 600;
font-size: 2em;
}
}
@@ -18,8 +18,7 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
<tbody className={style.emptyContainer}>
<tr>
<td colSpan={99} className={style.emptyCell}>
<Empty injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
<Empty text={text} injectedStyles={{ marginTop: '5vh' }} />
{handleAddNew && (
<div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
@@ -29,11 +29,6 @@
}
}
/* 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,13 +6,11 @@ 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', mixed, ...switchProps }: SwitchProps) {
export default function Switch({ size = 'medium', ...switchProps }: SwitchProps) {
return (
<BaseSwitch.Root className={cx([style.switch, style[size], mixed && style.mixed])} {...switchProps}>
<BaseSwitch.Root className={cx([style.switch, style[size]])} {...switchProps}>
<BaseSwitch.Thumb className={style.thumb} />
</BaseSwitch.Root>
);
@@ -8,12 +8,12 @@ import { getCustomFields } from '../api/customFields';
const placeholder: CustomFields = {};
export default function useCustomFields() {
const { data, status, isFetching, isError, refetch } = useQuery({
const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS,
queryFn: ({ signal }) => getCustomFields({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? placeholder, status, isFetching, isError, refetch };
return { data: data ?? placeholder, status, isFetching, isError, isLoadingError, refetch };
}
@@ -6,14 +6,14 @@ import { getProjectData, postProjectData } from '../api/project';
import { projectDataPlaceholder } from '../models/ProjectData';
export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({
const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({
queryKey: PROJECT_DATA,
queryFn: ({ signal }) => getProjectData({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch };
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, isLoadingError, refetch };
}
export function useUpdateProjectData() {
@@ -32,7 +32,7 @@ export default function useRundown() {
data: { loaded: loadedRundownId },
} = useProjectRundowns();
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
const { data, status, isError, isLoadingError, refetch, isFetching } = useQuery<Rundown>({
queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
refetchInterval: queryRefetchIntervalSlow,
@@ -50,14 +50,14 @@ export default function useRundown() {
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [loadedRundownId, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
return { data: data ?? cachedRundownPlaceholder, status, isError, isLoadingError, refetch, isFetching };
}
export function useRundownWithMetadata() {
const { data, status } = useRundown();
const { data, status, isLoadingError } = useRundown();
const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, rundownMetadata };
return { data, status, isLoadingError, rundownMetadata };
}
/**
@@ -65,7 +65,7 @@ export function useRundownWithMetadata() {
* built from the order and rundown fields
*/
export function useFlatRundown() {
const { data, status } = useRundown();
const { data, status, isLoadingError } = useRundown();
const flatRundown = useMemo(() => {
if (data.revision === -1) {
@@ -74,15 +74,15 @@ export function useFlatRundown() {
return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]);
return { data: flatRundown, rundownId: data.id, status };
return { data: flatRundown, rundownId: data.id, status, isLoadingError };
}
export function useFlatRundownWithMetadata() {
const { data, status } = useRundown();
const { data, status, isLoadingError } = useRundown();
const selectedEventId = useSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status };
return { data: rundownWithMetadata, status, isLoadingError };
}
/**
@@ -127,7 +127,7 @@ export function useRundownAuxData() {
export function useRundownById(rundownId: string | null | undefined) {
const enabled = Boolean(rundownId);
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
const { data, status, isError, isLoadingError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(rundownId ?? ''),
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
enabled,
@@ -135,5 +135,5 @@ export function useRundownById(rundownId: string | null | undefined) {
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
return { data: data ?? cachedRundownPlaceholder, status, isError, isLoadingError, refetch, isFetching };
}
@@ -11,6 +11,7 @@ export type RundownSource = {
rundown: Rundown;
flatRundown: ExtendedEntry[];
status: string;
isLoadingError: boolean;
selectedEventId: EntryId | null;
};
@@ -35,7 +36,7 @@ function useRundownSource(rundownId: string | null, loadedRundownId: string | nu
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
const runtimeSelectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
const { data: rundown, status } = useRundownById(rundownId);
const { data: rundown, status, isLoadingError } = useRundownById(rundownId);
const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
@@ -47,8 +48,9 @@ function useRundownSource(rundownId: string | null, loadedRundownId: string | nu
rundown,
flatRundown,
status,
isLoadingError,
selectedEventId: effectiveSelectedEventId,
}),
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
[effectiveSelectedEventId, flatRundown, isLoadingError, rundown, rundownId, status],
);
}
@@ -6,7 +6,7 @@ import { getSettings } from '../api/settings';
import { ontimePlaceholderSettings } from '../models/OntimeSettings';
export default function useSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({
queryKey: APP_SETTINGS,
queryFn: ({ signal }) => getSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
@@ -22,5 +22,5 @@ export default function useSettings() {
},
});
return { data: data ?? ontimePlaceholderSettings, status, isFetching, isError, refetch };
return { data: data ?? ontimePlaceholderSettings, status, isFetching, isError, isLoadingError, refetch };
}
@@ -7,7 +7,7 @@ import { VIEW_SETTINGS } from '../api/constants';
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() {
const { data, status } = useQuery({
const { data, status, isLoadingError } = useQuery({
queryKey: VIEW_SETTINGS,
queryFn: ({ signal }) => getViewSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
@@ -24,5 +24,5 @@ export default function useViewSettings() {
},
});
return { data: data ?? viewsSettingsPlaceholder, status, mutateAsync };
return { data: data ?? viewsSettingsPlaceholder, status, isLoadingError, mutateAsync };
}
@@ -1,76 +0,0 @@
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);
});
});
@@ -1,39 +0,0 @@
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);
}
+15 -4
View File
@@ -52,7 +52,6 @@ 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)
@@ -484,9 +483,21 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
// Snapshot the previous value
const previousRundown = queryClient.getQueryData<Rundown>(queryKey);
// 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);
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,
};
}
}
});
queryClient.setQueryData<Rundown>(queryKey, {
id: previousRundown.id,
@@ -25,6 +25,13 @@
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));
@@ -8,6 +8,7 @@ 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,
@@ -56,9 +57,11 @@ 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();
const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -101,6 +104,7 @@ export default function SourcesPanel() {
setHasFile('none');
setActiveSource(null);
setError('');
setCompletedRundownTitle('');
};
const openGSheetFlow = () => {
@@ -123,11 +127,12 @@ export default function SourcesPanel() {
}
};
const handleFinished = () => {
const handleFinished = (rundownTitle: string) => {
setImportFlow('finished');
setHasFile('none');
setActiveSource(null);
setError('');
setCompletedRundownTitle(rundownTitle);
};
const handleApplyImport = async (
@@ -139,7 +144,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();
handleFinished(title);
return;
}
@@ -156,7 +161,7 @@ export default function SourcesPanel() {
customFields: preview.customFields,
providedFields,
});
handleFinished();
handleFinished(currentRundown.title);
};
const loadWorksheetMetadata = useCallback(
@@ -289,11 +294,20 @@ export default function SourcesPanel() {
{showCompleted && (
<div className={style.finishSection}>
<span className={style.finishBadge}>Import complete</span>
<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 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>
)}
{isGSheetFlow && (
@@ -92,6 +92,10 @@
font-weight: 600;
}
.toolbarWarning {
color: $orange-400;
}
.mappingPaneTitle {
align-self: center;
}
@@ -132,7 +132,16 @@ export default function SheetImportEditor({
}
/>
</label>
{toolbarStatus && <Panel.Description>{toolbarStatus}</Panel.Description>}
{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>
)}
</Panel.InlineElements>
<div className={style.editorBody}>
@@ -150,13 +159,19 @@ export default function SheetImportEditor({
<section className={style.previewPane}>
<div className={style.previewPaneHeader}>
<span className={style.previewPaneTitle}>Import preview</span>
<div className={style.previewPaneHeading}>
<span className={style.previewPaneTitle}>Import preview</span>
</div>
</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>
@@ -1,27 +1,35 @@
.emptyState {
height: 100%;
min-height: 16rem;
display: grid;
place-content: center;
gap: 0.35rem;
padding: 1.5rem;
padding: 3rem 1.5rem;
text-align: center;
}
.emptyMessage {
width: min(30rem, 100%);
margin-inline: auto;
}
.emptyTitle {
color: $ui-white;
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.25rem;
color: rgba($gray-200, 0.72);
font-size: calc(1rem + 2px);
font-weight: 400;
}
.emptyBody {
color: $gray-400;
font-size: 0.95rem;
color: rgba($gray-200, 0.55);
font-size: calc(1rem - 3px);
line-height: 1.5;
}
.emptyAction {
margin: 1rem auto 0;
}
.table {
width: 100%;
border-collapse: collapse;
border-collapse: separate;
border-spacing: 0;
color: $ui-white;
font-size: calc(1rem - 2px);
text-align: left;
table-layout: auto;
@@ -34,31 +42,101 @@
}
th {
font-weight: 400;
color: $gray-400;
text-transform: capitalize;
vertical-align: top;
color: $gray-300;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
vertical-align: bottom;
white-space: normal;
}
th,
td {
padding: 0.5rem;
box-sizing: border-box;
min-width: 8rem;
vertical-align: top;
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;
}
tr:nth-child(even) {
background-color: $white-1;
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);
}
td[data-empty='true'] {
color: $gray-600;
}
}
.rowNumber,
.rowType {
position: sticky;
z-index: 1;
background-color: inherit;
}
.rowNumber {
width: 4.5rem;
min-width: 4.5rem;
left: 0;
width: 3.25rem;
min-width: 3.25rem !important;
color: $gray-400;
text-align: right;
}
.rowType {
width: 7rem;
min-width: 7rem;
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;
}
@@ -1,7 +1,11 @@
import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types';
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { isOntimeDelay, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import type { CSSProperties } from 'react';
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';
@@ -9,14 +13,85 @@ 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(() => {
@@ -24,33 +99,57 @@ 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.emptyTitle}>Preview not generated</div>
<div className={style.emptyBody}>{emptyContent}</div>
<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>
);
}
let eventIndex = 0;
return (
<table className={style.table}>
<thead>
<tr>
<th className={style.rowNumber}>#</th>
<th className={style.rowType}>Type</th>
{columnLabels.map((label, index) => (
{displayColumns.map((label, index) => (
<th key={`${label}-${index}`}>{label}</th>
))}
</tr>
@@ -59,16 +158,29 @@ export default function PreviewTable({
{preview.rundown.flatOrder.map((entryId) => {
const entry = preview.rundown.entries[entryId];
const isEvent = isOntimeEvent(entry);
if (isEvent) eventIndex++;
const hasType = isEvent || isOntimeGroup(entry) || isOntimeMilestone(entry);
const entryMetadata = previewMetadata?.[entryId];
const { rowClassName, entryColour, entryType } = getEntryDisplay(entry, entryMetadata?.groupColour);
return (
<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 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>
);
})}
@@ -5,7 +5,8 @@ import type {
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
} from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { millisToString, removeTrailingZero } from 'ontime-utils';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
@@ -32,7 +33,7 @@ type ImportAction =
| { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse }
| { type: 'applySuccess' }
| { type: 'exportSuccess' }
| { type: 'clearPreview'; error?: string }
| { type: 'clearPreview'; error?: string; needsRefresh?: boolean }
| { type: 'failure'; error: string }
| { type: 'reset' };
@@ -40,12 +41,14 @@ 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 {
@@ -57,15 +60,15 @@ function importReducer(state: ImportState, action: ImportAction): ImportState {
case 'startExport':
return { ...state, loading: 'export', error: '' };
case 'previewSuccess':
return { loading: '', error: '', preview: action.preview };
return { loading: '', error: '', preview: action.preview, needsPreviewRefresh: false };
case 'applySuccess':
case 'exportSuccess':
return { ...state, loading: '' };
case 'clearPreview':
return { ...state, error: action.error ?? '', preview: null };
return { ...state, error: action.error ?? '', preview: null, needsPreviewRefresh: action.needsRefresh ?? false };
case 'failure': {
if (state.loading === 'preview') {
return { loading: '', error: action.error, preview: null };
return { loading: '', error: action.error, preview: null, needsPreviewRefresh: false };
}
return { ...state, loading: '', error: action.error };
}
@@ -221,7 +224,7 @@ export function useSheetImportForm({
const sub = watch(() => {
if (!previewRef.current) return;
previewRef.current = null;
dispatch({ type: 'clearPreview' });
dispatch({ type: 'clearPreview', needsRefresh: true });
});
return () => sub.unsubscribe();
}, [watch]);
@@ -297,15 +300,37 @@ export function useSheetImportForm({
}, [append]);
const toolbarStatus = (() => {
const warningText = warningCount > 0 ? ` | warnings: ${warningCount}` : '';
if (!state.preview) {
return `entries: | start: | end: | duration: ${warningText}`;
return {
entries: '',
groups: '',
milestones: '',
start: '',
end: '',
duration: '',
warnings: warningCount,
};
}
const { flatOrder } = state.preview.rundown;
const { entries, flatOrder } = state.preview.rundown;
const { start, end, duration } = state.preview.summary;
return `entries: ${flatOrder.length} | start: ${millisToString(start)} | end: ${millisToString(end)} | duration: ${formatDuration(duration)}${warningText}`;
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 {
+110 -102
View File
@@ -1,6 +1,7 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent';
@@ -10,6 +11,7 @@ import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle';
import { getDefaultFormat } from '../../common/utils/time';
import { isTouchDevice } from '../../externals';
import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../../views/common/loader/Loader';
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
import FollowButton from './follow-button/FollowButton';
@@ -35,7 +37,7 @@ export default function OperatorLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Operator {...data} />;
@@ -43,6 +45,7 @@ export default function OperatorLoader() {
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
const selectedEventId = useSelectedEventId();
const { getLocalizedString } = useTranslation();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
const [showEditPrompt, setShowEditPrompt] = useState(false);
@@ -113,6 +116,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
const canEdit = shouldEdit && subscribe.length;
const hasEvents = rundown.order.length > 0;
return (
<div className={style.operatorContainer} data-testid='operator-view'>
@@ -127,117 +131,121 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
</div>
)}
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{rundown.order.map((entryId) => {
const entry = rundown.entries[entryId];
if (isOntimeEvent(entry)) {
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || entry.skip) {
return null;
}
{!hasEvents ? (
<EmptyFill text={getLocalizedString('common.no_data')} />
) : (
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{rundown.order.map((entryId) => {
const entry = rundown.entries[entryId];
if (isOntimeEvent(entry)) {
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || entry.skip) {
return null;
}
const { mainField, secondaryField, subscribedData } = getEventData(
entry,
mainSource,
secondarySource,
subscribe,
customFields,
);
const { mainField, secondaryField, subscribedData } = getEventData(
entry,
mainSource,
secondarySource,
subscribe,
customFields,
);
return (
<OperatorEvent
key={entry.id}
id={entry.id}
colour={entry.colour}
cue={entry.cue}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
duration={entry.duration}
delay={entry.delay}
dayOffset={entry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
}
if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
if (hidePast && isPast && !isCurrentParent) {
return null;
}
return (
<Fragment key={entry.id}>
<OperatorGroup
return (
<OperatorEvent
key={entry.id}
title={entry.title}
id={entry.id}
colour={entry.colour}
count={entry.entries.length}
cue={entry.cue}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
duration={entry.duration}
delay={entry.delay}
dayOffset={entry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
{entry.entries.map((nestedEntryId) => {
const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) {
return null;
}
);
}
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || nestedEntry.skip) {
return null;
}
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
const { mainField, secondaryField, subscribedData } = getEventData(
nestedEntry,
mainSource,
secondarySource,
subscribe,
customFields,
);
if (hidePast && isPast && !isCurrentParent) {
return null;
}
return (
<OperatorEvent
key={nestedEntry.id}
id={nestedEntry.id}
colour={nestedEntry.colour}
cue={nestedEntry.cue}
main={mainField}
secondary={secondaryField}
timeStart={nestedEntry.timeStart}
duration={nestedEntry.duration}
delay={nestedEntry.delay}
dayOffset={nestedEntry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
groupColour={entry.colour}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
})}
</Fragment>
);
}
return null;
})}
</div>
return (
<Fragment key={entry.id}>
<OperatorGroup
key={entry.id}
title={entry.title}
colour={entry.colour}
count={entry.entries.length}
duration={entry.duration}
/>
{entry.entries.map((nestedEntryId) => {
const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) {
return null;
}
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || nestedEntry.skip) {
return null;
}
const { mainField, secondaryField, subscribedData } = getEventData(
nestedEntry,
mainSource,
secondarySource,
subscribe,
customFields,
);
return (
<OperatorEvent
key={nestedEntry.id}
id={nestedEntry.id}
colour={nestedEntry.colour}
cue={nestedEntry.cue}
main={mainField}
secondary={secondaryField}
timeStart={nestedEntry.timeStart}
duration={nestedEntry.duration}
delay={nestedEntry.delay}
dayOffset={nestedEntry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
groupColour={entry.colour}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
})}
</Fragment>
);
}
return null;
})}
</div>
)}
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
</div>
);
@@ -14,9 +14,18 @@ export interface OperatorData {
}
export function useOperatorData(): ViewData<OperatorData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: settings, status: settingsStatus } = useSettings();
const {
data: rundown,
rundownMetadata,
status: rundownStatus,
isLoadingError: rundownIsLoadingError,
} = useRundownWithMetadata();
const {
data: customFields,
status: customFieldStatus,
isLoadingError: customFieldIsLoadingError,
} = useCustomFields();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
return {
data: {
@@ -25,6 +34,10 @@ export function useOperatorData(): ViewData<OperatorData> {
customFields,
settings,
},
status: aggregateQueryStatus([rundownStatus, customFieldStatus, settingsStatus]),
status: aggregateQueryStatus([
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: customFieldStatus, isLoadingError: customFieldIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
]),
};
}
@@ -1,19 +1,24 @@
import { memo } from 'react';
import Empty from '../../common/components/state/Empty';
import EmptyFill from '../../common/components/state/EmptyFill';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { useTranslation } from '../../translation/TranslationProvider';
import Rundown from './Rundown';
export default memo(RundownList);
function RundownList() {
const { data, status, rundownMetadata } = useRundownWithMetadata();
const { data, status, isLoadingError, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor();
const { getLocalizedString } = useTranslation();
const isLoading = status !== 'success' || !data || !rundownMetadata;
// avoid showing the editable empty state before we know whether the rundown is actually empty
if (status === 'pending') {
return <EmptyFill text='Loading…' />;
}
if (isLoading) {
return <Empty text='Connecting to server' />;
if (isLoadingError) {
return <EmptyFill text={getLocalizedString('common.no_data')} />;
}
return (
@@ -22,13 +22,10 @@ export default function CuesheetEntryEditor({ entryId, rundown }: CuesheetEntryE
return event ?? null;
}, [entryId, rundown.entries, rundown.order.length]);
// the cuesheet always edits a single entry
const events = useMemo(() => (isOntimeEvent(entry) ? [entry] : null), [entry]);
if (events) {
if (isOntimeEvent(entry)) {
return (
<div className={style.entryEditor} data-testid='editor-container'>
<EventEditor events={events} />
<EventEditor event={entry} />
</div>
);
}
@@ -75,26 +75,6 @@
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,19 +1,14 @@
import { OntimeEvent } from 'ontime-types';
import { useCallback, useMemo } from 'react';
import { useCallback } 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';
@@ -21,115 +16,66 @@ import style from './EntryEditor.module.scss';
export type EventEditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | string;
interface EventEditorProps {
/** events being edited, editing several events at once shows a merged view of their values */
events: OntimeEvent[];
event: OntimeEvent;
}
export default function EventEditor({ events }: EventEditorProps) {
export default function EventEditor({ event }: EventEditorProps) {
const { data: customFields } = useCustomFields();
const { updateEntry, batchUpdateEvents } = useEntryActionsContext();
const { updateEntry } = 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];
submit({ custom: { [fieldLabel]: value } });
updateEntry({ id: event.id, custom: { [fieldLabel]: value } });
} else {
submit({ [field]: value });
updateEntry({ id: event.id, [field]: value });
}
},
[submit],
[event.id, updateEntry],
);
// 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={`${editorKey}-times`}
countToEnd={resolveConflict(merged.countToEnd)}
endAction={resolveConflict(merged.endAction)}
timerType={resolveConflict(merged.timerType)}
timeWarning={resolveConflict(merged.timeWarning)}
timeDanger={resolveConflict(merged.timeDanger)}
submit={submit}
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}
/>
<EventEditorTitles
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}
key={`${event.id}-titles`}
eventId={event.id}
cue={event.cue}
flag={event.flag}
title={event.title}
note={event.note}
colour={event.colour}
/>
<div className={style.column}>
<Editor.Title>
Custom Fields
{isEditor && <AppLink search='settings=manage__custom'>Manage Custom Fields</AppLink>}
</Editor.Title>
<EntryEditorCustomFields
key={`${editorKey}-custom`}
fields={customFields}
custom={merged.custom}
idKey={editorKey}
mixedPlaceholder={mixedPlaceholder}
handleSubmit={handleSubmit}
/>
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={event} />
</div>
<div className={style.column}>
<Editor.Title>
Automations
{isEditor && singleEvent && <AppLink search='settings=automation'>Manage Automations</AppLink>}
{isEditor && <AppLink search='settings=automation'>Manage Automations</AppLink>}
</Editor.Title>
{singleEvent ? (
<EventEditorTriggers triggers={singleEvent.triggers} eventId={singleEvent.id} />
) : (
<Info>Automations are not available when editing multiple events</Info>
)}
<EventEditorTriggers triggers={event.triggers} eventId={event.id} />
</div>
</div>
);
@@ -112,12 +112,7 @@ 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}
custom={group.custom}
idKey={group.id}
/>
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={group} />
</div>
</div>
);
@@ -69,12 +69,7 @@ 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}
custom={milestone.custom}
idKey={milestone.id}
/>
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={milestone} />
</div>
</div>
);
@@ -1,4 +1,4 @@
import { OntimeEntry, OntimeEvent, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { useMemo } from 'react';
import useRundown from '../../../common/hooks-query/useRundown';
@@ -15,25 +15,6 @@ export default function RundownEntryEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents);
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) {
return null;
@@ -48,20 +29,19 @@ export default function RundownEntryEditor() {
return event ?? null;
}, [data.order.length, data.entries, selectedEvents]);
if (events.length > 0) {
const singleEvent = events.length === 1 ? events[0] : null;
return (
<div className={style.rundownEditor} data-testid='editor-container'>
<EventEditor events={events} />
{singleEvent && <EventEditorFooter id={singleEvent.id} cue={singleEvent.cue} />}
</div>
);
}
if (!entry) {
return <EventEditorEmpty />;
}
if (isOntimeEvent(entry)) {
return (
<div className={style.rundownEditor} data-testid='editor-container'>
<EventEditor event={entry} />
<EventEditorFooter id={entry.id} cue={entry.cue} />
</div>
);
}
if (isOntimeMilestone(entry)) {
return (
<div className={style.rundownEditor} data-testid='editor-container'>
@@ -1,122 +0,0 @@
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();
});
});
@@ -1,48 +0,0 @@
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>
);
}
@@ -1,9 +1,8 @@
import { CustomFields, EntryCustomFields } from 'ontime-types';
import { CustomFields, OntimeEvent, OntimeGroup, OntimeMilestone } 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';
@@ -12,29 +11,21 @@ import style from '../EntryEditor.module.scss';
interface EntryEditorCustomFieldsProps {
fields: CustomFields;
/** 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;
entry: OntimeEvent | OntimeGroup | OntimeMilestone;
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 = `${idKey}-${fieldKey}`;
const key = `${entry.id}-${fieldKey}`;
const fieldName = `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 initialValue = entry.custom[fieldKey] ?? '';
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
const labelText = customFields[fieldKey].label;
@@ -45,7 +36,6 @@ 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}
@@ -61,13 +51,13 @@ export default function EntryEditorCustomFields({
field={fieldName}
label={labelText}
initialValue={initialValue}
placeholder={placeholder ?? 'Paste image URL'}
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>
);
}
@@ -1,62 +0,0 @@
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, OntimeEvent, TimerType } from 'ontime-types';
import { EndAction, TimeStrategy, TimerType } from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
import { memo } from 'react';
import { IoInformationCircle } from 'react-icons/io5';
@@ -8,47 +8,103 @@ 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 { mixedPlaceholder, switchLabel } from '../entryEditor.utils';
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 style from '../EntryEditor.module.scss';
interface EventEditorTimesProps {
countToEnd: boolean | undefined;
endAction: EndAction | undefined;
timerType: TimerType | undefined;
timeWarning: number | undefined;
timeDanger: number | undefined;
submit: (patch: Partial<OntimeEvent>) => void;
eventId: string;
timeStart: number;
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: boolean;
countToEnd: boolean;
delay: number;
endAction: EndAction;
timerType: TimerType;
timeWarning: number;
timeDanger: number;
}
type TimeFields = 'timeWarning' | 'timeDanger';
type HandledActions = 'countToEnd' | 'timerType' | 'endAction' | 'timeWarning' | 'timeDanger';
export default memo(EventEditorTimes);
function EventEditorTimes({
eventId,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
countToEnd,
delay,
endAction,
timerType,
timeWarning,
timeDanger,
submit,
}: EventEditorTimesProps) {
const handleTimeSubmit = (field: TimeFields, value: string) => {
submit({ [field]: parseUserTime(value) });
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 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 ?? null}
placeholder={mixedPlaceholder}
value={endAction}
onValueChange={(value: EndAction | null) => {
if (value === null) return;
submit({ endAction: value });
handleSubmit('endAction', value);
}}
options={[
{ value: EndAction.None, label: 'None' },
@@ -62,11 +118,10 @@ function EventEditorTimes({
<Editor.Label className={style.switchLabel}>
<Switch
id='countToEnd'
checked={countToEnd ?? false}
mixed={countToEnd === undefined}
onCheckedChange={(value) => submit({ countToEnd: value })}
checked={countToEnd}
onCheckedChange={(value) => handleSubmit('countToEnd', value)}
/>
{switchLabel(countToEnd)}
{countToEnd ? 'On' : 'Off'}
</Editor.Label>
</div>
</div>
@@ -86,11 +141,10 @@ function EventEditorTimes({
<div>
<Editor.Label htmlFor='timerType'>Timer Type</Editor.Label>
<Select
value={timerType ?? null}
placeholder={mixedPlaceholder}
value={timerType}
onValueChange={(value: TimerType | null) => {
if (value === null) return;
submit({ timerType: value });
handleSubmit('timerType', value);
}}
options={[
{ value: TimerType.CountDown, label: 'Count down' },
@@ -107,9 +161,9 @@ function EventEditorTimes({
<TimeInput
id='timeWarning'
name='timeWarning'
submitHandler={handleTimeSubmit}
submitHandler={handleSubmit}
time={timeWarning}
placeholder={timeWarning === undefined ? mixedPlaceholder : 'Duration'}
placeholder='Duration'
/>
</div>
<div>
@@ -117,9 +171,9 @@ function EventEditorTimes({
<TimeInput
id='timeDanger'
name='timeDanger'
submitHandler={handleTimeSubmit}
submitHandler={handleSubmit}
time={timeDanger}
placeholder={timeDanger === undefined ? mixedPlaceholder : 'Duration'}
placeholder='Duration'
/>
</div>
</div>
@@ -1,36 +1,34 @@
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 { enDash } from '../../../../common/utils/styleUtils';
import { mixedPlaceholder, switchLabel } from '../entryEditor.utils';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import EventTextArea from './EventTextArea';
import EntryEditorTextInput from './EventTextInput';
import style from '../EntryEditor.module.scss';
interface EventEditorTitlesProps {
/** id of the event being edited, null when editing several events */
eventId: string | null;
/** amount of events being edited */
eventCount: number;
eventId: string;
cue: string;
flag: boolean | undefined;
title: string | undefined;
note: string | undefined;
colour: string | undefined;
submit: (patch: Partial<OntimeEvent>) => void;
flag: boolean;
title: string;
note: string;
colour: string;
}
export default memo(EventEditorTitles);
function EventEditorTitles({ eventId, eventCount, cue, flag, title, note, colour, submit }: EventEditorTitlesProps) {
const isMulti = eventId === null;
function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEditorTitlesProps) {
const { updateEntry } = useEntryActionsContext();
const flagSubmitHandler = (newValue: boolean) => {
updateEntry({ id: eventId, flag: newValue });
};
const textSubmitHandler = (field: string, newValue: string) => {
submit({ [field]: newValue });
updateEntry({ id: eventId, [field]: newValue });
};
return (
@@ -38,39 +36,21 @@ function EventEditorTitles({ eventId, eventCount, cue, flag, title, note, colour
<Editor.Title>Event Data</Editor.Title>
<div className={style.splitThree}>
<div>
<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
/>
<Editor.Label htmlFor='eventId'>Event ID (read only)</Editor.Label>
<Input id='eventId' data-testid='input-textfield' value={eventId} readOnly fluid />
</div>
{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}
/>
)}
<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 ?? false}
mixed={flag === undefined}
onCheckedChange={(newValue) => submit({ flag: newValue })}
/>
{switchLabel(flag)}
<Switch id='flag' checked={flag} onCheckedChange={flagSubmitHandler} />
{flag ? 'On' : 'Off'}
</Editor.Label>
</div>
</div>
@@ -78,20 +58,8 @@ function EventEditorTitles({ eventId, eventCount, cue, flag, title, note, colour
<Editor.Label>Colour</Editor.Label>
<SwatchSelect name='colour' value={colour} handleChange={textSubmitHandler} />
</div>
<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}
/>
<EntryEditorTextInput field='title' label='Title' initialValue={title} submitHandler={textSubmitHandler} />
<EventTextArea field='note' label='Note' initialValue={note} submitHandler={textSubmitHandler} />
</div>
);
}
@@ -5,15 +5,11 @@ 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;
/** undefined represents values which do not agree across the edited entries */
initialValue: string | undefined;
placeholder?: string;
initialValue: string;
style?: CSSProperties;
submitHandler: (field: EventEditorUpdateFields, value: string) => void;
}
@@ -23,7 +19,6 @@ export default function EventTextArea({
field,
label,
initialValue,
placeholder,
style: givenStyles,
submitHandler,
}: CountedTextAreaProps) {
@@ -34,27 +29,16 @@ 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>
<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>
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
{label}
</Editor.Label>
<AutoTextarea
id={field}
inputref={ref}
rows={1}
data-testid='input-textarea'
placeholder={placeholder}
fluid
value={value}
onChange={onChange}
@@ -6,13 +6,10 @@ 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;
/** undefined represents values which do not agree across the edited entries */
initialValue: string | undefined;
initialValue: string;
placeholder?: string;
submitHandler: (field: EventEditorUpdateFields, value: string) => void;
}
@@ -34,21 +31,11 @@ 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>
<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>
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
{label}
</Editor.Label>
<Input
id={field}
ref={ref}
@@ -1,10 +0,0 @@
/** 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';
}
@@ -1,108 +0,0 @@
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;
}
@@ -1,6 +1,5 @@
import { memo, useEffect, useMemo } from 'react';
import EmptyPage from '../../../common/components/state/EmptyPage';
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
@@ -13,7 +12,7 @@ import { makeRundownColumns } from './makeRundownColumns';
export default memo(RundownTable);
function RundownTable() {
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: customFields } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource();
@@ -32,16 +31,10 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
const isLoading = !customFields || customFieldStatus === 'pending';
return (
<EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'>
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
)}
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
</CuesheetDnd>
</EntryActionsProvider>
);
@@ -35,7 +35,7 @@ export default function BackstageLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Backstage {...data} />;
@@ -20,10 +20,14 @@ export function useBackstageData(): ViewData<BackstageData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundown();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
const { data: rundownData, status: rundownStatus, isLoadingError: rundownIsLoadingError } = useFlatRundown();
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
return {
data: {
@@ -33,6 +37,11 @@ export function useBackstageData(): ViewData<BackstageData> {
isMirrored,
settings,
},
status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]),
status: aggregateQueryStatus([
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
};
}
@@ -7,7 +7,7 @@ $dot-spacing: 1.5rem;
display: grid;
place-items: center;
background-color: var(--background-color-override, $viewer-background-color);
height: 100vh;
height: 100dvh;
}
.ellipsis {
@@ -21,26 +21,26 @@ $dot-spacing: 1.5rem;
height: $dot-size;
border-radius: 50%;
background-color: var(--accent-color-override, $ontime-color);
animation-timing-function: cubic-bezier(0, 1, 1, 0);
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
&:nth-child(1) {
left: $dot-size;
animation: lds-ellipsis1 0.6s infinite;
animation: lds-ellipsis1 1s infinite;
}
&:nth-child(2) {
left: $dot-size;
animation: lds-ellipsis2 0.6s infinite;
animation: lds-ellipsis2 1s infinite;
}
&:nth-child(3) {
left: calc($dot-size + $dot-spacing);
animation: lds-ellipsis2 0.6s infinite;
animation: lds-ellipsis2 1s infinite;
}
&:nth-child(4) {
left: calc($dot-size + 2 * $dot-spacing);
animation: lds-ellipsis3 0.6s infinite;
animation: lds-ellipsis3 1s infinite;
}
}
}
+1 -15
View File
@@ -80,21 +80,7 @@ $item-height: 3.5rem;
display: flex;
flex-direction: column;
align-items: center;
button {
margin-top: 1.75rem;
}
}
.empty-state__content {
max-width: none;
span {
max-width: none;
white-space: nowrap;
font-size: clamp(1.5rem, 4vw, 2.25rem);
line-height: 1.1;
}
gap: 1.5rem;
}
.list-container {
@@ -43,7 +43,7 @@ export default function CountdownLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Countdown {...data} />;
@@ -87,7 +87,7 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
{!hasEvents && (
<div className='empty-state'>
<Empty text={getLocalizedString('common.no_data')} className='empty-state__content' />
<Empty text={getLocalizedString('common.no_data')} />
</div>
)}
@@ -121,7 +121,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscriptions.length === 0) {
return (
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Empty text={getLocalizedString('countdown.select_event')} />
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add
</Button>
@@ -137,7 +137,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscribedEvents.length === 0) {
return (
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Empty text={getLocalizedString('countdown.select_event')} />
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add
</Button>
@@ -154,7 +154,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (eventsToShow.length === 0) {
return (
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-state__content' />
<Empty text={getLocalizedString('countdown.all_have_finished')} />
</div>
);
}
@@ -21,10 +21,18 @@ export function useCountdownData(): ViewData<CountdownData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
const {
data: rundownData,
status: rundownStatus,
isLoadingError: rundownIsLoadingError,
} = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
return {
data: {
@@ -34,6 +42,11 @@ export function useCountdownData(): ViewData<CountdownData> {
isMirrored,
settings,
},
status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]),
status: aggregateQueryStatus([
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
};
}
@@ -2,7 +2,6 @@ import { MaybeString, ProjectRundown } from 'ontime-types';
import { memo, use, useMemo } from 'react';
import Select from '../../common/components/select/Select';
import EmptyPage from '../../common/components/state/EmptyPage';
import { PresetContext } from '../../common/context/PresetContext';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
@@ -34,40 +33,34 @@ function CuesheetTableWrapper({
const preset = use(PresetContext);
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: customFields } = useCustomFields();
const columns = useMemo(
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
[customFields, cuesheetMode, preset],
);
const isLoading = !customFields || customFieldStatus === 'pending';
return (
<CuesheetDnd columns={columns}>
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable
columns={columns}
source={source}
cuesheetMode={cuesheetMode}
tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode}
isCurrentRundown={isCurrentRundown}
insertElement={
<>
<RundownSelect
cuesheetMode={cuesheetMode}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</>
}
/>
)}
<CuesheetTable
columns={columns}
source={source}
cuesheetMode={cuesheetMode}
tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode}
isCurrentRundown={isCurrentRundown}
insertElement={
<>
<RundownSelect
cuesheetMode={cuesheetMode}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</>
}
/>
</CuesheetDnd>
);
}
@@ -5,6 +5,10 @@ $table-header-font-size: calc(1rem - 2px);
@include rows.cuesheet-row-columns($table-header-font-size);
.tableLoading {
grid-area: table;
}
.cuesheet {
font-size: $table-font-size;
font-weight: 400;
@@ -11,7 +11,7 @@ import {
TableVirtuosoHandle,
} from 'react-virtuoso';
import EmptyPage from '../../../common/components/state/EmptyPage';
import EmptyFill from '../../../common/components/state/EmptyFill';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
@@ -19,6 +19,7 @@ import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
import { AppMode } from '../../../ontimeConfig';
import { useTranslation } from '../../../translation/TranslationProvider';
import { usePersistedCuesheetOptions } from '../cuesheet.options';
import { useCuesheetPermissions } from '../useTablePermissions';
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
@@ -62,8 +63,9 @@ export default function CuesheetTable({
isCurrentRundown,
insertElement,
}: CuesheetTableProps) {
const { flatRundown, status, selectedEventId } = source;
const { flatRundown, status, isLoadingError, selectedEventId } = source;
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const { getLocalizedString } = useTranslation();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
@@ -228,10 +230,13 @@ export default function CuesheetTable({
});
}, [cuesheetMode, hideIndexColumn, table]);
const isLoading = !flatRundown || status === 'pending';
// avoid showing the editable empty state before we know whether the rundown is actually empty
if (status === 'pending') {
return <EmptyFill text='Loading…' className={style.tableLoading} />;
}
if (isLoading) {
return <EmptyPage text='Loading...' />;
if (isLoadingError) {
return <EmptyFill text={getLocalizedString('common.no_data')} className={style.tableLoading} />;
}
return (
@@ -1,5 +1,7 @@
@use '@/theme/viewerDefs' as *;
$content-width: min(100%, 1100px);
.project {
margin: 0;
box-sizing: border-box; /* reset */
@@ -16,56 +18,104 @@
display: flex;
flex-direction: column;
/* =================== HEADER ===================*/
.project-header {
width: $content-width;
margin-inline: auto;
display: flex;
align-items: center;
gap: clamp(12px, 2vw, 24px);
padding-bottom: $view-element-gap;
border-bottom: 1px solid $white-10;
}
.logo {
max-width: min(200px, 30vw);
}
.title {
font-size: $header-font-size;
font-weight: 600;
line-height: 1.1em;
}
.description {
font-size: $base-font-size;
color: var(--secondary-color-override, $viewer-secondary-color);
}
/* =================== CONTENT ===================*/
.info {
flex: 1;
max-height: 100%;
width: $content-width;
margin-inline: auto;
overflow-y: auto;
width: min(calc(100vw - 4rem), 960px);
display: flex;
flex-direction: column;
align-items: start;
gap: $view-element-gap;
padding-block: $view-element-gap;
padding-bottom: 10vh;
}
.info__card {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
border-radius: $element-border-radius;
padding: $view-block-padding $view-inline-padding;
display: flex;
flex-direction: column;
gap: 0.35em;
}
.info__media {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: $view-element-gap;
}
.info__media .info__value {
flex: 1;
min-width: 0;
}
.info__label {
font-size: $timer-label-size;
font-weight: 600;
letter-spacing: 0.05em;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.info__value {
white-space: break-spaces;
}
.info__custom {
display: flex;
gap: 1rem;
line-height: 1.35;
overflow-wrap: anywhere;
}
.info__image-container {
display: flex;
justify-content: center;
align-items: center;
width: 192px;
height: 192px;
flex: 0 0 min(192px, 25%);
}
.info__image {
display: block;
max-width: 100%;
max-height: 100%;
height: auto;
object-fit: contain;
}
.link.info__value {
display: flex;
gap: $view-element-gap;
display: inline-flex;
gap: 0.35em;
align-items: center;
color: $action-text-color;
@@ -78,12 +128,13 @@
/* =================== MOBILE ===================*/
@media screen and (max-width: 768px) {
.project {
.project-header {
flex-direction: column;
align-items: start;
gap: 0.5rem;
}
.logo img {
height: min(50px, 10vh);
}
.info__image-container {
width: 96px;
height: 96px;
}
}
}
@@ -1,4 +1,5 @@
import { OntimeView } from 'ontime-types';
import { type ReactNode, useState } from 'react';
import { IoOpenOutline } from 'react-icons/io5';
import EmptyPage from '../../common/components/state/EmptyPage';
@@ -21,7 +22,7 @@ export default function ProjectInfoLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <ProjectInfo {...data} />;
@@ -41,56 +42,49 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
return (
<>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
<EmptyPage text={getLocalizedString('common.no_data')} />;
<EmptyPage text={getLocalizedString('common.no_data')} />
</>
);
}
const hasHeader = Boolean(projectData.logo || projectData.title || projectData.description);
return (
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
{hasHeader && (
<div className='project-header'>
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='project-header__text'>
{projectData.title && <div className='title'>{projectData.title}</div>}
{projectData.description && <div className='description'>{projectData.description}</div>}
</div>
</div>
)}
<div className='info'>
{projectData.title && (
<div>
<div className='info__label'>{getLocalizedString('project.title')}</div>
<div className='info__value'>{projectData.title}</div>
</div>
)}
{projectData.description && (
<div>
<div className='info__label'>{getLocalizedString('project.description')}</div>
<div className='info__value'>{projectData.description}</div>
</div>
)}
{projectData.info && (
<div>
<div className='info__label'>{getLocalizedString('project.info')}</div>
<div className='info__value'>{projectData.info}</div>
</div>
)}
{projectData.info && <InfoCard label={getLocalizedString('project.info')}>{projectData.info}</InfoCard>}
{projectData.url && (
<div>
<div className='info__card'>
<div className='info__label'>{getLocalizedString('project.url')}</div>
<a href={projectData.url} target='_blank' rel='noreferrer' className='info__value link'>
{projectData.url} <IoOpenOutline style={{ fontSize: '1em' }} />
{projectData.url}
<IoOpenOutline style={{ fontSize: '1em' }} />
</a>
</div>
)}
{projectData.custom.map((info, idx) => {
const hasUrl = Boolean(info.url);
return (
// oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here
<div key={`${info.title}-${idx}`} className='info__custom'>
{hasUrl && (
<div className='info__image-container'>
<img className='info__image' src={info.url} loading='lazy' />
<div key={`${info.title}-${idx}`} className='info__card'>
{info.title && <div className='info__label'>{info.title}</div>}
{info.url ? (
<div className='info__media'>
<InfoImage src={info.url} />
{info.value && <div className='info__value'>{info.value}</div>}
</div>
) : (
info.value && <div className='info__value'>{info.value}</div>
)}
<div>
<div className='info__label'>{info.title}</div>
<div className='info__value'>{info.value}</div>
</div>
</div>
);
})}
@@ -98,3 +92,31 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
</div>
);
}
interface InfoCardProps {
label: string;
children: ReactNode;
}
function InfoCard({ label, children }: InfoCardProps) {
return (
<div className='info__card'>
<div className='info__label'>{label}</div>
<div className='info__value'>{children}</div>
</div>
);
}
function InfoImage({ src }: { src: string }) {
const [hasError, setHasError] = useState(false);
if (hasError) {
return null;
}
return (
<div className='info__image-container'>
<img className='info__image' src={src} loading='lazy' alt='' onError={() => setHasError(true)} />
</div>
);
}
@@ -2,7 +2,7 @@ import { ProjectData } from 'ontime-types';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ViewData } from '../utils/viewLoader.utils';
import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
export interface ProjectInfoData {
projectData: ProjectData;
@@ -14,13 +14,13 @@ export function useProjectInfoData(): ViewData<ProjectInfoData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
return {
data: {
projectData,
isMirrored,
},
status: projectDataStatus,
status: aggregateQueryStatus([{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError }]),
};
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function StudioLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Studio {...data} />;
+18 -5
View File
@@ -20,10 +20,18 @@ export function useStudioData(): ViewData<StudioData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const {
data: viewSettings,
status: viewSettingsStatus,
isLoadingError: viewSettingsIsLoadingError,
} = useViewSettings();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
return {
data: {
@@ -33,6 +41,11 @@ export function useStudioData(): ViewData<StudioData> {
settings,
viewSettings,
},
status: aggregateQueryStatus([projectDataStatus, viewSettingsStatus, settingsStatus, customFieldsStatus]),
status: aggregateQueryStatus([
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: viewSettingsStatus, isLoadingError: viewSettingsIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
};
}
@@ -1,6 +1,7 @@
import { OntimeView } from 'ontime-types';
import { useMemo } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
@@ -29,7 +30,7 @@ export default function TimelinePageLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <TimelinePage {...data} />;
@@ -73,7 +74,7 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
totalDuration={totalDuration}
/>
) : (
<EmptyPage text={getLocalizedString('common.no_data')} />
<EmptyFill text={getLocalizedString('common.no_data')} />
)}
</div>
);
@@ -16,10 +16,18 @@ export interface TimelineData {
export function useTimelineData(): ViewData<TimelineData> {
// HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
const {
data: rundownData,
status: rundownStatus,
isLoadingError: rundownIsLoadingError,
} = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
return {
data: {
@@ -28,6 +36,11 @@ export function useTimelineData(): ViewData<TimelineData> {
projectData,
settings,
},
status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]),
status: aggregateQueryStatus([
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
};
}
+1 -1
View File
@@ -43,7 +43,7 @@ export default function TimerLoader() {
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Timer {...data} />;
+18 -10
View File
@@ -22,11 +22,19 @@ export function useTimerData(): ViewData<TimerData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
const { data: rundown, status: rundownStatus } = useRundown();
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const {
data: viewSettings,
status: viewSettingsStatus,
isLoadingError: viewSettingsIsLoadingError,
} = useViewSettings();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
const { data: rundown, status: rundownStatus, isLoadingError: rundownIsLoadingError } = useRundown();
const { entries } = rundown;
return {
@@ -39,11 +47,11 @@ export function useTimerData(): ViewData<TimerData> {
entries,
},
status: aggregateQueryStatus([
projectDataStatus,
viewSettingsStatus,
settingsStatus,
customFieldsStatus,
rundownStatus,
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: viewSettingsStatus, isLoadingError: viewSettingsIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
]),
};
}
+17 -11
View File
@@ -5,21 +5,27 @@ export type ViewData<T> = {
status: QueryStatus;
};
type AggregatableQuery = {
status: QueryStatus;
/** true only when the query has never received data, ie useQuery's isLoadingError */
isLoadingError: boolean;
};
/**
* Aggregates a loading status from multiple query statuses.
* If all statuses are 'pending', returns 'pending'.
* If all statuses are 'success', returns 'success'.
* If any status is 'error', returns 'error'.
* Aggregates a loading status from multiple queries, for the purpose of deciding
* whether a view can render.
* - 'pending' while any query hasn't settled yet (no result, first fetch in flight)
* - 'error' once all queries have settled, if any of them never received data
* - 'success' once all queries have settled and every one has data to show,
* even if a query's last (background) fetch failed
*/
export function aggregateQueryStatus(statuses: QueryStatus[]): QueryStatus {
if (statuses.every((status) => status === 'pending')) {
export function aggregateQueryStatus(queries: AggregatableQuery[]): QueryStatus {
const allSettled = queries.every((query) => query.status !== 'pending');
if (!allSettled) {
return 'pending';
}
if (statuses.every((status) => status === 'success')) {
return 'success';
}
if (statuses.some((status) => status === 'error')) {
if (queries.some((query) => query.isLoadingError)) {
return 'error';
}
return 'pending';
return 'success';
}
-87
View File
@@ -1,87 +0,0 @@
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.getByText('Spreadsheet data applied.')).toBeVisible();
await page.getByRole('button', { name: 'Reset flow' }).click();
await expect(page.getByRole('button', { name: 'Open editor' })).toBeVisible();
await page.getByRole('button', { name: 'Import another' }).click();
// verify the data in the rundown
await page.getByRole('button', { name: 'Close settings' }).scrollIntoViewIfNeeded();