Repository navigation
[scheduler] Add onEventEditingStart to let consumers open their own edit UI - #23361
Conversation
…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
onEventEditingStart to let consumers open their own edit UI
Deploy previewBundle size
Check out the code infra dashboard for more information about this PR. |
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
…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
left a comment
There was a problem hiding this comment.
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 affectedTouch 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
…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
…internals Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LTSbctmFaRK7waP3GRKKrr
|
Applied in a67912c and f759fd8:
On correlating the reason with the occurrence type: the narrowing gap is real, but I'd rather not move away from our |
…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
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
left a comment
There was a problem hiding this comment.
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
…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
|
All applied in 6e01021:
|
flaviendelangle
left a comment
There was a problem hiding this comment.
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
…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
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kCFymKqjChrc3azSzoh4Z
…effect Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kCFymKqjChrc3azSzoh4Z
…el spec Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kCFymKqjChrc3azSzoh4Z
…g UI docs 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
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kCFymKqjChrc3azSzoh4Z
|
All applied in 2c76328 through dc7aebe:
|
…wn-edit-ui-instead-of-the-built-in-event-dialog
flaviendelangle
left a comment
There was a problem hiding this comment.
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 moreevent 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
|
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
|
Fixed in 1573c31 — I went with the separate stable property rather than re-pointing
Both guides now tell consumers to anchor custom popovers to |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GH1L6Mpzwk7sUFMXN4iK1s
…wn-edit-ui-instead-of-the-built-in-event-dialog
flaviendelangle
left a comment
There was a problem hiding this comment.
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
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
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.Design
startEditingresolving 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.eventDetails.reasonis"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 repeatstartEditingcalls 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.readOnly, resourceareEventsReadOnly, or thereadOnlyprop) reportreason: "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.'none'reasons —createChangeEventDetailsis generic over the reason string by design, matching Base UI's own semantic reasons (imperative-action,cancel-open, ...).eventDetailsis typed as a discriminated union:eventDetails.occurrenceis the persisted occurrence on"edit"and"view", and the draft on"creation", so narrowing onreasonworks in plain TS while the callback keeps the(value, eventDetails)convention.eventDetails.eventcarries the initiating native event on every path (the creation flow stashes it with the placeholder, sincestartEditingruns in an effect), andeventDetails.triggercarries 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.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.