From b981bae63f2036aca67062eb1548f45e94cd2c02 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 12:29:30 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01PDETGBrhwgAqgTHjmrmJGJ --- .../input/colour-input/SwatchSelect.tsx | 9 +- .../input/text-input/useReactiveTextInput.tsx | 12 +- .../components/input/time-input/TimeInput.tsx | 5 +- .../src/common/components/select/Select.tsx | 12 +- .../components/switch/Switch.module.scss | 5 + .../src/common/components/switch/Switch.tsx | 6 +- .../client/src/common/hooks/useEntryAction.ts | 7 +- .../entry-editor/CuesheetEventEditor.tsx | 7 +- .../rundown/entry-editor/EventEditor.tsx | 107 +++++++++++++----- .../rundown/entry-editor/GroupEditor.tsx | 7 +- .../rundown/entry-editor/MilestoneEditor.tsx | 7 +- .../entry-editor/RundownEntryEditor.tsx | 36 ++++-- .../__tests__/mergeEvents.test.ts | 96 ++++++++++++++++ .../composite/EventEditorCustomFields.tsx | 24 ++-- .../composite/EventEditorSchedule.tsx | 62 ++++++++++ .../composite/EventEditorTimes.tsx | 106 +++++------------ .../composite/EventEditorTitles.tsx | 84 +++++++++----- .../entry-editor/composite/EventTextArea.tsx | 6 +- .../entry-editor/composite/EventTextInput.tsx | 3 +- .../rundown/entry-editor/entryEditor.utils.ts | 10 ++ .../rundown/entry-editor/mergeEvents.ts | 82 ++++++++++++++ e2e/tests/features/215-multi-edit.spec.ts | 62 ++++++++++ 22 files changed, 584 insertions(+), 171 deletions(-) create mode 100644 apps/client/src/features/rundown/entry-editor/__tests__/mergeEvents.test.ts create mode 100644 apps/client/src/features/rundown/entry-editor/composite/EventEditorSchedule.tsx create mode 100644 apps/client/src/features/rundown/entry-editor/entryEditor.utils.ts create mode 100644 apps/client/src/features/rundown/entry-editor/mergeEvents.ts create mode 100644 e2e/tests/features/215-multi-edit.spec.ts diff --git a/apps/client/src/common/components/input/colour-input/SwatchSelect.tsx b/apps/client/src/common/components/input/colour-input/SwatchSelect.tsx index bdb4aac7b..01230482f 100644 --- a/apps/client/src/common/components/input/colour-input/SwatchSelect.tsx +++ b/apps/client/src/common/components/input/colour-input/SwatchSelect.tsx @@ -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) => ( ))} - + ); } diff --git a/apps/client/src/common/components/input/text-input/useReactiveTextInput.tsx b/apps/client/src/common/components/input/text-input/useReactiveTextInput.tsx index 59f314ce7..f0cc54e39 100644 --- a/apps/client/src/common/components/input/text-input/useReactiveTextInput.tsx +++ b/apps/client/src/common/components/input/text-input/useReactiveTextInput.tsx @@ -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, options?: { @@ -22,7 +23,7 @@ export default function useReactiveTextInput( allowKeyboardNavigation?: boolean; }, ): UseReactiveTextInputReturn { - const [text, setText] = useState(initialText); + const [text, setText] = useState(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 diff --git a/apps/client/src/common/components/input/time-input/TimeInput.tsx b/apps/client/src/common/components/input/time-input/TimeInput.tsx index 98136c402..a07a59e16 100644 --- a/apps/client/src/common/components/input/time-input/TimeInput.tsx +++ b/apps/client/src/common/components/input/time-input/TimeInput.tsx @@ -41,7 +41,10 @@ export default function TimeInput({ * @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)); diff --git a/apps/client/src/common/components/select/Select.tsx b/apps/client/src/common/components/select/Select.tsx index 696d06ea0..2482129f5 100644 --- a/apps/client/src/common/components/select/Select.tsx +++ b/apps/client/src/common/components/select/Select.tsx @@ -16,13 +16,21 @@ interface SelectProps extends Omit, 'items'> { options: SelectOption[]; 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({ options, fluid, size = 'medium', ...selectRootProps }: SelectProps) { +export default function Select({ + options, + fluid, + size = 'medium', + placeholder, + ...selectRootProps +}: SelectProps) { return ( - + diff --git a/apps/client/src/common/components/switch/Switch.module.scss b/apps/client/src/common/components/switch/Switch.module.scss index acf01f824..6607af475 100644 --- a/apps/client/src/common/components/switch/Switch.module.scss +++ b/apps/client/src/common/components/switch/Switch.module.scss @@ -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); diff --git a/apps/client/src/common/components/switch/Switch.tsx b/apps/client/src/common/components/switch/Switch.tsx index 2d69beae5..8d061a8ef 100644 --- a/apps/client/src/common/components/switch/Switch.tsx +++ b/apps/client/src/common/components/switch/Switch.tsx @@ -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 ( - + ); diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index 9386c66a4..22a222276 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -484,7 +484,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) { const previousRundown = queryClient.getQueryData(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, }; } } diff --git a/apps/client/src/features/rundown/entry-editor/CuesheetEventEditor.tsx b/apps/client/src/features/rundown/entry-editor/CuesheetEventEditor.tsx index 5c630b3d2..8c9543db9 100644 --- a/apps/client/src/features/rundown/entry-editor/CuesheetEventEditor.tsx +++ b/apps/client/src/features/rundown/entry-editor/CuesheetEventEditor.tsx @@ -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 (
- +
); } diff --git a/apps/client/src/features/rundown/entry-editor/EventEditor.tsx b/apps/client/src/features/rundown/entry-editor/EventEditor.tsx index b41773a28..024b28e6c 100644 --- a/apps/client/src/features/rundown/entry-editor/EventEditor.tsx +++ b/apps/client/src/features/rundown/entry-editor/EventEditor.tsx @@ -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) => { + 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 (
+ {singleEvent && ( + + )}
Custom Fields {isEditor && Manage Custom Fields} - +
Automations - {isEditor && Manage Automations} + {isEditor && singleEvent && Manage Automations} - + {singleEvent ? ( + + ) : ( + Automations are not available when editing multiple events + )}
); diff --git a/apps/client/src/features/rundown/entry-editor/GroupEditor.tsx b/apps/client/src/features/rundown/entry-editor/GroupEditor.tsx index f1508cd93..d0bdc632c 100644 --- a/apps/client/src/features/rundown/entry-editor/GroupEditor.tsx +++ b/apps/client/src/features/rundown/entry-editor/GroupEditor.tsx @@ -112,7 +112,12 @@ export default function GroupEditor({ group }: GroupEditorProps) { Custom Fields {isEditor && Manage Custom Fields} - + ); diff --git a/apps/client/src/features/rundown/entry-editor/MilestoneEditor.tsx b/apps/client/src/features/rundown/entry-editor/MilestoneEditor.tsx index be69e2211..53b6ac105 100644 --- a/apps/client/src/features/rundown/entry-editor/MilestoneEditor.tsx +++ b/apps/client/src/features/rundown/entry-editor/MilestoneEditor.tsx @@ -69,7 +69,12 @@ export default function MilestoneEditor({ milestone }: MilestoneEditorProps) { Custom Fields {isEditor && Manage Custom Fields} - + ); diff --git a/apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx b/apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx index 2384186d2..18e24be6e 100644 --- a/apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx +++ b/apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx @@ -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(() => { + 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(() => { 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 ; - } - - if (isOntimeEvent(entry)) { + if (events.length > 0) { + const singleEvent = events.length === 1 ? events[0] : null; return (
- - + + {singleEvent && }
); } + if (!entry) { + return ; + } + if (isOntimeMilestone(entry)) { return (
diff --git a/apps/client/src/features/rundown/entry-editor/__tests__/mergeEvents.test.ts b/apps/client/src/features/rundown/entry-editor/__tests__/mergeEvents.test.ts new file mode 100644 index 000000000..eaf74132d --- /dev/null +++ b/apps/client/src/features/rundown/entry-editor/__tests__/mergeEvents.test.ts @@ -0,0 +1,96 @@ +import { EndAction, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types'; + +import { mergeEvents } from '../mergeEvents'; + +function makeEvent(patch: Partial): 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 }); + }); + }); +}); diff --git a/apps/client/src/features/rundown/entry-editor/composite/EventEditorCustomFields.tsx b/apps/client/src/features/rundown/entry-editor/composite/EventEditorCustomFields.tsx index d2fc25210..90efc2abc 100644 --- a/apps/client/src/features/rundown/entry-editor/composite/EventEditorCustomFields.tsx +++ b/apps/client/src/features/rundown/entry-editor/composite/EventEditorCustomFields.tsx @@ -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 ( {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} /> - +
); } diff --git a/apps/client/src/features/rundown/entry-editor/composite/EventEditorSchedule.tsx b/apps/client/src/features/rundown/entry-editor/composite/EventEditorSchedule.tsx new file mode 100644 index 000000000..4d5a0972b --- /dev/null +++ b/apps/client/src/features/rundown/entry-editor/composite/EventEditorSchedule.tsx @@ -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 ( +
+ Event schedule +
+
+ +
+
{delayLabel}
+
+
+ ); +} diff --git a/apps/client/src/features/rundown/entry-editor/composite/EventEditorTimes.tsx b/apps/client/src/features/rundown/entry-editor/composite/EventEditorTimes.tsx index 3634dcde0..db9a03269 100644 --- a/apps/client/src/features/rundown/entry-editor/composite/EventEditorTimes.tsx +++ b/apps/client/src/features/rundown/entry-editor/composite/EventEditorTimes.tsx @@ -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) => 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 ( <> -
- Event schedule -
-
- -
-
{delayLabel}
-
-
-
Event Behaviour
End Action { if (value === null) return; - handleSubmit('timerType', value); + submit({ timerType: value }); }} options={[ { value: TimerType.CountDown, label: 'Count down' }, @@ -161,9 +107,9 @@ function EventEditorTimes({
@@ -171,9 +117,9 @@ function EventEditorTimes({
diff --git a/apps/client/src/features/rundown/entry-editor/composite/EventEditorTitles.tsx b/apps/client/src/features/rundown/entry-editor/composite/EventEditorTitles.tsx index 2eae440fd..2a4637f0c 100644 --- a/apps/client/src/features/rundown/entry-editor/composite/EventEditorTitles.tsx +++ b/apps/client/src/features/rundown/entry-editor/composite/EventEditorTitles.tsx @@ -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) => 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 Event Data
- Event ID (read only) - + {isMulti ? 'Selection (read only)' : 'Event ID (read only)'} +
- + {isMulti ? ( +
+ Cue (not available) + +
+ ) : ( + + )}
Flag - - {flag ? 'On' : 'Off'} + submit({ flag: newValue })} + /> + {switchLabel(flag)}
@@ -58,8 +78,20 @@ function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEdi Colour
- - + + ); } diff --git a/apps/client/src/features/rundown/entry-editor/composite/EventTextArea.tsx b/apps/client/src/features/rundown/entry-editor/composite/EventTextArea.tsx index 9968be987..81cf2d76e 100644 --- a/apps/client/src/features/rundown/entry-editor/composite/EventTextArea.tsx +++ b/apps/client/src/features/rundown/entry-editor/composite/EventTextArea.tsx @@ -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} diff --git a/apps/client/src/features/rundown/entry-editor/composite/EventTextInput.tsx b/apps/client/src/features/rundown/entry-editor/composite/EventTextInput.tsx index 43d1cf3af..13cb2506d 100644 --- a/apps/client/src/features/rundown/entry-editor/composite/EventTextInput.tsx +++ b/apps/client/src/features/rundown/entry-editor/composite/EventTextInput.tsx @@ -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; } diff --git a/apps/client/src/features/rundown/entry-editor/entryEditor.utils.ts b/apps/client/src/features/rundown/entry-editor/entryEditor.utils.ts new file mode 100644 index 000000000..e61c8f0d9 --- /dev/null +++ b/apps/client/src/features/rundown/entry-editor/entryEditor.utils.ts @@ -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'; +} diff --git a/apps/client/src/features/rundown/entry-editor/mergeEvents.ts b/apps/client/src/features/rundown/entry-editor/mergeEvents.ts new file mode 100644 index 000000000..9a74bbae3 --- /dev/null +++ b/apps/client/src/features/rundown/entry-editor/mergeEvents.ts @@ -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; + +/** + * 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(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; +} diff --git a/e2e/tests/features/215-multi-edit.spec.ts b/e2e/tests/features/215-multi-edit.spec.ts new file mode 100644 index 000000000..c8c0c80e3 --- /dev/null +++ b/e2e/tests/features/215-multi-edit.spec.ts @@ -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'); +});