Compare commits

...

3 Commits

Author SHA1 Message Date
Claude 69aeb4aabb feat(rundown): clear a mixed field explicitly, cover both fixes with tests
Emptying a field which holds different values across the selection does not
submit, otherwise focusing it and tabbing away would wipe every entry. That
left no way to clear such a field, so text fields now offer an explicit
Clear action, shown only while the entries disagree. Once they agree the
field can be emptied by hand and the action is withdrawn.

Both of the fixes which multi edit depends on were unguarded, so the logic
moves into pure helpers alongside the existing hook helper convention:

- shouldSubmitValue covers an unknown initial value being submitted as an
  empty string
- applyPatchToEvents covers the optimistic batch update, whose signature now
  makes the original mistake of spreading the request body unrepresentable,
  and pins the custom field merge

Both tests were confirmed to fail against the original behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDETGBrhwgAqgTHjmrmJGJ
2026-07-25 12:49:47 +00:00
Claude 34ca715fb7 feat(rundown): batch edit event duration, model conflicts with a symbol
Two follow ups on multi event editing.

Duration is now editable across a selection. The batch endpoint already
applies every mutation to a cloned rundown and calls processRundown once on
commit, so a batched duration cascades correctly through linked events with
a single recalculation, and the server infers the duration lock for entries
which were locked to their end.

Start and end times remain excluded: they are absolute points in time, so
giving several events the same value collapses every linked event to a zero
duration. Only the duration is offered in a multi selection.

The optimistic update is skipped when the patch carries a duration, since
the resulting schedule cannot be resolved on the client.

Conflicting values are now marked with a dedicated symbol rather than
undefined, which was doing double duty for "the entries disagree" and "the
field is absent". The distinction was already leaking into the custom field
merge, which needed a key lookup to tell the two apart. The symbol also
makes it a type error to assign a merged value into an entry patch, where it
would previously have been dropped silently on serialisation.

Components keep taking plain optional values: the conflict is resolved once
at the editor boundary, so no indeterminate handling spreads into the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDETGBrhwgAqgTHjmrmJGJ
2026-07-25 12:39:17 +00:00
Claude b981bae63f feat(rundown): edit multiple events at once in the entry editor
Selecting several events in the editor now turns the entry editor into a
multi editor: fields shared by every selected event show their value,
fields which differ show as mixed, and editing a field applies it to the
whole selection through the existing batch endpoint.

Rather than branching between a single and a multi editor, the editor now
always renders a merged view of N events (N >= 1) where a field is
undefined when the events disagree. For a single event nothing is ever
undefined, so single event editing is unchanged by construction and the
composites carry no multi edit branching.

- add mergeEvents() to build the merged view over a selection
- EventEditor takes an events array and resolves the write path itself,
  replacing the eventId threaded into the composites with a submit callback
- extract EventEditorSchedule, which is only rendered for a single event
  since schedule values cascade through the rundown
- support indeterminate values in Switch, Select, SwatchSelect and TimeInput
- cue, event id, schedule and automations are not available in multi edit

Two related fixes which multi edit depends on:
- useReactiveTextInput submitted an empty string when the initial value was
  undefined, so leaving a mixed field without editing it would have wiped
  the value on every selected event
- the batch optimistic update spread the request body onto the entry
  instead of the patch, and replaced custom fields rather than merging them

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDETGBrhwgAqgTHjmrmJGJ
2026-07-25 12:29:30 +00:00
27 changed files with 927 additions and 191 deletions
@@ -6,7 +6,8 @@ import SwatchPicker from './SwatchPicker';
import style from './SwatchSelect.module.scss';
interface ColourInputProps {
value: string;
/** undefined represents values which do not agree, no swatch is highlighted */
value: string | undefined;
name: 'colour';
handleChange: (newValue: 'colour', name: string) => void;
}
@@ -44,7 +45,11 @@ export default function SwatchSelect(props: ColourInputProps) {
{colours.map((colour) => (
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
))}
<SwatchPicker color={value} onChange={setColour} isSelected={!colours.includes(value)} />
<SwatchPicker
color={value ?? ''}
onChange={setColour}
isSelected={value !== undefined && !colours.includes(value)}
/>
</div>
);
}
@@ -0,0 +1,31 @@
import { shouldSubmitValue } from '../useReactiveTextInput';
describe('shouldSubmitValue()', () => {
it('submits a value which differs from the initial value', () => {
expect(shouldSubmitValue('new', 'old')).toBe(true);
});
it('does not submit a value which has not changed', () => {
expect(shouldSubmitValue('same', 'same')).toBe(false);
});
it('submits an empty value when the initial value is known', () => {
// clearing a field which has a value is a valid edit
expect(shouldSubmitValue('', 'old')).toBe(true);
});
it('does not submit an empty value when the initial value is unknown', () => {
// an unknown value renders as an empty field, submitting it on blur
// would overwrite every entry being edited with an empty value
expect(shouldSubmitValue('', undefined)).toBe(false);
});
it('submits a typed value when the initial value is unknown', () => {
expect(shouldSubmitValue('typed', undefined)).toBe(true);
});
it('submits an unchanged value when the caller opts in', () => {
expect(shouldSubmitValue('same', 'same', true)).toBe(true);
expect(shouldSubmitValue('', undefined, true)).toBe(true);
});
});
@@ -1,6 +1,23 @@
import { HotkeyItem, getHotkeyHandler } from '@mantine/hooks';
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
/**
* Whether a value should be sent to the server
* An undefined initial value means that the value is unknown, for example when several
* entries are being edited and they do not agree on a value
* It is shown as an empty field, so submitting it would overwrite data the user has not edited
*/
export function shouldSubmitValue(
valueToSubmit: string,
initialText: string | undefined,
allowSubmitSameValue?: boolean,
): boolean {
if (allowSubmitSameValue) {
return true;
}
return valueToSubmit !== (initialText ?? '');
}
interface UseReactiveTextInputReturn {
value: string;
onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
@@ -9,7 +26,8 @@ interface UseReactiveTextInputReturn {
}
export default function useReactiveTextInput(
initialText: string,
/** undefined represents an unknown value, the field is shown empty and blurring it submits nothing */
initialText: string | undefined,
submitCallback: (newValue: string) => void,
ref: RefObject<HTMLInputElement | HTMLTextAreaElement | null>,
options?: {
@@ -22,7 +40,7 @@ export default function useReactiveTextInput(
allowKeyboardNavigation?: boolean;
},
): UseReactiveTextInputReturn {
const [text, setText] = useState<string>(initialText);
const [text, setText] = useState<string>(initialText ?? '');
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
const isKeyboardSubmitting = useRef(false);
// track escape to prevent the subsequent blur from submitting
@@ -57,8 +75,7 @@ export default function useReactiveTextInput(
*/
const handleSubmit = useCallback(
(valueToSubmit: string) => {
// No need to update if it hasn't changed
if (valueToSubmit === initialText && !options?.allowSubmitSameValue) {
if (!shouldSubmitValue(valueToSubmit, initialText, options?.allowSubmitSameValue)) {
options?.onCancelUpdate?.();
} else {
const cleanVal = valueToSubmit.trim();
@@ -86,10 +103,10 @@ export default function useReactiveTextInput(
const handleEscape = useCallback(() => {
isEscaping.current = true;
// No need to update if it hasn't changed
setText(initialText);
setText(initialText ?? '');
// force the text to be the initial value
if (ref.current) {
ref.current.value = initialText;
ref.current.value = initialText ?? '';
}
options?.onCancelUpdate?.();
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring
@@ -41,7 +41,10 @@ export default function TimeInput<T extends string>({
* @description Resets input value to given
*/
const resetValue = useCallback(() => {
if (typeof time !== 'number' || isNaN(time)) {
if (time === undefined) {
// there is no value to show, we leave the field empty so that the placeholder is visible
setValue('');
} else if (typeof time !== 'number' || isNaN(time)) {
setValue('00:00:00');
} else if (shouldFormat) {
setValue(formatTime(time));
@@ -16,13 +16,21 @@ interface SelectProps<T> extends Omit<BaseSelect.Root.Props<T>, 'items'> {
options: SelectOption<T>[];
fluid?: boolean;
size?: 'medium' | 'large';
/** shown when the value is null, used to represent values which do not agree */
placeholder?: string;
}
export default function Select<T>({ options, fluid, size = 'medium', ...selectRootProps }: SelectProps<T>) {
export default function Select<T>({
options,
fluid,
size = 'medium',
placeholder,
...selectRootProps
}: SelectProps<T>) {
return (
<BaseSelect.Root items={options} {...selectRootProps}>
<BaseSelect.Trigger className={cx([styles.select, styles[size], fluid && styles.fluid])}>
<BaseSelect.Value />
<BaseSelect.Value placeholder={placeholder} />
<BaseSelect.Icon className={styles.selectIcon}>
<LuChevronsUpDown />
</BaseSelect.Icon>
@@ -29,6 +29,11 @@
}
}
/* values do not agree, we show an empty track instead of a thumb position */
.mixed .thumb {
visibility: hidden;
}
.medium {
padding: 2px;
--width: calc(2.5rem + 4px);
@@ -6,11 +6,13 @@ import style from './Switch.module.scss';
interface SwitchProps extends BaseSwitch.Root.Props {
size?: 'medium' | 'large';
/** the switch represents several values which do not agree, we hide the thumb */
mixed?: boolean;
}
export default function Switch({ size = 'medium', ...switchProps }: SwitchProps) {
export default function Switch({ size = 'medium', mixed, ...switchProps }: SwitchProps) {
return (
<BaseSwitch.Root className={cx([style.switch, style[size]])} {...switchProps}>
<BaseSwitch.Root className={cx([style.switch, style[size], mixed && style.mixed])} {...switchProps}>
<BaseSwitch.Thumb className={style.thumb} />
</BaseSwitch.Root>
);
@@ -0,0 +1,76 @@
import { OntimeEvent, OntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
import { applyPatchToEvents, canPredictBatchResult } from '../entryAction.utils';
function makeEntries(): RundownEntries {
return {
'1': { type: SupportedEntry.Event, id: '1', title: 'one', custom: { lx: 'a', sound: 'x' } } as OntimeEvent,
'2': { type: SupportedEntry.Event, id: '2', title: 'two', custom: { lx: 'b' } } as OntimeEvent,
'3': { type: SupportedEntry.Event, id: '3', title: 'three', custom: {} } as OntimeEvent,
group: { type: SupportedEntry.Group, id: 'group', title: 'a group', custom: {} } as OntimeGroup,
};
}
describe('applyPatchToEvents()', () => {
it('applies the patch to the given events only', () => {
const patched = applyPatchToEvents(makeEntries(), ['1', '2'], { title: 'patched' });
expect((patched['1'] as OntimeEvent).title).toBe('patched');
expect((patched['2'] as OntimeEvent).title).toBe('patched');
expect((patched['3'] as OntimeEvent).title).toBe('three');
});
it('patches custom fields instead of replacing them', () => {
const patched = applyPatchToEvents(makeEntries(), ['1', '2'], { custom: { lx: 'new' } });
// the field which was not part of the patch must survive
expect((patched['1'] as OntimeEvent).custom).toStrictEqual({ lx: 'new', sound: 'x' });
expect((patched['2'] as OntimeEvent).custom).toStrictEqual({ lx: 'new' });
});
it('keeps the existing custom fields when the patch has none', () => {
const patched = applyPatchToEvents(makeEntries(), ['1'], { title: 'patched' });
expect((patched['1'] as OntimeEvent).custom).toStrictEqual({ lx: 'a', sound: 'x' });
});
it('ignores ids which are not in the rundown', () => {
const patched = applyPatchToEvents(makeEntries(), ['1', 'does-not-exist'], { title: 'patched' });
expect(Object.keys(patched)).toStrictEqual(['1', '2', '3', 'group']);
expect((patched['1'] as OntimeEvent).title).toBe('patched');
});
it('ignores entries which are not events', () => {
const patched = applyPatchToEvents(makeEntries(), ['group'], { title: 'patched' });
expect((patched.group as OntimeGroup).title).toBe('a group');
});
it('does not mutate the given entries', () => {
const entries = makeEntries();
applyPatchToEvents(entries, ['1'], { title: 'patched', custom: { lx: 'new' } });
expect((entries['1'] as OntimeEvent).title).toBe('one');
expect((entries['1'] as OntimeEvent).custom).toStrictEqual({ lx: 'a', sound: 'x' });
});
it('handles repeated ids', () => {
const patched = applyPatchToEvents(makeEntries(), ['1', '1'], { title: 'patched' });
expect((patched['1'] as OntimeEvent).title).toBe('patched');
});
});
describe('canPredictBatchResult()', () => {
it('allows resolving patches which do not affect the schedule', () => {
expect(canPredictBatchResult({ title: 'a title' })).toBe(true);
expect(canPredictBatchResult({ colour: 'red', flag: true })).toBe(true);
});
it('defers to the server when the duration changes', () => {
// a duration cascades into the start and end of every linked event
expect(canPredictBatchResult({ duration: 1000 })).toBe(false);
expect(canPredictBatchResult({ title: 'a title', duration: 1000 })).toBe(false);
});
});
@@ -0,0 +1,39 @@
import { EntryId, OntimeEvent, RundownEntries, isOntimeEvent } from 'ontime-types';
/**
* Applies a patch to a set of events, used to optimistically resolve a batch edit
* Entries which are missing or are not events are left untouched, mirroring the server
* @returns a new entries object, the given entries are not mutated
*/
export function applyPatchToEvents(
entries: RundownEntries,
ids: EntryId[],
patch: Partial<OntimeEvent>,
): RundownEntries {
const patchedEntries = { ...entries };
for (const id of new Set(ids)) {
const entry = patchedEntries[id];
if (!isOntimeEvent(entry)) {
continue;
}
patchedEntries[id] = {
...entry,
...patch,
// custom fields are patched, not replaced
custom: patch.custom ? { ...entry.custom, ...patch.custom } : entry.custom,
};
}
return patchedEntries;
}
/**
* Whether the result of a batch edit can be resolved without the server
* Changing the duration cascades through the rundown, so the resulting
* schedule is only known once the server has recalculated it
*/
export function canPredictBatchResult(patch: Partial<OntimeEvent>): boolean {
return !('duration' in patch);
}
+4 -15
View File
@@ -52,6 +52,7 @@ import {
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings';
import { applyPatchToEvents, canPredictBatchResult } from './entryAction.utils';
export type EventOptions = Partial<{
// options of any new entries (event / delay / group)
@@ -483,21 +484,9 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
// Snapshot the previous value
const previousRundown = queryClient.getQueryData<Rundown>(queryKey);
if (previousRundown) {
const eventIds = new Set(data.ids);
const newRundown = { ...previousRundown.entries };
eventIds.forEach((eventId) => {
if (Object.hasOwn(newRundown, eventId)) {
const event = newRundown[eventId];
if (isOntimeEvent(event)) {
newRundown[eventId] = {
...event,
...data,
};
}
}
});
// when the result cannot be resolved here we wait for the recalculated rundown from the server
if (previousRundown && canPredictBatchResult(data.data)) {
const newRundown = applyPatchToEvents(previousRundown.entries, data.ids, data.data);
queryClient.setQueryData<Rundown>(queryKey, {
id: previousRundown.id,
@@ -22,10 +22,13 @@ export default function CuesheetEntryEditor({ entryId, rundown }: CuesheetEntryE
return event ?? null;
}, [entryId, rundown.entries, rundown.order.length]);
if (isOntimeEvent(entry)) {
// the cuesheet always edits a single entry
const events = useMemo(() => (isOntimeEvent(entry) ? [entry] : null), [entry]);
if (events) {
return (
<div className={style.entryEditor} data-testid='editor-container'>
<EventEditor event={entry} />
<EventEditor events={events} />
</div>
);
}
@@ -75,6 +75,26 @@
gap: 1rem;
}
// a label with an action aligned to the end of the row
.labelRow {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
.clearAction {
font-size: $aux-text-size;
color: $blue-400;
margin-bottom: $element-inner-spacing; // match the label
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
color: $blue-500;
}
}
.splitTwo {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -1,14 +1,19 @@
import { OntimeEvent } from 'ontime-types';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import Info from '../../../common/components/info/Info';
import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import EventEditorBatchSchedule from './composite/EventEditorBatchSchedule';
import EntryEditorCustomFields from './composite/EventEditorCustomFields';
import EventEditorSchedule from './composite/EventEditorSchedule';
import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles';
import EventEditorTriggers from './composite/EventEditorTriggers';
import { mixedPlaceholder } from './entryEditor.utils';
import { mergeEvents, resolveConflict } from './mergeEvents';
import style from './EntryEditor.module.scss';
@@ -16,66 +21,115 @@ import style from './EntryEditor.module.scss';
export type EventEditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | string;
interface EventEditorProps {
event: OntimeEvent;
/** events being edited, editing several events at once shows a merged view of their values */
events: OntimeEvent[];
}
export default function EventEditor({ event }: EventEditorProps) {
export default function EventEditor({ events }: EventEditorProps) {
const { data: customFields } = useCustomFields();
const { updateEntry } = useEntryActionsContext();
const { updateEntry, batchUpdateEvents } = useEntryActionsContext();
const isEditor = window.location.pathname.includes('editor');
const ids = useMemo(() => events.map((event) => event.id), [events]);
const merged = useMemo(() => mergeEvents(events), [events]);
// when editing a single event, we can show the values which are unique to it
const singleEvent = events.length === 1 ? events[0] : null;
/**
* Applies a patch to every event being edited
*/
const submit = useCallback(
(patch: Partial<OntimeEvent>) => {
if (ids.length === 1) {
updateEntry({ id: ids[0], ...patch });
return;
}
batchUpdateEvents(patch, ids);
},
[batchUpdateEvents, ids, updateEntry],
);
const handleSubmit = useCallback(
(field: EventEditorUpdateFields, value: string) => {
if (field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1];
updateEntry({ id: event.id, custom: { [fieldLabel]: value } });
submit({ custom: { [fieldLabel]: value } });
} else {
updateEntry({ id: event.id, [field]: value });
submit({ [field]: value });
}
},
[event.id, updateEntry],
[submit],
);
// inputs keep local state, we remount them when the edited entries change
const editorKey = ids.join();
return (
<div className={style.content}>
{singleEvent ? (
<EventEditorSchedule
key={`${editorKey}-schedule`}
eventId={singleEvent.id}
timeStart={singleEvent.timeStart}
timeEnd={singleEvent.timeEnd}
duration={singleEvent.duration}
timeStrategy={singleEvent.timeStrategy}
linkStart={singleEvent.linkStart}
delay={singleEvent.delay}
/>
) : (
<EventEditorBatchSchedule
key={`${editorKey}-schedule`}
duration={resolveConflict(merged.duration)}
submit={submit}
/>
)}
<EventEditorTimes
key={`${event.id}-times`}
eventId={event.id}
timeStart={event.timeStart}
timeEnd={event.timeEnd}
duration={event.duration}
timeStrategy={event.timeStrategy}
linkStart={event.linkStart}
countToEnd={event.countToEnd}
delay={event.delay}
endAction={event.endAction}
timerType={event.timerType}
timeWarning={event.timeWarning}
timeDanger={event.timeDanger}
key={`${editorKey}-times`}
countToEnd={resolveConflict(merged.countToEnd)}
endAction={resolveConflict(merged.endAction)}
timerType={resolveConflict(merged.timerType)}
timeWarning={resolveConflict(merged.timeWarning)}
timeDanger={resolveConflict(merged.timeDanger)}
submit={submit}
/>
<EventEditorTitles
key={`${event.id}-titles`}
eventId={event.id}
cue={event.cue}
flag={event.flag}
title={event.title}
note={event.note}
colour={event.colour}
key={`${editorKey}-titles`}
eventId={singleEvent?.id ?? null}
eventCount={events.length}
cue={singleEvent?.cue ?? ''}
flag={resolveConflict(merged.flag)}
title={resolveConflict(merged.title)}
note={resolveConflict(merged.note)}
colour={resolveConflict(merged.colour)}
submit={submit}
/>
<div className={style.column}>
<Editor.Title>
Custom Fields
{isEditor && <AppLink search='settings=manage__custom'>Manage Custom Fields</AppLink>}
</Editor.Title>
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={event} />
<EntryEditorCustomFields
key={`${editorKey}-custom`}
fields={customFields}
custom={merged.custom}
idKey={editorKey}
mixedPlaceholder={mixedPlaceholder}
handleSubmit={handleSubmit}
/>
</div>
<div className={style.column}>
<Editor.Title>
Automations
{isEditor && <AppLink search='settings=automation'>Manage Automations</AppLink>}
{isEditor && singleEvent && <AppLink search='settings=automation'>Manage Automations</AppLink>}
</Editor.Title>
<EventEditorTriggers triggers={event.triggers} eventId={event.id} />
{singleEvent ? (
<EventEditorTriggers triggers={singleEvent.triggers} eventId={singleEvent.id} />
) : (
<Info>Automations are not available when editing multiple events</Info>
)}
</div>
</div>
);
@@ -112,7 +112,12 @@ export default function GroupEditor({ group }: GroupEditorProps) {
Custom Fields
{isEditor && <AppLink search='settings=manage__custom'>Manage Custom Fields</AppLink>}
</Editor.Title>
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={group} />
<EntryEditorCustomFields
fields={customFields}
handleSubmit={handleSubmit}
custom={group.custom}
idKey={group.id}
/>
</div>
</div>
);
@@ -69,7 +69,12 @@ export default function MilestoneEditor({ milestone }: MilestoneEditorProps) {
Custom Fields
{isEditor && <AppLink search='settings=manage__custom'>Manage Custom Fields</AppLink>}
</Editor.Title>
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={milestone} />
<EntryEditorCustomFields
fields={customFields}
handleSubmit={handleSubmit}
custom={milestone.custom}
idKey={milestone.id}
/>
</div>
</div>
);
@@ -1,4 +1,4 @@
import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { OntimeEntry, OntimeEvent, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { useMemo } from 'react';
import useRundown from '../../../common/hooks-query/useRundown';
@@ -15,6 +15,25 @@ 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;
@@ -29,19 +48,20 @@ export default function RundownEntryEditor() {
return event ?? null;
}, [data.order.length, data.entries, selectedEvents]);
if (!entry) {
return <EventEditorEmpty />;
}
if (isOntimeEvent(entry)) {
if (events.length > 0) {
const singleEvent = events.length === 1 ? events[0] : null;
return (
<div className={style.rundownEditor} data-testid='editor-container'>
<EventEditor event={entry} />
<EventEditorFooter id={entry.id} cue={entry.cue} />
<EventEditor events={events} />
{singleEvent && <EventEditorFooter id={singleEvent.id} cue={singleEvent.cue} />}
</div>
);
}
if (!entry) {
return <EventEditorEmpty />;
}
if (isOntimeMilestone(entry)) {
return (
<div className={style.rundownEditor} data-testid='editor-container'>
@@ -0,0 +1,122 @@
import { EndAction, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types';
import { conflict, isConflict, mergeEvents, resolveConflict } from '../mergeEvents';
function makeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
return {
type: SupportedEntry.Event,
id: 'event',
title: 'title',
note: 'note',
colour: '#FFCC78',
flag: false,
duration: 600000,
endAction: EndAction.None,
countToEnd: false,
timerType: TimerType.CountDown,
timeWarning: 120000,
timeDanger: 60000,
linkStart: true,
custom: {},
...patch,
} as OntimeEvent;
}
describe('mergeEvents()', () => {
it('returns the values of a single event', () => {
const event = makeEvent({ id: '1', title: 'only event' });
expect(mergeEvents([event])).toStrictEqual({
title: 'only event',
note: 'note',
colour: '#FFCC78',
flag: false,
duration: 600000,
endAction: EndAction.None,
countToEnd: false,
timerType: TimerType.CountDown,
timeWarning: 120000,
timeDanger: 60000,
linkStart: true,
custom: {},
});
});
it('keeps values which are shared by all events', () => {
const merged = mergeEvents([makeEvent({ id: '1' }), makeEvent({ id: '2' }), makeEvent({ id: '3' })]);
expect(merged.title).toBe('title');
expect(merged.colour).toBe('#FFCC78');
expect(merged.timerType).toBe(TimerType.CountDown);
expect(merged.duration).toBe(600000);
});
it('marks only the fields which differ as conflicting', () => {
const merged = mergeEvents([makeEvent({ id: '1', title: 'first' }), makeEvent({ id: '2', title: 'second' })]);
expect(merged.title).toBe(conflict);
expect(merged.note).toBe('note');
expect(merged.colour).toBe('#FFCC78');
expect(merged.flag).toBe(false);
});
it('handles boolean and numeric fields', () => {
const merged = mergeEvents([
makeEvent({ id: '1', flag: true, timeWarning: 1000, duration: 1000 }),
makeEvent({ id: '2', flag: false, timeWarning: 1000, duration: 2000 }),
]);
expect(merged.flag).toBe(conflict);
expect(merged.timeWarning).toBe(1000);
expect(merged.duration).toBe(conflict);
});
describe('custom fields', () => {
it('merges values under the same key', () => {
const merged = mergeEvents([
makeEvent({ id: '1', custom: { lx: 'same', sound: 'a' } }),
makeEvent({ id: '2', custom: { lx: 'same', sound: 'b' } }),
]);
expect(merged.custom).toStrictEqual({ lx: 'same', sound: conflict });
});
it('treats a missing key as empty', () => {
const merged = mergeEvents([makeEvent({ id: '1', custom: { lx: 'value' } }), makeEvent({ id: '2', custom: {} })]);
expect(merged.custom).toStrictEqual({ lx: conflict });
});
it('collects keys from all events', () => {
const merged = mergeEvents([
makeEvent({ id: '1', custom: { lx: '' } }),
makeEvent({ id: '2', custom: { sound: 'value' } }),
]);
// lx is empty in both events, sound is only filled in one of them
expect(merged.custom).toStrictEqual({ lx: '', sound: conflict });
});
it('does not report a key which is absent from every event', () => {
const merged = mergeEvents([makeEvent({ id: '1', custom: {} }), makeEvent({ id: '2', custom: {} })]);
expect(merged.custom).toStrictEqual({});
// an absent key is distinguishable from a conflicting one
expect(merged.custom.lx).toBeUndefined();
expect(isConflict(merged.custom.lx)).toBe(false);
});
});
});
describe('resolveConflict()', () => {
it('passes known values through', () => {
expect(resolveConflict('value')).toBe('value');
expect(resolveConflict(0)).toBe(0);
expect(resolveConflict(false)).toBe(false);
expect(resolveConflict('')).toBe('');
});
it('resolves a conflict to undefined', () => {
expect(resolveConflict(conflict)).toBeUndefined();
});
});
@@ -0,0 +1,48 @@
import { OntimeEvent } from 'ontime-types';
import { MILLIS_PER_SECOND, dayInMs, parseUserTime } from 'ontime-utils';
import { memo } from 'react';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { mixedPlaceholder } from '../entryEditor.utils';
import style from '../EntryEditor.module.scss';
interface EventEditorBatchScheduleProps {
/** undefined when the events do not share a duration */
duration: number | undefined;
submit: (patch: Partial<OntimeEvent>) => void;
}
/**
* Schedule fields which can be applied to several events at once
* Start and end times are absolute points in time: giving several events the same
* value would collapse their durations, so only the duration is offered here
* The server infers the duration lock and recalculates the rundown once for the whole batch
*/
export default memo(EventEditorBatchSchedule);
function EventEditorBatchSchedule({ duration, submit }: EventEditorBatchScheduleProps) {
const handleSubmit = (_field: 'duration', value: string) => {
// durations cannot exceed a day
submit({ duration: Math.min(parseUserTime(value), dayInMs - MILLIS_PER_SECOND) });
};
return (
<div className={style.column}>
<Editor.Title>Event schedule</Editor.Title>
<div className={style.splitTwo}>
<div>
<Editor.Label htmlFor='duration'>Duration</Editor.Label>
<TimeInput
id='duration'
name='duration'
submitHandler={handleSubmit}
time={duration}
placeholder={duration === undefined ? mixedPlaceholder : 'Duration'}
align='left'
/>
</div>
</div>
</div>
);
}
@@ -1,8 +1,9 @@
import { CustomFields, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
import { CustomFields, EntryCustomFields } from 'ontime-types';
import { CSSProperties, Fragment } from 'react';
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { EventEditorUpdateFields } from '../EventEditor';
import { MergedCustomFields, resolveConflict } from '../mergeEvents';
import EventEditorImage from './EventEditorImage';
import EventTextArea from './EventTextArea';
import EntryEditorTextInput from './EventTextInput';
@@ -11,21 +12,29 @@ import style from '../EntryEditor.module.scss';
interface EntryEditorCustomFieldsProps {
fields: CustomFields;
entry: OntimeEvent | OntimeGroup | OntimeMilestone;
/** values of the entries being edited, undefined when the entries do not agree */
custom: EntryCustomFields | MergedCustomFields;
/** used to generate stable keys for the fields */
idKey: string;
mixedPlaceholder?: string;
handleSubmit: (field: EventEditorUpdateFields, value: string) => void;
}
export default function EntryEditorCustomFields({
fields: customFields,
custom,
idKey,
mixedPlaceholder,
handleSubmit,
entry,
}: EntryEditorCustomFieldsProps) {
return (
<Fragment>
{Object.keys(customFields).map((fieldKey) => {
const key = `${entry.id}-${fieldKey}`;
const key = `${idKey}-${fieldKey}`;
const fieldName = `custom-${fieldKey}`;
const initialValue = entry.custom[fieldKey] ?? '';
// a key which is absent from the merged view is not present in any of the entries
const initialValue = resolveConflict(custom[fieldKey] ?? '');
const placeholder = initialValue === undefined ? mixedPlaceholder : undefined;
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
const labelText = customFields[fieldKey].label;
@@ -36,6 +45,7 @@ export default function EntryEditorCustomFields({
field={fieldName}
label={labelText}
initialValue={initialValue}
placeholder={placeholder}
submitHandler={handleSubmit}
className={style.decorated}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
@@ -51,13 +61,13 @@ export default function EntryEditorCustomFields({
field={fieldName}
label={labelText}
initialValue={initialValue}
placeholder='Paste image URL'
placeholder={placeholder ?? 'Paste image URL'}
submitHandler={handleSubmit}
className={style.decorated}
maxLength={255}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
<EventEditorImage src={initialValue} />
<EventEditorImage src={initialValue ?? ''} />
</div>
);
}
@@ -0,0 +1,62 @@
import { TimeStrategy } from 'ontime-types';
import { memo } from 'react';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { formatTime, normaliseWallClock } from '../../../../common/utils/time';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import style from '../EntryEditor.module.scss';
interface EventEditorScheduleProps {
eventId: string;
timeStart: number;
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: boolean;
delay: number;
}
/**
* Schedule of a single event
* Schedule values cascade through the rundown, so they are not editable across a selection
*/
export default memo(EventEditorSchedule);
function EventEditorSchedule({
eventId,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
delay,
}: EventEditorScheduleProps) {
const hasDelay = delay !== 0;
const delayedStart = normaliseWallClock(timeStart + delay);
const delayedEnd = normaliseWallClock(timeEnd + delay);
const delayLabel = hasDelay
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(delayedStart)}${formatTime(delayedEnd)}`
: '';
return (
<div className={style.column}>
<Editor.Title>Event schedule</Editor.Title>
<div>
<div className={style.inline}>
<TimeInputFlow
eventId={eventId}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
timeStrategy={timeStrategy}
linkStart={linkStart}
delay={delay}
showLabels
/>
</div>
<div className={style.delayLabel}>{delayLabel}</div>
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import { EndAction, TimeStrategy, TimerType } from 'ontime-types';
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
import { memo } from 'react';
import { IoInformationCircle } from 'react-icons/io5';
@@ -8,103 +8,47 @@ import TimeInput from '../../../../common/components/input/time-input/TimeInput'
import Select from '../../../../common/components/select/Select';
import Switch from '../../../../common/components/switch/Switch';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { formatTime, normaliseWallClock } from '../../../../common/utils/time';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import { mixedPlaceholder, switchLabel } from '../entryEditor.utils';
import style from '../EntryEditor.module.scss';
interface EventEditorTimesProps {
eventId: string;
timeStart: number;
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: boolean;
countToEnd: boolean;
delay: number;
endAction: EndAction;
timerType: TimerType;
timeWarning: number;
timeDanger: number;
countToEnd: boolean | undefined;
endAction: EndAction | undefined;
timerType: TimerType | undefined;
timeWarning: number | undefined;
timeDanger: number | undefined;
submit: (patch: Partial<OntimeEvent>) => void;
}
type HandledActions = 'countToEnd' | 'timerType' | 'endAction' | 'timeWarning' | 'timeDanger';
type TimeFields = 'timeWarning' | 'timeDanger';
export default memo(EventEditorTimes);
function EventEditorTimes({
eventId,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
countToEnd,
delay,
endAction,
timerType,
timeWarning,
timeDanger,
submit,
}: EventEditorTimesProps) {
const { updateEntry } = useEntryActionsContext();
const handleSubmit = (field: HandledActions, value: string | boolean) => {
if (field === 'countToEnd') {
updateEntry({ id: eventId, countToEnd: value as boolean });
return;
}
if (field === 'timeWarning' || field === 'timeDanger') {
const newTime = parseUserTime(value as string);
updateEntry({ id: eventId, [field]: newTime });
return;
}
if (field === 'timerType' || field === 'endAction') {
updateEntry({ id: eventId, [field]: value });
return;
}
const handleTimeSubmit = (field: TimeFields, value: string) => {
submit({ [field]: parseUserTime(value) });
};
const hasDelay = delay !== 0;
const delayedStart = normaliseWallClock(timeStart + delay);
const delayedEnd = normaliseWallClock(timeEnd + delay);
const delayLabel = hasDelay
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(delayedStart)}${formatTime(delayedEnd)}`
: '';
return (
<>
<div className={style.column}>
<Editor.Title>Event schedule</Editor.Title>
<div>
<div className={style.inline}>
<TimeInputFlow
eventId={eventId}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
timeStrategy={timeStrategy}
linkStart={linkStart}
delay={delay}
showLabels
/>
</div>
<div className={style.delayLabel}>{delayLabel}</div>
</div>
</div>
<div className={style.column}>
<Editor.Title>Event Behaviour</Editor.Title>
<div className={style.splitTwo}>
<div>
<Editor.Label htmlFor='endAction'>End Action</Editor.Label>
<Select
value={endAction}
value={endAction ?? null}
placeholder={mixedPlaceholder}
onValueChange={(value: EndAction | null) => {
if (value === null) return;
handleSubmit('endAction', value);
submit({ endAction: value });
}}
options={[
{ value: EndAction.None, label: 'None' },
@@ -118,10 +62,11 @@ function EventEditorTimes({
<Editor.Label className={style.switchLabel}>
<Switch
id='countToEnd'
checked={countToEnd}
onCheckedChange={(value) => handleSubmit('countToEnd', value)}
checked={countToEnd ?? false}
mixed={countToEnd === undefined}
onCheckedChange={(value) => submit({ countToEnd: value })}
/>
{countToEnd ? 'On' : 'Off'}
{switchLabel(countToEnd)}
</Editor.Label>
</div>
</div>
@@ -141,10 +86,11 @@ function EventEditorTimes({
<div>
<Editor.Label htmlFor='timerType'>Timer Type</Editor.Label>
<Select
value={timerType}
value={timerType ?? null}
placeholder={mixedPlaceholder}
onValueChange={(value: TimerType | null) => {
if (value === null) return;
handleSubmit('timerType', value);
submit({ timerType: value });
}}
options={[
{ value: TimerType.CountDown, label: 'Count down' },
@@ -161,9 +107,9 @@ function EventEditorTimes({
<TimeInput
id='timeWarning'
name='timeWarning'
submitHandler={handleSubmit}
submitHandler={handleTimeSubmit}
time={timeWarning}
placeholder='Duration'
placeholder={timeWarning === undefined ? mixedPlaceholder : 'Duration'}
/>
</div>
<div>
@@ -171,9 +117,9 @@ function EventEditorTimes({
<TimeInput
id='timeDanger'
name='timeDanger'
submitHandler={handleSubmit}
submitHandler={handleTimeSubmit}
time={timeDanger}
placeholder='Duration'
placeholder={timeDanger === undefined ? mixedPlaceholder : 'Duration'}
/>
</div>
</div>
@@ -1,34 +1,36 @@
import { OntimeEvent } from 'ontime-types';
import { memo } from 'react';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../../common/components/input/input/Input';
import Switch from '../../../../common/components/switch/Switch';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import { enDash } from '../../../../common/utils/styleUtils';
import { mixedPlaceholder, switchLabel } from '../entryEditor.utils';
import EventTextArea from './EventTextArea';
import EntryEditorTextInput from './EventTextInput';
import style from '../EntryEditor.module.scss';
interface EventEditorTitlesProps {
eventId: string;
/** id of the event being edited, null when editing several events */
eventId: string | null;
/** amount of events being edited */
eventCount: number;
cue: string;
flag: boolean;
title: string;
note: string;
colour: string;
flag: boolean | undefined;
title: string | undefined;
note: string | undefined;
colour: string | undefined;
submit: (patch: Partial<OntimeEvent>) => void;
}
export default memo(EventEditorTitles);
function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEditorTitlesProps) {
const { updateEntry } = useEntryActionsContext();
const flagSubmitHandler = (newValue: boolean) => {
updateEntry({ id: eventId, flag: newValue });
};
function EventEditorTitles({ eventId, eventCount, cue, flag, title, note, colour, submit }: EventEditorTitlesProps) {
const isMulti = eventId === null;
const textSubmitHandler = (field: string, newValue: string) => {
updateEntry({ id: eventId, [field]: newValue });
submit({ [field]: newValue });
};
return (
@@ -36,21 +38,39 @@ function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEdi
<Editor.Title>Event Data</Editor.Title>
<div className={style.splitThree}>
<div>
<Editor.Label htmlFor='eventId'>Event ID (read only)</Editor.Label>
<Input id='eventId' data-testid='input-textfield' value={eventId} readOnly fluid />
<Editor.Label htmlFor='eventId'>{isMulti ? 'Selection (read only)' : 'Event ID (read only)'}</Editor.Label>
<Input
id='eventId'
data-testid='input-textfield'
value={isMulti ? `${eventCount} events selected` : eventId}
readOnly
fluid
/>
</div>
<EntryEditorTextInput
field='cue'
label='Cue'
initialValue={cue}
submitHandler={textSubmitHandler}
maxLength={10}
/>
{isMulti ? (
<div>
<Editor.Label htmlFor='cue'>Cue (not available)</Editor.Label>
<Input id='cue' value={enDash} readOnly fluid />
</div>
) : (
<EntryEditorTextInput
field='cue'
label='Cue'
initialValue={cue}
submitHandler={textSubmitHandler}
maxLength={10}
/>
)}
<div>
<Editor.Label htmlFor='flag'>Flag</Editor.Label>
<Editor.Label className={style.switchLabel}>
<Switch id='flag' checked={flag} onCheckedChange={flagSubmitHandler} />
{flag ? 'On' : 'Off'}
<Switch
id='flag'
checked={flag ?? false}
mixed={flag === undefined}
onCheckedChange={(newValue) => submit({ flag: newValue })}
/>
{switchLabel(flag)}
</Editor.Label>
</div>
</div>
@@ -58,8 +78,20 @@ function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEdi
<Editor.Label>Colour</Editor.Label>
<SwatchSelect name='colour' value={colour} handleChange={textSubmitHandler} />
</div>
<EntryEditorTextInput field='title' label='Title' initialValue={title} submitHandler={textSubmitHandler} />
<EventTextArea field='note' label='Note' initialValue={note} submitHandler={textSubmitHandler} />
<EntryEditorTextInput
field='title'
label='Title'
initialValue={title}
placeholder={title === undefined ? mixedPlaceholder : undefined}
submitHandler={textSubmitHandler}
/>
<EventTextArea
field='note'
label='Note'
initialValue={note}
placeholder={note === undefined ? mixedPlaceholder : undefined}
submitHandler={textSubmitHandler}
/>
</div>
);
}
@@ -5,11 +5,15 @@ import { AutoTextarea } from '../../../../common/components/input/auto-textarea/
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
import { EventEditorUpdateFields } from '../EventEditor';
import style from '../EntryEditor.module.scss';
interface CountedTextAreaProps {
className?: string;
field: EventEditorUpdateFields;
label: string;
initialValue: string;
/** undefined represents values which do not agree across the edited entries */
initialValue: string | undefined;
placeholder?: string;
style?: CSSProperties;
submitHandler: (field: EventEditorUpdateFields, value: string) => void;
}
@@ -19,6 +23,7 @@ export default function EventTextArea({
field,
label,
initialValue,
placeholder,
style: givenStyles,
submitHandler,
}: CountedTextAreaProps) {
@@ -29,16 +34,27 @@ export default function EventTextArea({
submitOnCtrlEnter: true,
});
// an unknown value cannot be cleared by emptying the field, we offer an explicit action
const canClear = initialValue === undefined;
return (
<div>
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
{label}
</Editor.Label>
<div className={style.labelRow}>
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
{label}
</Editor.Label>
{canClear && (
<button type='button' className={style.clearAction} onClick={() => submitCallback('')}>
Clear
</button>
)}
</div>
<AutoTextarea
id={field}
inputref={ref}
rows={1}
data-testid='input-textarea'
placeholder={placeholder}
fluid
value={value}
onChange={onChange}
@@ -6,10 +6,13 @@ import useReactiveTextInput from '../../../../common/components/input/text-input
import { EventEditorUpdateFields } from '../EventEditor';
import { GroupEditorUpdateTextFields } from '../GroupEditor';
import style from '../EntryEditor.module.scss';
interface EntryEditorTextInputProps extends InputProps {
field: EventEditorUpdateFields | GroupEditorUpdateTextFields;
label: string;
initialValue: string;
/** undefined represents values which do not agree across the edited entries */
initialValue: string | undefined;
placeholder?: string;
submitHandler: (field: EventEditorUpdateFields, value: string) => void;
}
@@ -31,11 +34,21 @@ export default function EntryEditorTextInput({
submitOnEnter: true,
});
// an unknown value cannot be cleared by emptying the field, we offer an explicit action
const canClear = initialValue === undefined;
return (
<div>
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
{label}
</Editor.Label>
<div className={style.labelRow}>
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
{label}
</Editor.Label>
{canClear && (
<button type='button' className={style.clearAction} onClick={() => submitCallback('')}>
Clear
</button>
)}
</div>
<Input
id={field}
ref={ref}
@@ -0,0 +1,10 @@
/** Shown in place of a value when the entries being edited do not agree */
export const mixedPlaceholder = 'Mixed';
/** Returns the label of a switch which may represent values that do not agree */
export function switchLabel(value: boolean | undefined): string {
if (value === undefined) {
return mixedPlaceholder;
}
return value ? 'On' : 'Off';
}
@@ -0,0 +1,108 @@
import { CustomFieldKey, OntimeEvent } from 'ontime-types';
/**
* Marks a value which is not the same across the entries being edited
* This is a distinct value from undefined, which means that the field is absent
* Being a symbol, it also cannot be assigned to a patch by accident
*/
export const conflict: unique symbol = Symbol('conflict');
export type Conflict = typeof conflict;
/** A value which may not be the same across the entries being edited */
export type MergedValue<T> = T | Conflict;
export function isConflict<T>(value: MergedValue<T>): value is Conflict {
return value === conflict;
}
/** Resolves a merged value for the UI, where an unknown value is represented by undefined */
export function resolveConflict<T>(value: MergedValue<T>): T | undefined {
return isConflict(value) ? undefined : value;
}
/**
* Fields which can be edited across a selection of events
* timeStart and timeEnd are absolute points in time and are excluded:
* giving several events the same start or end collapses their durations
*/
export const batchEditableFields = [
'title',
'note',
'colour',
'flag',
'duration',
'endAction',
'countToEnd',
'timerType',
'timeWarning',
'timeDanger',
'linkStart',
] as const;
type BatchEditableField = (typeof batchEditableFields)[number];
/**
* Custom fields of the entries being edited
* A missing key means that none of the entries have a value for that field
*/
export type MergedCustomFields = Record<CustomFieldKey, MergedValue<string>>;
/**
* A merged view over a set of events
* A field holds the conflict symbol when the events do not agree on its value
*/
export type MergedEvent = {
[K in BatchEditableField]: MergedValue<OntimeEvent[K]>;
} & {
custom: MergedCustomFields;
};
/**
* Merges a list of events into a single view
* For a single event, no field is in conflict and every value matches the event
* @param events - events to merge, must contain at least one element
*/
export function mergeEvents(events: OntimeEvent[]): MergedEvent {
return {
title: mergeField(events, 'title'),
note: mergeField(events, 'note'),
colour: mergeField(events, 'colour'),
flag: mergeField(events, 'flag'),
duration: mergeField(events, 'duration'),
endAction: mergeField(events, 'endAction'),
countToEnd: mergeField(events, 'countToEnd'),
timerType: mergeField(events, 'timerType'),
timeWarning: mergeField(events, 'timeWarning'),
timeDanger: mergeField(events, 'timeDanger'),
linkStart: mergeField(events, 'linkStart'),
custom: mergeCustomFields(events),
};
}
/**
* Returns the shared value of a field, or the conflict symbol if the events disagree
*/
function mergeField<K extends BatchEditableField>(events: OntimeEvent[], field: K): MergedValue<OntimeEvent[K]> {
const value = events[0][field];
return events.some((event) => event[field] !== value) ? conflict : value;
}
/**
* Merges the custom fields of a list of events
* Fields missing from an entry are considered empty
*/
function mergeCustomFields(events: OntimeEvent[]): MergedCustomFields {
const merged: MergedCustomFields = {};
for (const event of events) {
for (const key of Object.keys(event.custom)) {
if (key in merged) {
continue;
}
const value = event.custom[key] ?? '';
merged[key] = events.some((other) => (other.custom[key] ?? '') !== value) ? conflict : value;
}
}
return merged;
}
+87
View File
@@ -0,0 +1,87 @@
import { expect, test } from '@playwright/test';
test('Editing multiple events', async ({ page }) => {
await page.goto('/editor');
await page.getByRole('button', { name: 'Edit' }).click();
// clear rundown
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
// create two events with distinct titles
await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(2);
await page.getByTestId('entry-1').getByTestId('entry__title').fill('first');
await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter');
await page.getByTestId('entry-2').getByTestId('entry__title').fill('second');
await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter');
const editor = page.getByTestId('editor-container');
// a single selection shows the full schedule and the event id
await page.getByTestId('entry-1').getByTestId('rundown-event').click();
await expect(editor.getByTestId('time-input-timeStart')).toBeVisible();
await expect(editor.getByLabel('Title', { exact: true })).toHaveValue('first');
// selecting both events shows a merged view
await page
.getByTestId('entry-2')
.getByTestId('rundown-event')
.click({ modifiers: ['Shift'] });
await expect(editor.locator('#eventId')).toHaveValue('2 events selected');
await expect(editor.getByText('Automations are not available when editing multiple events')).toBeVisible();
// start and end times are unique to an event, only the duration can be batched
await expect(editor.getByTestId('time-input-timeStart')).toBeHidden();
await expect(editor.getByTestId('time-input-timeEnd')).toBeHidden();
await expect(editor.getByTestId('time-input-duration')).toBeVisible();
// fields which do not agree are shown as mixed
const title = editor.getByLabel('Title', { exact: true });
await expect(title).toHaveValue('');
await expect(title).toHaveAttribute('placeholder', 'Mixed');
// leaving a mixed field without editing it does not overwrite the events
await title.click();
await page.keyboard.press('Tab');
await expect(page.getByTestId('entry-1').getByTestId('entry__title')).toHaveValue('first');
await expect(page.getByTestId('entry-2').getByTestId('entry__title')).toHaveValue('second');
// a mixed field cannot be cleared by emptying it, so an explicit action is offered
const clearTitle = editor.getByRole('button', { name: 'Clear', exact: true }).first();
await expect(clearTitle).toBeVisible();
await clearTitle.click();
await expect(page.getByTestId('entry-1').getByTestId('entry__title')).toHaveValue('');
await expect(page.getByTestId('entry-2').getByTestId('entry__title')).toHaveValue('');
// the events now agree, so the field can be cleared by hand and the action is withdrawn
await expect(editor.getByLabel('Title', { exact: true })).not.toHaveAttribute('placeholder', 'Mixed');
// editing a field applies it to the whole selection
await title.fill('shared title');
await title.press('Enter');
await expect(page.getByTestId('entry-1').getByTestId('entry__title')).toHaveValue('shared title');
await expect(page.getByTestId('entry-2').getByTestId('entry__title')).toHaveValue('shared title');
// the value is no longer mixed
await expect(editor.getByLabel('Title', { exact: true })).toHaveValue('shared title');
// a batched duration is applied to every event and the rundown is recalculated once
const duration = editor.getByTestId('time-input-duration');
await duration.click();
await duration.fill('5m');
await duration.press('Enter');
await expect(page.getByTestId('entry-1').getByTestId('time-input-duration')).toHaveValue('00:05:00');
await expect(page.getByTestId('entry-2').getByTestId('time-input-duration')).toHaveValue('00:05:00');
// the second event is linked, so its start follows the new end of the first
await expect(page.getByTestId('entry-2').getByTestId('time-input-timeStart')).toHaveValue('00:05:00');
await expect(page.getByTestId('entry-2').getByTestId('time-input-timeEnd')).toHaveValue('00:10:00');
// going back to a single selection restores the full editor
await page.getByTestId('entry-1').getByTestId('rundown-event').click();
await expect(editor.getByTestId('time-input-timeStart')).toBeVisible();
await expect(editor.locator('#eventId')).not.toHaveValue('2 events selected');
});