Skip to content

Attempt at multi-edit#1994

Open
cdslipp wants to merge 28 commits into
cpvalente:masterfrom
sherwoodsystems:multiEdit
Open

Attempt at multi-edit#1994
cdslipp wants to merge 28 commits into
cpvalente:masterfrom
sherwoodsystems:multiEdit

Conversation

@cdslipp
Copy link
Copy Markdown
Contributor

@cdslipp cdslipp commented Mar 5, 2026

Demo Video

Video Thumbnail

I need to clean up the code, I am touching too many existing components and I am going to break the main MultiEventEditor.tsx into smaller components to mirror the main EventEditor components. Either way please let me know what you think of the functionality and approach! This PR is a bit bigger than I wanted, but I couldnt figure out how to integrate with the existing editor component without overcomplicating stuff like crazy. A completely separate UI component makes more sense to me, but it is one more thing to maintain which is annoying.

List of Batch Edit Behaviours and Mixed States

Right now, I have been messing with multiple UI states for each selection in the editor, here are my current choices:

Event Schedule

  • Start Time: blank out time but batch edit link to start toggle (same behaviour as batch context menu/right click)
  • End Time: blank out time but batch edit lock end toggle
    -If end lock is mixed or off, clicking the lock will enable duration lock for all, but this presents a dialog to the user to confirm
  • Duration - only editable if duration lock is ON
    • IF duration lock is mixed or off, clicking the lock will enable duration lock for all, but this presents a dialog to the user to confirm
    • If everything set to duration lock, but durations are mixed the input says "multiple"
    • If everyting same duration it says the duration

Event Behaviour

  • End Action: select says "mixed" when mixed
  • Count to End: we hide the toggle/thumb thing on switch when mixed

Event Data

  • Event ID and Cue should just blank out and not be multi-editable
  • Flag: we hide the toggle/thumb thing on switch when mixed
  • Colour: We show NO selection if mixed, and if all the same colour we show the selection highlight
  • Title: if all same we show, if mixed we say "multiple"
  • Notes: Same as title, btu if all blank show blank

Custom Fields

If there are no custom fields, show the manage custom fields link same as single select

  • Same as notes - if all blank show blank, if mixed show "multiple", all same show the same value

Automations

I looked at this briefly and it feels too complex right now because there is no easy batch endpoint for triggers/automations... so as far as I can tell this would be clunky to set up without setting up a server route. This could be an improvement later one

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 5, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 9375914d-c785-4993-a999-51095ee19485

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cdslipp cdslipp mentioned this pull request Mar 6, 2026
@cdslipp cdslipp marked this pull request as ready for review March 6, 2026 03:56
@cpvalente
Copy link
Copy Markdown
Owner

cpvalente commented Mar 6, 2026

Thank you for this @cdslipp

There seems to be several unrelated changes which we would be inclined to removing (even if they are good) to keep this change clean

The entry merging is slightly complex and we can probably simplify by leveraging some of the existing processes or pushing logic into the server.

One first step is to check if the batch endpoint can simplify things
We also dont need to check so many of the edge cases (like the first link) since the server will clean this up

I also wonder if we may be better off not using a new multimerge panel but leverage the existing. Since the components are modified to accept indeterminate states, we could generalise the logic
This would however make the component more complex, but maybe make it easier to change in future since we have less places that need to change (?)

It could also be worth it to revisit some of the old attempts. The performance issue was hopefully mitigated by the recent changes and we can see which decisions we can recover from past efforts
https://github.com/cpvalente/ontime/tree/bp/edit-multiple-events
#1186

I wil take some time to go through the changes more carefully and give a proper code review.
Meanwhile, wuld you be willing to join us on discord? maybe we can chat about the PR

@cpvalente
Copy link
Copy Markdown
Owner

@coderabbitai full review

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a dedicated multi-event (“batch edit”) editor UI to the rundown entry editor, enabling editing of shared event fields with mixed/indeterminate states.

Changes:

  • Introduces MultiEventEditor UI and supporting merge logic for multi-selection editing.
  • Updates selection store behavior to avoid mutating Set instances in Zustand state.
  • Extends shared input components (Select, Switch, TextArea, SwatchSelect, TimeInput) to better support mixed/empty states in multi-edit.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
apps/client/src/features/rundown/useEventSelection.ts Avoids in-place Set mutation to ensure selection updates trigger renders correctly.
apps/client/src/features/rundown/entry-editor/multi-edit/useMultiEventMerge.ts Adds hook to merge selected events into a “multi-edit view model”.
apps/client/src/features/rundown/entry-editor/multi-edit/multiEditUtils.ts Implements merge logic and indeterminate handling for multi-editable fields.
apps/client/src/features/rundown/entry-editor/multi-edit/MultiEventEditor.tsx New batch editor UI for multi-selected events.
apps/client/src/features/rundown/entry-editor/multi-edit/MultiEventEditor.module.scss Styles for the new multi-event editor.
apps/client/src/features/rundown/entry-editor/composite/EventTextArea.tsx Adds optional placeholder support (needed for “multiple” state).
apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx Switches to MultiEventEditor when more than one event is selected.
apps/client/src/features/rundown/_blockMixins.scss Changes selected outline color (now matches cursor color).
apps/client/src/common/components/switch/Switch.tsx Adds indeterminate prop and exposes it via a data attribute.
apps/client/src/common/components/switch/Switch.module.scss Hides thumb when data-indeterminate is set.
apps/client/src/common/components/select/Select.tsx Adds placeholder rendering support (used for “Mixed”).
apps/client/src/common/components/input/time-input/TimeInput.tsx Shows blank input for undefined/NaN times (supports multi-edit indeterminate).
apps/client/src/common/components/input/textarea/Textarea.module.scss Styles placeholders as italic.
apps/client/src/common/components/input/input/Input.module.scss Styles placeholders as italic.
apps/client/src/common/components/input/colour-input/SwatchSelect.tsx Adds noSelection to represent mixed colour state.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +64 to +88
custom: { ...first.custom },
allLockDuration: false,
allLockEnd: false,
};

// If the first event in the merged set is the first rundown event, seed linkStart from the second event
const firstIsRundownFirst = first.id === firstRundownEventId;
if (firstIsRundownFirst && events.length >= 2) {
merged.linkStart = events[1].linkStart;
}

for (let i = 1; i < events.length; i++) {
const event = events[i];
for (const field of mergeableFields) {
if (field === 'linkStart') continue; // handled separately below
if (merged[field] !== INDETERMINATE && event[field] !== first[field]) {
(merged[field] as MergedValue<unknown>) = INDETERMINATE;
}
}
// Merge custom fields per-key
for (const key of Object.keys(merged.custom)) {
if (merged.custom[key] !== INDETERMINATE && event.custom[key] !== first.custom[key]) {
merged.custom[key] = INDETERMINATE;
}
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mergeEvents() seeds merged.custom from the first selected event and then only iterates Object.keys(merged.custom). If different selected events have different custom-field keys (or missing keys), those keys are ignored in the merge and the UI can show an incorrect mixed/blank state. Consider merging over the union of custom-field keys across all selected events (or better: over the CustomFields schema keys) and comparing values using the same fallback as the single editor (event.custom[key] ?? '').

Copilot uses AI. Check for mistakes.
$block-box-shadow: rgba(0, 0, 0, 0.5) 0 0 3px 2px;
$secondary-block-height: 2.5rem;
$block-selected-color: $blue-400;
$block-selected-color: $orange-400;
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$block-selected-color is now the same value as $block-cursor-color ($orange-400). Since .selected and .hasCursor both use a 1px outline, this makes cursor vs selection visually indistinguishable and removes state information from the UI. Consider keeping these colors distinct (or otherwise differentiating the styles).

Suggested change
$block-selected-color: $orange-400;
$block-selected-color: $orange-300;

Copilot uses AI. Check for mistakes.
Comment thread apps/client/src/common/components/input/colour-input/SwatchSelect.tsx Outdated
Comment thread apps/client/src/common/components/input/input/Input.module.scss Outdated
const resetValue = useCallback(() => {
if (typeof time !== 'number' || isNaN(time)) {
setValue('00:00:00');
setValue('');
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: can you explain why do we need this change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was my workaround to getting empty state in the multi editor (ie when showing start and end time I want them to always be blank to imply there is a mixed value). However, there might be a clear way to do this so that we don't affect other areas of the app? I will try to make this change closer to the actual editor so its not a default state

Comment thread apps/client/src/common/components/select/Select.tsx Outdated
Comment on lines +59 to +61
&[data-indeterminate] {
opacity: 0;
}
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: the switch without the indicator looks broken. indeterminate switches are strange and was one of the reasons we considered using another design element

to keep things simple here, lets stick with switches but improve the design, maybe there are relevant UX resources for this subject? I saw some that had a line in the middle of the switch which should be good enough if we dont have better ideas

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have been trying a bunch of things, I think I landed somewhere I really like - let me know what you think!
image

Comment thread apps/client/src/common/components/switch/Switch.tsx Outdated

interface SwitchProps extends BaseSwitch.Root.Props {
size?: 'medium' | 'large';
indeterminate?: boolean;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: what is the relationship between value and indeterminate?

Would we get easier logic by extending value to be something like
value: boolean | 'indeterminate'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was literally just using this so we have a separate state ie that we can always toggle a mixed switch to 'ON'. However as I have been thinking about this and trying to rationalize... I realized its not computationally heavy at all to figure out the majority state - ie if most of the switches the selection are off, why not always make it so the toggle goes towards the majority first? If most switches in the selection are off, switch off. If most switches are on, switch on... this will make this feel more logical and not just like a random choice

allLockEnd: boolean;
};

export function mergeEvents(entries: RundownEntries, selectedIds: Set<string>, flatOrder: EntryId[]): MergedEvent | null {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: instead of merging all events, how about merging each entry values on its own?

this means that we will have more but simpler logic, the logic will also be closer to the consumer and we will have better opportunity to deal with the edge cases of each value

// if event is already selected, we remove it from selection
// and set the anchor to the event after
selectedEvents.delete(id);
const remaining = new Set(selectedEvents);
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: can you explain this change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying to understand Zustand as I haven't worked much with it before... as far I as I can tell with zustand + react most states are immutable so you have to create a new reference object instead of trying to mutate the original for it to be truly reactive? Pretty sure I got here after it not reactively updating the first time. I normally work with Svelte now, so I'm not really used to this kind of thing, so please let me know if there is a cleaner approach here!

https://zustand.docs.pmnd.rs/learn/guides/immutable-state-and-merging#immutable-state-and-merging

allLockEnd: boolean;
};

export function mergeEvents(entries: RundownEntries, selectedIds: Set<string>, flatOrder: EntryId[]): MergedEvent | null {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: I think it is great that we push the business logic away into separate modules

try and keep the business logic pure (no side effects) and make a test suite to validate it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants