Skip to content

[scheduler] Add onEventEditingStart to let consumers open their own edit UI - #23361

Merged
rita-codes merged 30 commits into
mui:masterfrom
rita-codes:23329-scheduler-let-consumers-open-their-own-edit-ui-instead-of-the-built-in-event-dialog
Aug 21, 2026
Merged

rita-codes merged 30 commits into
mui:masterfrom
rita-codes:23329-scheduler-let-consumers-open-their-own-edit-ui-instead-of-the-built-in-event-dialog

Conversation

@rita-codes

@rita-codes rita-codes commented Aug 18, 2026 •

Copy link
Copy Markdown
Member

Closes #23329

Adds a cancelable onEventEditingStart(occurrence, eventDetails) callback to all scheduler components, letting applications open their own editing UI instead of the built-in event dialog.

<EventCalendarPremium
  events={events}
  onEventEditingStart={(occurrence, eventDetails) => {
    if (eventDetails.reason === 'view') {
      return; // read-only: keep the built-in view-only dialog
    }
    eventDetails.cancel();
    openYourDrawer(eventDetails.occurrence.id);
  }}
/>

Design

  • The callback fires right before the built-in dialog (or its mobile drawer variant) opens — startEditing resolving to 'edit' and the armed → edit transition — so it covers pointer, keyboard, touch, the "+N more" popover, and any entry point added later (context menu Edit item, imperative API), rather than guarding the click only. Arming on touch (toolbar + resize affordances) is selection UI and stays built-in; canceling from the toolbar's Edit disarms, since the armed state keeps document-wide guards that must not stay active under custom UI.
  • It also fires for event creation: eventDetails.reason is "creation" when the user is creating a new event (letting the app open its own creation UI prefilled with the proposed time). Canceling a creation also drops the draft placeholder, so no stale preview stays on the grid — and repeat startEditing calls while the surface is already open are no-ops (the one-shot invariant lives in the store), so form-driven placeholder updates never re-fire the callback.
  • Read-only activations (event readOnly, resource areEventsReadOnly, or the readOnly prop) report reason: "view" — the built-in dialog opens view-only for them, so an app replacing the dialog can render its own read-only UI instead of an editable form.
  • The contract is documented precisely: canceling replaces the built-in dialog — the app owns the editing form, the recurring scope selection when saving from it, and persistence. Everything else keeps the built-in behavior: drag and drop, resizing, the recurring scope dialog they trigger, and the touch toolbar with its edit, delete and resize affordances.
  • This is the first scheduler callback using non-'none' reasons — createChangeEventDetails is generic over the reason string by design, matching Base UI's own semantic reasons (imperative-action, cancel-open, ...).
  • eventDetails is typed as a discriminated union: eventDetails.occurrence is the persisted occurrence on "edit" and "view", and the draft on "creation", so narrowing on reason works in plain TS while the callback keeps the (value, eventDetails) convention. eventDetails.event carries the initiating native event on every path (the creation flow stashes it with the placeholder, since startEditing runs in an effect), and eventDetails.trigger carries the element the built-in surface would anchor to — the occurrence element, the toolbar's Edit button, or the creation surface's anchor — ready to position custom popovers.
  • Cancellation cleans up after itself: a canceled activation from the "+N more" popover closes the popover, and the editing anchor is only committed when editing actually starts.
  • New public type exports: SchedulerEventOccurrence, SchedulerEventOccurrencePlaceholder, SchedulerRenderableEventOccurrence, SchedulerChangeEventDetails, SchedulerEventEditingStartEventDetails.

Docs: new "Replace the dialog with your own UI" section on both Editing pages (Event Calendar and Event Timeline), each with its own demo covering edit and creation interception. The Timeline page also gains the "Event dialog" section it was missing, following the existing duplication convention between the two pages.

rita-codes and others added 2 commits August 17, 2026 20:06
…their own edit UI

Fired from SchedulerStore.startEditing, the single funnel every editing-surface
opening passes through, so it covers pointer, keyboard, touch, the "+N more"
popover and event creation. Calling eventDetails.cancel() keeps the built-in
dialog/drawer closed; canceling during a creation also drops the draft placeholder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTSbctmFaRK7waP3GRKKrr
… eventDetails.reason

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTSbctmFaRK7waP3GRKKrr
@rita-codes rita-codes self-assigned this Aug 18, 2026
@rita-codes rita-codes added type: new feature Expand the scope of the product to solve a new problem. scope: scheduler Changes related to the scheduler. labels Aug 18, 2026
@rita-codes rita-codes changed the title [scheduler] Add onEventEditingStart to let consumers open their own edit UI [scheduler] Add onEventEditingStart to let consumers open their own edit UI Aug 18, 2026
@code-infra-dashboard

code-infra-dashboard Bot commented Aug 18, 2026 •

Copy link
Copy Markdown

Deploy preview

Bundle size

Bundle Parsed size Gzip size
@mui/x-data-grid 0B(0.00%) 0B(0.00%)
@mui/x-data-grid-pro 0B(0.00%) 0B(0.00%)
@mui/x-data-grid-premium 0B(0.00%) 0B(0.00%)
@mui/x-charts 0B(0.00%) 0B(0.00%)
@mui/x-charts-pro 0B(0.00%) 0B(0.00%)
@mui/x-charts-premium 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers-pro 0B(0.00%) 0B(0.00%)
@mui/x-tree-view 0B(0.00%) 0B(0.00%)
@mui/x-tree-view-pro 0B(0.00%) 0B(0.00%)
@mui/x-scheduler 🔺+1.02KB(+0.27%) 🔺+309B(+0.30%)
@mui/x-scheduler-premium 🔺+1.15KB(+0.21%) 🔺+335B(+0.22%)
@mui/x-chat 0B(0.00%) 0B(0.00%)
@mui/x-license 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

rita-codes and others added 4 commits August 18, 2026 09:03
…et-consumers-open-their-own-edit-ui-instead-of-the-built-in-event-dialog
…s on the Timeline page

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTSbctmFaRK7waP3GRKKrr
Match the duplication convention between the Calendar and Timeline editing pages
instead of cross-linking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTSbctmFaRK7waP3GRKKrr
… asserts, allDay in demos, reason-branched docs snippet

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTSbctmFaRK7waP3GRKKrr

@flaviendelangle flaviendelangle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

PR review

Two merge-blocking issues remain: cancellation strands the “+N more” popover and disables touch resizing despite the documented contract. CI is green, but the affected cancellation paths are not covered.

Bugs (3)

1. 🔴 Canceled activation leaves the “+N more” popover open

Location: packages/x-scheduler-internals/src/internals/utils/SchedulerStore/SchedulerStore.ts:909

if (eventDetails.isCanceled) {
  if (isCreation) {
    this.setOccurrencePlaceholder(null);
  }
  return;
}

The popover closes only after editingOccurrence transitions from non-null to null. Cancellation returns before that state is set, so the popover remains behind the custom editor and reappears when it closes.

Failure scenario: Open “+N more,” select an event, cancel built-in editing, then close the custom drawer. The scheduler popover is still open.

Fix: Report cancellation back to EventEditingTrigger and close the originating popover, or add a dedicated editing-attempt lifecycle signal.

2. 🔴 Cancellation removes the touch resizing affordance

Location: docs/data/scheduler/event-calendar/editing/editing.md:98

Interactions that stay inside the calendar, like drag and drop and resizing, are not affected

Touch resize handles are visible only while the event is armed. Arming is stored in editingOccurrence, but cancellation returns before setting it. An application that always cancels to open custom UI can therefore never expose resize handles on touch devices.

Failure scenario: On mobile, tap a resizable event while onEventEditingStart cancels. The custom editor opens, but the event never arms and its resize handles remain inaccessible.

Fix: Preserve a resize-capable armed state without opening the built-in toolbar/drawer, or explicitly narrow the documented contract if disabling touch resize is intentional.

3. 🟠 Creation callbacks lose the initiating native event

Location: packages/x-scheduler-internals/src/internals/utils/SchedulerStore/SchedulerStore.ts:907

const eventDetails = createChangeEventDetails(isCreation ? 'creation' : 'edit', event);

Creation begins in pointer/keyboard handlers, but those handlers store only a placeholder. The later effects call startEditing without the event, causing Base UI to fabricate Event('base-ui'). Existing-event activation correctly forwards event.nativeEvent.

Failure scenario: Custom creation UI inspects coordinates, modifiers, target, or pointer-versus-keyboard activation and receives a synthetic event with no useful interaction data.

Fix: Preserve the native event through the pending creation flow and pass it to startEditing.

Tests (3)

1. 🟠 The “+N more” cancellation path is untested

Location: packages/x-scheduler/src/month-view/tests/MonthView.test.tsx:191

it('should stay open while editing and close once the editing surface closes', async () => {

Coverage only exercises the non-canceled true-to-false editing transition.

Failure scenario: The new portal entry path ships while leaving the popover open after cancellation.

Fix: Cancel activation from a hidden event and assert the built-in dialog and popover are both closed.

2. 🟠 Mobile cancellation and touch resizing are untested

Location: packages/x-scheduler/src/event-calendar/EventCalendar.test.tsx:474

expect(screen.queryByRole('dialog')).to.equal(null);

The cancellation tests exercise the desktop dialog only; none covers the compact drawer/armed state that controls touch resize handles.

Failure scenario: Desktop tests pass while consumers lose resizing on mobile.

Fix: Add a StandaloneCompactDayView test that cancels the callback and verifies the intended touch-resize contract.

3. 🟠 The native-event assertion accepts the synthetic fallback

Location: packages/x-scheduler-premium/src/event-timeline-premium/EventTimelinePremium.test.tsx:1016

expect(onEventEditingStart.lastCall.args[1].event).to.be.instanceOf(Event);

Event('base-ui') also satisfies this assertion, and the creation test never checks its event.

Failure scenario: Every creation entry point discards its native event while the test suite remains green.

Fix: Assert the event type, target, modifiers, or strict event identity for edit and creation paths.

Simplifications (1)

1. 🟠 Correlate the reason with the occurrence type

Location: packages/x-scheduler-internals/src/internals/utils/SchedulerStore/SchedulerStore.types.ts:390

onEventEditingStart?: (
  occurrence: SchedulerRenderableEventOccurrence,
  eventDetails: SchedulerEventEditingStartEventDetails,
) => void;

Checking eventDetails.reason === 'edit' does not narrow the separate occurrence argument. Consumers still need casts or property checks to access persisted-event fields, despite the docs teaching reason-based branching.

Failure scenario: A TypeScript custom editor branches on "edit" but cannot safely access modelInBuiltInFormat or dataTimezone.

Fix: Expose a correlated tuple or discriminated payload pairing "edit" with SchedulerEventOccurrence and "creation" with SchedulerEventOccurrencePlaceholder.

Docs (1)

1. 🟠 onEventsChange is presented as a persistence mechanism for canceled edits

Location: docs/data/scheduler/event-calendar/editing/editing.md:96, docs/data/scheduler/event-timeline/editing/editing.md:90

persisting the changes (for example through `onEventsChange`)

onEventsChange is an output callback invoked by the scheduler’s update path. Once editing is canceled, custom form submission does not trigger it; both new demos correctly update state directly.

Failure scenario: A consumer expects saving the custom form to invoke onEventsChange, but nothing is persisted.

Fix: Recommend updating controlled events state or invoking the application’s persistence/data-source handler directly.

Verdict

Request changes - cancellation currently breaks two explicitly supported interaction paths.


🤖 Review generated with Codex

rita-codes and others added 3 commits August 18, 2026 10:51
…creation native event, narrow docs contract

- startEditing returns whether editing started; the "+N more" popover closes
  when an activation inside it is canceled.
- The native event that initiated a creation placeholder is stashed and
  forwarded to onEventEditingStart instead of Base UI's synthetic fallback.
- Docs: canceling also suppresses touch resizing (part of the built-in editing
  surface); persistence goes through the controlled events state, not onEventsChange.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTSbctmFaRK7waP3GRKKrr
…ping arming built-in

The armed state (touch toolbar + resize affordances) is selection UI, not the
editing form — conceptually the same as the upcoming desktop context menu. The
callback now fires on the transitions that actually open the form: startEditing
resolving to 'edit' and the armed → edit transition (the toolbar's Edit action,
and any future entry point such as a context menu item). Canceling from the
toolbar keeps the event armed, so touch resizing stays fully built-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTSbctmFaRK7waP3GRKKrr
@rita-codes

Copy link
Copy Markdown
Member Author

Applied in a67912c and f759fd8:

  • "+N more" popover: startEditing now reports whether editing started, and the popover closes when an activation inside it is canceled. Test added.
  • Creation native event: the initiating event (pointer and keyboard) is now stashed with the creation placeholder and forwarded to the callback, instead of Base UI's synthetic fallback. The event assertions now check event.type, so the fallback can no longer satisfy them.
  • Touch resizing: went further than narrowing the docs — the armed state is conceptually the same thing as the desktop context menu we have on the roadmap (selection affordances, not editing), so the callback now fires right before the dialog opens: startEditing resolving to 'edit', plus the armed → edit transition. Arming, touch resize, delete and any future entry point (context menu Edit item) stay built-in and go through the same veto instead of bypassing it. Canceling from the toolbar keeps the event armed. Tests pin the whole flow on StandaloneCompactDayView.
  • Docs: persistence now points to updating the controlled events state, and the contract states exactly what cancellation replaces (the dialog) and what stays built-in (DnD, resizing, the touch toolbar).

On correlating the reason with the occurrence type: the narrowing gap is real, but I'd rather not move away from our (value, eventDetails) callback convention. Alternative: type the details as a discriminated union via Base UI's customProperties — 'edit' carrying details.occurrence: SchedulerEventOccurrence, 'creation' carrying the placeholder — so eventDetails.reason === 'edit' narrows in plain TS while the first argument stays as the convenience union. Happy to add it in this PR if you're on board.

rita-codes and others added 2 commits August 18, 2026 12:41
…g JSDoc placement

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTSbctmFaRK7waP3GRKKrr
…et-consumers-open-their-own-edit-ui-instead-of-the-built-in-event-dialog
@flaviendelangle

Copy link
Copy Markdown
Member

but I'd rather not move away from our (value, eventDetails) callback convention

Agreed!

`eventDetails.occurrence` is typed per reason via Base UI's customProperties, so
narrowing on `reason` gives the persisted occurrence on 'edit' and the draft on
'creation', while the callback keeps the (value, eventDetails) convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTSbctmFaRK7waP3GRKKrr

@flaviendelangle flaviendelangle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

PR review

Two merge-blocking defects remain: creation callbacks can fire repeatedly after the dialog opens, and canceled toolbar edits leave global interaction guards active over custom UI. CI is green, but these transitions are not covered.

Bugs (3)

1. 🔴 Creation callback re-fires while the dialog is already open

Location: packages/x-scheduler-internals/src/internals/utils/SchedulerStore/SchedulerStore.ts:950

if (mode === 'edit' && !this.requestEditingStart(occurrence, event)) {
  return false;
}

Creation effects in DayGridCell, MonthViewCell, and EventTimelinePremiumContent call startEditing whenever the derived placeholder changes. The built-in form updates that placeholder when dates, times, or resources change, so onEventEditingStart fires again despite no surface opening.

If this unexpected second invocation is canceled, the placeholder is removed while the previous editing state and dialog remain. Submitting then attempts to update the synthetic placeholder ID and throws because no persisted event exists.

Failure scenario: Create an all-day, month, or timeline event without initially canceling the callback, change its date or resource, then cancel the second callback and submit the form.

Fix: Make startEditing idempotent for an already-edited occurrence before requesting permission, while still refreshing the stored occurrence, or apply the existing isEditingPlaceholder guard from TimeGridColumn to every creation effect. Add a regression test covering form-driven placeholder updates.

2. 🔴 Canceled toolbar edits leave global guards active over custom UI

Location: packages/x-scheduler-internals/src/internals/utils/SchedulerStore/SchedulerStore.ts:967

if (mode === 'edit' && !this.requestEditingStart(editingOccurrence.occurrence, event)) {
  return;
}

On coarse-pointer dialog surfaces, cancellation deliberately leaves the occurrence armed. AnchoredEventToolbar therefore remains mounted with document-wide click capture and non-passive wheel/touchmove listeners.

A custom MUI dialog cannot scroll because the scroll blocker does not exempt modal roots. In non-MUI custom UI, the first click is swallowed to disarm the scheduler. Consumers also have no public way to end this armed state when their custom editor closes.

Failure scenario: Tap an event on a tablet, tap Edit, cancel the callback, and open a long custom dialog. Wheel and touch scrolling in the dialog are prevented.

Fix: Suspend the armed global guards while editing is externally handled, or represent external editing separately from the armed selection state. Test coarse-pointer cancellation with scrolling and interaction inside custom UI.

3. 🟠 Timeline keyboard creation loses its initiating event

Location: packages/x-scheduler-internals-premium/src/timeline-grid/event-row/TimelineGridEventRow.tsx:118

triggerKeyboardCreation();

The keyboard-creation hook now accepts the native event, and the calendar day/time callers pass it, but the timeline caller was missed.

Failure scenario: Focus a timeline row and press Enter. onEventEditingStart receives Event('base-ui'), so the initiating key, modifiers, and target are unavailable.

Fix: Call triggerKeyboardCreation(event.nativeEvent) and assert the event type or identity in a timeline integration test.

Tests (2)

1. 🟠 Native-event forwarding is not asserted at component boundaries

Location: packages/x-scheduler-internals-premium/src/timeline-grid/tests/keyboard-navigation.TimelineGrid.test.tsx:236

expect(store!.state.occurrencePlaceholder?.type).to.equal('creation');
expect(store!.state.occurrencePlaceholder?.surfaceType).to.equal('timeline');

The keyboard tests only verify placeholder state. Store tests manually supply an event, while the compact toolbar test only verifies callback count and UI state. Consequently, omitting event.nativeEvent from a caller leaves the suite green—as the timeline bug demonstrates.

Failure scenario: Keyboard creation or toolbar Edit reports Base UI’s fallback event without failing tests.

Fix: Add integration assertions for eventDetails.event.type or strict event identity across timeline/day/time keyboard creation and toolbar Edit.

2. 🟠 Creation tests do not verify the callback occurrence

Location: packages/x-scheduler/src/event-calendar/EventCalendar.test.tsx:489

expect(onEventEditingStart.lastCall.args[1].reason).to.equal('creation');
expect(onEventEditingStart.lastCall.args[1].event.type).to.equal('click');

The test never verifies the occurrence produced for the clicked cell. The custom-UI demos immediately depend on its start and end values.

Failure scenario: A stale or incorrectly ranged placeholder reaches the custom editor while every existing assertion passes.

Fix: Assert the callback’s eventDetails.occurrence, including its displayed start/end and all-day or surface semantics.

Simplifications (1)

1. 🟠 Avoid broadcasting an anchor for canceled starts

Location: packages/x-scheduler/src/internals/components/event-editing/EventEditingContext.tsx:51

setAnchor(forwardedAnchorRef.current);
// ...
return store.startEditing(...);

The anchor is committed before cancellation is known. A canceled direct edit changes the context value and makes every visible editing-context consumer reconcile, despite no built-in surface opening. Cancellation from the “+N more” popover can also leave the detached popover node retained as the anchor.

Failure scenario: An application that always cancels to open custom UI incurs work proportional to visible occurrences on every activation.

Fix: Call store.startEditing first and commit the anchor only when it returns true.

Docs (1)

1. 🟠 Examples bypass the documented discriminated occurrence

Location: docs/data/scheduler/event-calendar/editing/editing.md:80, docs/data/scheduler/event-timeline/editing/editing.md:74

onEventEditingStart={(occurrence, eventDetails) => {
  if (eventDetails.reason === 'creation') {
    openYourCreationUI(occurrence.displayTimezone);
  } else {
    openYourEditingUI(occurrence.id);
  }
}}

The preceding prose correctly says eventDetails.reason narrows eventDetails.occurrence, but the examples use the separate first argument, which remains SchedulerRenderableEventOccurrence.

Failure scenario: A consumer follows the example and accesses an edit-only field such as occurrence.dataTimezone; TypeScript still reports that it may be a creation placeholder.

Fix: Use eventDetails.occurrence inside both branches and update the full demos similarly.

Verdict

Request changes - repeated creation callbacks and persistent armed-state guards break realistic custom-editor flows.


🤖 Review generated with Codex

rita-codes and others added 2 commits August 18, 2026 14:05
…wn-edit-ui-instead-of-the-built-in-event-dialog
- Gate every creation effect on isEditingPlaceholder (DayGridCell, MonthViewCell,
  timeline EventRowContent) so form-driven placeholder updates don't re-fire
  onEventEditingStart while the dialog is open; regression test added.
- Canceling the armed → edit transition now disarms: the armed state keeps
  document-wide guards (scroll block, outside-pointer capture) that must not
  stay active under the consumer's custom UI.
- Timeline keyboard creation forwards its initiating keydown; integration
  asserts pin event.type across keyboard creation and the toolbar Edit.
- The anchor is only committed when editing actually starts.
- Docs snippets and demos read the occurrence from eventDetails, matching the
  discriminated typing; creation test asserts the draft occurrence semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTSbctmFaRK7waP3GRKKrr
@rita-codes

Copy link
Copy Markdown
Member Author

All applied in 6e01021:

  • Creation re-fires: the isEditingPlaceholder guard from TimeGridColumn now gates every creation effect (day cell, month cell, timeline row), with a regression test updating the placeholder while the dialog is open.
  • Armed guards over custom UI: canceling the armed → edit transition now disarms — you're right that the document-wide scroll block and outside-pointer capture must not survive under the consumer's UI. This also gives consumers a clean end state without needing a public disarm API.
  • Timeline keyboard event: forwarded, and event.type is now asserted across keyboard creation (day cell + timeline) and the toolbar Edit, so the synthetic fallback can't slip back in on any caller.
  • Anchor: only committed when editing actually starts.
  • Docs/demos: snippets and demos now read eventDetails.occurrence, and the creation test pins the draft's allDay/range semantics.

@flaviendelangle flaviendelangle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

PR review

One merge-blocking API issue remains: read-only activations are indistinguishable from editable ones, allowing custom editors to bypass the scheduler’s effective read-only policy. The targeted suites pass—156 tests with one existing skip—and all four scheduler packages type-check.

Bugs (1)

1. 🔴 Read-only activations masquerade as editable ones

Location: packages/x-scheduler-internals/src/internals/utils/SchedulerStore/SchedulerStore.ts:919

const isCreation = this.state.occurrencePlaceholder?.type === 'creation';

const eventDetails = isCreation
  ? createChangeEventDetails('creation', ...)
  : createChangeEventDetails('edit', ...);

EventEditingContext resolves effective read-only state before calling startEditing, but that information is discarded here. Consequently, root-, resource-, and event-level read-only occurrences all produce reason: "edit" even though the built-in dialog renders view-only content. Effective resource/root policy also cannot reliably be recovered from occurrence.readOnly.

I independently reproduced a read-only activation invoking the callback with reason: "edit".

Failure scenario: A consumer follows the custom-editing demo, cancels every callback, and opens an editable form. Activating an event belonging to an areEventsReadOnly resource opens that form and allows the controlled events state to be changed, while the built-in dialog would have been view-only.

Fix: Model this transition explicitly, for example with reason: "view" or an effective readOnly/mode property, and update the demo to render non-editable custom UI. Alternatively, do not invoke this editing hook for view-only activations.

Tests (4)

1. 🟠 The read-only callback contract is untested

Location: packages/x-scheduler/src/event-calendar/EventCalendar.test.tsx:445

describe('onEventEditingStart', () => {
  it('should be called with the occurrence when activating an event ...', ...);
  it('should keep the built-in dialog closed when the handler cancels', ...);

The new integration tests cover editable events and creation only. None activates an event made read-only through the event, resource, or root policy.

Failure scenario: Read-only occurrences ship as ordinary "edit" transitions while all callback tests remain green.

Fix: Add root- and resource-level read-only cases that pin whether the callback fires and what explicit view-only information it receives.

2. 🟠 Timed keyboard creation is not tested through the callback

Location: packages/x-scheduler-internals/src/calendar-grid/time-column/CalendarGridTimeColumn.test.tsx:53

const cell = screen.getByRole('gridcell');
await user.click(cell);
await user.keyboard('{Enter}');

expect(store!.state.occurrencePlaceholder?.type).to.equal('creation');

This changed entry point now forwards event.nativeEvent, but its test only checks placeholder state. It also clicks the cell before pressing Enter, so the stored initiating event may be the click rather than the keydown. The day-cell and timeline tests manually invoke store.startEditing, bypassing the production creation effects.

Failure scenario: Timed keyboard creation loses its initiating keydown or fails to invoke onEventEditingStart, yet the placeholder assertions continue passing.

Fix: Focus the timed cell without clicking, press Enter in an integration test, cancel the callback, and assert one "creation" call with a native keydown, no dialog, and no stale placeholder.

3. 🟠 Two anti-refire guards lack regression coverage

Location: packages/x-scheduler-premium/src/event-timeline-premium/content/EventTimelinePremiumContent.tsx:597

const isEditingPlaceholder = useStore(
  store,
  schedulerOtherSelectors.isEditedOccurrence,
  placeholder?.key,
);

Equivalent new logic exists in DayGridCell, MonthViewCell, and timeline rows, but only Month View tests that a form-driven placeholder update does not re-fire the callback.

Failure scenario: The timeline or all-day guard is removed or inverted, causing resource/date changes in the built-in form to invoke the consumer callback repeatedly; the Month View test still passes.

Fix: Mirror the placeholder-update/call-count test for timeline creation and day/week all-day creation, or centralize the invariant as suggested below.

4. 🟡 The public discriminated union has no type-level test

Location: packages/x-scheduler-internals/src/internals/utils/SchedulerStore/SchedulerStore.types.ts:506

export type SchedulerEventEditingStartEventDetails =
  | BaseUIChangeEventDetails<'edit', { occurrence: SchedulerEventOccurrence }>
  | BaseUIChangeEventDetails<'creation', { occurrence: SchedulerEventOccurrencePlaceholder }>;

Runtime tests mostly use any and do not verify reason-based narrowing or that the type remains importable from @mui/x-scheduler/models.

Failure scenario: The occurrence is widened to the common renderable type or its public export disappears, breaking the documented TypeScript ergonomics without failing behavior tests.

Fix: Add a compile-only public API test that imports the type and verifies branch-specific fields after narrowing on reason.

Simplifications (2)

1. 🟠 Centralize the one-shot editing invariant in the store

Location: packages/x-scheduler-internals/src/internals/utils/SchedulerStore/SchedulerStore.ts:945

public startEditing = (...) => {
  if (mode === 'edit' && !this.requestEditingStart(occurrence, event)) {
    return false;
  }
  this.set('editingOccurrence', { occurrence, mode });
  return true;
};

Preventing repeat callbacks is currently duplicated across four renderers through isEditingPlaceholder subscriptions. Timeline rows and calendar cells consequently perform extra selector work on every store notification, and every future creation renderer must remember the same guard.

Failure scenario: A new renderer calls startEditing after placeholder identity churn without copying the guard, reintroducing duplicate consumer callbacks.

Fix: Make startEditing idempotent when the same occurrence is already in edit mode, while still refreshing the stored occurrence, then remove the renderer-level subscriptions.

2. 🟠 Populate the public eventDetails.trigger

Location: packages/x-scheduler-internals/src/internals/utils/SchedulerStore/SchedulerStore.ts:923

createChangeEventDetails('creation', event, undefined, { occurrence });
createChangeEventDetails('edit', event, undefined, { occurrence });

The third argument is the trigger element, but it is always undefined. The public Base UI details type exposes trigger, and the scheduler already has the occurrence anchor or toolbar button.

Failure scenario: A consumer replacing the built-in dialog with an anchored popover reads eventDetails.trigger and cannot position it without querying or reconstructing the originating element.

Fix: Thread the event anchor/current target through startEditing, setEditingMode, and requestEditingStart, then pass it to createChangeEventDetails.

Docs (0)

No findings.

Verdict

Request changes - the new callback currently treats view-only activations as editable transitions.


🤖 Review generated with Codex

rita-codes and others added 8 commits August 19, 2026 16:13
…ditingStart

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kCFymKqjChrc3azSzoh4Z
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kCFymKqjChrc3azSzoh4Z
@rita-codes

Copy link
Copy Markdown
Member Author

All applied in 2c76328 through dc7aebe:

  • Read-only activations: now reported as reason: 'view', resolved in requestEditingStart through the same effective policy as the dialog (event, resource, root readOnly), so the union gains a third branch carrying the persisted occurrence. Docs teach an early return on 'view' (keeping the built-in view-only dialog) unless the app renders its own read-only UI. Tests pin all three sources plus cancellation, on both components.
  • One-shot invariant: centralized as you suggested — startEditing is now a no-op returning true when the occurrence is already open in the surface, and the four renderer guards are gone. It deliberately doesn't refresh the stored occurrence: the creation form reads from the placeholder, so re-setting editingOccurrence would only churn subscribers. Store tests cover no-refire, cancel-then-retry and armed → edit.
  • Timed keyboard creation: now covered end to end through the week view's production creation effect (focus without click, Enter), asserting a single 'creation' call with the native keydown, no dialog, and the draft dropped on cancel.
  • eventDetails.trigger: populated on every path — the occurrence element on direct edits, the toolbar's Edit button, and the creation surface's anchor (the same node the built-in dialog anchors to). Asserted across all entry points.
  • Type-level test: a compile-only spec on the public barrel pins the narrowing of all three reasons, with an active @ts-expect-error as a tripwire if the creation branch's occurrence ever widens.

…wn-edit-ui-instead-of-the-built-in-event-dialog

@flaviendelangle flaviendelangle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Only one bug left 👼

PR review

One merge-blocking public API defect remains. The full CI matrix passes, but no test checks the lifetime of eventDetails.trigger after cancellation.

Bugs (1)

1. 🔴 Canceling can detach the documented custom-popover anchor

Location: packages/x-scheduler-internals/src/internals/utils/SchedulerStore/SchedulerStore.ts:940

this.parameters.onEventEditingStart?.(occurrence, eventDetails);
if (eventDetails.isCanceled) {
  if (isCreation) {
    this.setOccurrencePlaceholder(null);
  }
  return false;
}

Both new guides describe eventDetails.trigger as ready to anchor custom popovers. Several normal cancellation paths unmount that element immediately after the callback:

  • Timeline creation passes its placeholder at EventTimelinePremiumContent.tsx:574; cancellation clears and unmounts it.
  • Armed-toolbar editing passes the Edit button at EventToolbar.tsx:68; cancellation disarms and unmounts the toolbar.
  • A +N more event passes an item inside the source popover; onEditingCanceled={onClose} closes and eventually unmounts it.

React batches the consumer’s state update with this cleanup. The element exists during the callback but is disconnected before a custom Popover or Popper can measure it.

Failure scenario: A consumer calls eventDetails.cancel(), stores eventDetails.trigger as anchorEl, and opens a custom MUI Popover. The anchor is detached after the update, causing an invalid-anchor warning or positioning at the page origin.

Fix: Provide a stable positioning anchor for each path. Use the persistent timeline row, the armed occurrence element, and the original +N more button respectively. If trigger must remain the exact activation source, expose a separate stable anchor property and narrow the claims in both editing guides. Add integration tests that cancel each path and assert the positioning anchor remains isConnected after React commits.

Tests (0)

No findings.

Simplifications (0)

No findings.

Docs (0)

No findings.

Verdict

Request changes - canceled custom-popover flows receive anchors that the scheduler immediately unmounts.


🤖 Review generated with Codex

…-consumers-open-their-own-edit-ui-instead-of-the-built-in-event-dialog

# Conflicts:
#	packages/x-scheduler/src/month-view/tests/MonthView.test.tsx
@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This pull request has conflicts, please resolve those before we can evaluate the pull request.

…starts

Canceling onEventEditingStart can unmount the element passed as
eventDetails.trigger (the armed toolbar's Edit button, an item inside the
"+N more" popover, a timeline creation placeholder), leaving custom popovers
with a detached anchor. Keep trigger as the exact activation source and add
eventDetails.anchor: an element that stays connected after the callback,
sourced per path from the armed occurrence element, the "+N more" button and
the timeline row, defaulting to the trigger everywhere else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH1L6Mpzwk7sUFMXN4iK1s
@github-actions github-actions Bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Aug 20, 2026
@rita-codes

Copy link
Copy Markdown
Member Author

Fixed in 1573c31 — I went with the separate stable property rather than re-pointing trigger.

eventDetails now exposes anchor: an element guaranteed to still be connected after a cancellation. Per path it's the armed occurrence element (toolbar Edit), the "+N more" button (popover items), and the persistent timeline row (creation placeholder); everywhere else it defaults to the trigger, so stable-trigger flows are unchanged.

trigger keeps the Base UI semantics of "the exact element that was activated", with no lifetime guarantee — re-pointing it at a parent row/button would have silently broken consumers using it to restore focus or branch on what was clicked. This also matches how Base UI itself splits the concepts (eventDetails.trigger vs the Positioner's anchor), and the internal AnchoredEventToolbar already had the same anchor distinction.

Both guides now tell consumers to anchor custom popovers to eventDetails.anchor and warn that trigger can unmount after a canceled activation. Added integration tests for each path that cancel and assert the anchor remains isConnected after React commits, plus store-level tests for the trigger/anchor forwarding.

…wn-edit-ui-instead-of-the-built-in-event-dialog

@flaviendelangle flaviendelangle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's fix this bug and just merge 👀

PR review

One merge-blocking issue remains in the coarse-pointer "+N more" flow. The code trace confirms it; 203 targeted tests passed, though the detached-worktree setup could not load the MonthView suite because its package-local react-transition-group link was unavailable.

Bugs (1)

1. 🔴 Stable anchor is lost while an occurrence is armed

Location: packages/x-scheduler/src/internals/components/event-editing/EventEditingContext.tsx:83

const started = store.startEditing(
  occurrence,
  getInitialEditingMode(surface, { isCreating, isReadOnly }),
  event,
  forwardedAnchorRef.current ?? undefined,
  stableAnchor ?? undefined,
);

if (started) {
  setAnchor(forwardedAnchorRef.current);
}

On a coarse pointer, selecting an event from "+N more" enters armed mode, so store.startEditing does not call requestEditingStart and discards stableAnchor. The context then retains only the transient popover item. When toolbar Edit later invokes the callback, EventToolbar supplies that transient item as eventDetails.anchor. Cancellation closes the source popover and detaches it, breaking the documented stable-anchor contract.

Failure scenario: Open "+N more" on a touch device, tap an event, tap Edit, then cancel onEventEditingStart and open a custom Popover against eventDetails.anchor. Its anchor detaches when the source popover closes, so the custom UI may jump or warn about an invalid anchor.

Fix: Retain a separate callback anchor across the armed state and pass it from EventToolbar to setEditingMode. Keep the transient item separately for positioning the built-in toolbar.

Tests (1)

1. 🟠 The popover test skips the armed transition

Location: packages/x-scheduler/src/month-view/tests/MonthView.test.tsx:206

const firstEventButton = within(popover).getAllByRole('button')[0];
await user.click(firstEventButton);

This test uses the default fine-pointer path, where the callback fires immediately. It does not cover the coarse-pointer sequence that loses the stable anchor.

Failure scenario: The direct cancellation test passes while "+N more" → arm → toolbar Edit returns a detached anchor.

Fix: Add a coarse-pointer test that opens "+N more", arms an event, clicks toolbar Edit, cancels, waits for the popover to exit, and asserts that eventDetails.anchor is the connected "+N more" button.

Simplifications (0)

No findings.

Docs (0)

No findings.

Verdict

Request changes - the new public stable-anchor contract fails in a normal touch interaction.


🤖 Review generated with Codex

rita-codes and others added 2 commits August 21, 2026 09:00
On a coarse pointer an activation arms instead of opening the surface, so
startEditing skips onEventEditingStart and dropped the activation's
stableAnchor. The toolbar's Edit then forwarded the transient popover item
as eventDetails.anchor, which a cancellation unmounts. Keep the activation's
stable anchor in the editing context and let the toolbar forward it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH1L6Mpzwk7sUFMXN4iK1s
…-their-own-edit-ui-instead-of-the-built-in-event-dialog' into 23329-scheduler-let-consumers-open-their-own-edit-ui-instead-of-the-built-in-event-dialog
@rita-codes
rita-codes merged commit 52b497d into mui:master Aug 21, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: scheduler Changes related to the scheduler. type: new feature Expand the scope of the product to solve a new problem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[scheduler] Let consumers open their own edit UI instead of the built-in event dialog

2 participants