(() => {
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 (
+
+ );
+}
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 Behaviour
End Action
@@ -141,10 +86,11 @@ function EventEditorTimes({
Timer Type
@@ -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
@@ -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');
+});