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
This commit is contained in:
Claude
2026-07-25 12:29:30 +00:00
parent 2a890cf2b3
commit b981bae63f
22 changed files with 584 additions and 171 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>
);
}
@@ -9,7 +9,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 +23,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
@@ -58,7 +59,8 @@ export default function useReactiveTextInput(
const handleSubmit = useCallback(
(valueToSubmit: string) => {
// No need to update if it hasn't changed
if (valueToSubmit === initialText && !options?.allowSubmitSameValue) {
// an undefined initial value is shown as an empty field, submitting it would overwrite the underlying data
if (valueToSubmit === (initialText ?? '') && !options?.allowSubmitSameValue) {
options?.onCancelUpdate?.();
} else {
const cleanVal = valueToSubmit.trim();
@@ -86,10 +88,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>
);
@@ -484,7 +484,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
const previousRundown = queryClient.getQueryData<Rundown>(queryKey);
if (previousRundown) {
const eventIds = new Set(data.ids);
const { data: patch, ids } = data;
const eventIds = new Set(ids);
const newRundown = { ...previousRundown.entries };
eventIds.forEach((eventId) => {
@@ -493,7 +494,9 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
if (isOntimeEvent(event)) {
newRundown[eventId] = {
...event,
...data,
...patch,
// custom fields are patched, not replaced
custom: patch.custom ? { ...event.custom, ...patch.custom } : event.custom,
};
}
}
@@ -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>
);
}
@@ -1,14 +1,18 @@
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 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 } from './mergeEvents';
import style from './EntryEditor.module.scss';
@@ -16,66 +20,109 @@ 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}
/>
)}
<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={merged.countToEnd}
endAction={merged.endAction}
timerType={merged.timerType}
timeWarning={merged.timeWarning}
timeDanger={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={merged.flag}
title={merged.title}
note={merged.note}
colour={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,96 @@
import { EndAction, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types';
import { mergeEvents } from '../mergeEvents';
function makeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
return {
type: SupportedEntry.Event,
id: 'event',
title: 'title',
note: 'note',
colour: '#FFCC78',
flag: false,
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,
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);
});
it('marks only the fields which differ as undefined', () => {
const merged = mergeEvents([makeEvent({ id: '1', title: 'first' }), makeEvent({ id: '2', title: 'second' })]);
expect(merged.title).toBeUndefined();
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 }),
makeEvent({ id: '2', flag: false, timeWarning: 1000 }),
]);
expect(merged.flag).toBeUndefined();
expect(merged.timeWarning).toBe(1000);
});
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: undefined });
});
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: undefined });
});
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: undefined });
});
});
});
@@ -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 } 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] ?? '';
const value = custom[fieldKey];
const initialValue = fieldKey in custom ? value : '';
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>
);
}
@@ -9,7 +9,9 @@ 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 +21,7 @@ export default function EventTextArea({
field,
label,
initialValue,
placeholder,
style: givenStyles,
submitHandler,
}: CountedTextAreaProps) {
@@ -39,6 +42,7 @@ export default function EventTextArea({
inputref={ref}
rows={1}
data-testid='input-textarea'
placeholder={placeholder}
fluid
value={value}
onChange={onChange}
@@ -9,7 +9,8 @@ import { GroupEditorUpdateTextFields } from '../GroupEditor';
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;
}
@@ -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,82 @@
import { CustomFieldKey, OntimeEvent } from 'ontime-types';
/**
* Fields which can be edited across a selection of events
* Schedule fields, cue and triggers are deliberately excluded:
* they are either unique to an event or would cascade through the rundown
*/
export const batchEditableFields = [
'title',
'note',
'colour',
'flag',
'endAction',
'countToEnd',
'timerType',
'timeWarning',
'timeDanger',
'linkStart',
] as const;
type BatchEditableField = (typeof batchEditableFields)[number];
export type MergedCustomFields = Record<CustomFieldKey, string | undefined>;
/**
* A merged view over a set of events
* A field is undefined when the events do not agree on its value
*/
export type MergedEvent = {
[K in BatchEditableField]: OntimeEvent[K] | undefined;
} & {
custom: MergedCustomFields;
};
/**
* Merges a list of events into a single view
* For a single event, every field is defined and 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'),
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 undefined if the events disagree
*/
function mergeField<K extends BatchEditableField>(events: OntimeEvent[], field: K): OntimeEvent[K] | undefined {
const value = events[0][field];
return events.some((event) => event[field] !== value) ? undefined : 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) ? undefined : value;
}
}
return merged;
}
+62
View File
@@ -0,0 +1,62 @@
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 schedule and the event id
await page.getByTestId('entry-1').getByTestId('rundown-event').click();
await expect(editor.getByText('Event schedule')).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('Event schedule')).toBeHidden();
await expect(editor.getByText('Automations are not available when editing multiple events')).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');
// 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');
// going back to a single selection restores the full editor
await page.getByTestId('entry-1').getByTestId('rundown-event').click();
await expect(editor.getByText('Event schedule')).toBeVisible();
await expect(editor.locator('#eventId')).not.toHaveValue('2 events selected');
});