Skip to content
4 changes: 4 additions & 0 deletions docs/data/scheduler/event-calendar/events/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@ function Calendar() {

{{"demo": "TitleProperty.js", "bg": "inline", "defaultCodeOpen": false}}

A property declared with a `getter` but no `setter` is read-only: the Event Calendar can display it but never writes it back to your model.

When `start` or `end` is read-only, the event's dates cannot move at all—it can't be dragged or resized, a cut event can't be pasted onto another date, and `updateEvent()` ignores the new dates and updates the rest of the properties.

## Event constraints 🚧

:::warning
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
SchedulerRenderableEventOccurrence,
SchedulerEventOccurrence,
SchedulerEventOccurrencePlaceholder,
SchedulerProcessedEvent,
} from '../../../models';
import type {
SchedulerState,
Expand Down Expand Up @@ -577,6 +578,51 @@ export class SchedulerStore<
return this.updateEvents({ created: [calendarEvent] }).created[0];
};

/**
* Drops `start` and `end` from an update whose event cannot move its dates.
* `eventModelStructure` may declare them with a getter and no setter, in which case the new
* dates cannot be written back to the consumer's model — without this they would be written
* under the built-in key name instead, next to the custom fields, while the real field keeps
* the old value.
* The remaining changes still apply: the event dialog always submits `start` / `end`, even when
* it rendered those fields locked, so refusing the whole update would break a title edit.
*/
private removeImmovableDates(
changes: SchedulerEventUpdatedProperties,
original: SchedulerProcessedEvent,
): SchedulerEventUpdatedProperties {
const hasDateChange = 'start' in changes || 'end' in changes;
if (!hasDateChange || schedulerEventSelectors.canMoveDates(this.state, changes.id)) {
return changes;
}

if (process.env.NODE_ENV !== 'production') {
const { adapter } = this.state;
// The dialog resubmits the unchanged dates on every save, so only a real move is worth a
// warning. Compared as instants, which is independent from the timezone the caller used.
const isMovingDates = (['start', 'end'] as const).some((property) => {
if (!(property in changes)) {
return false;
}
const value = changes[property];
return (
value == null || adapter.getTime(value) !== original.dataTimezone[property].timestamp
);
});

if (isMovingDates) {
warnOnce([
`MUI X Scheduler: The \`start\` and \`end\` dates of the event with id="${String(changes.id)}" were not updated.`,
'`eventModelStructure` declares them with a getter but no setter, so the new dates cannot be written back to your event model and the change was dropped.',
'Add a `setter` to `eventModelStructure.start` and `eventModelStructure.end` to make the dates editable.',
]);
}
}

const { start, end, ...changesWithoutDates } = changes;
return changesWithoutDates;
}

/**
* Updates an event in the calendar.
*/
Expand All @@ -590,19 +636,21 @@ export class SchedulerStore<
);
}

if (this.state.recurringEventsPlugin == null && calendarEvent.rrule != null) {
const changes = this.removeImmovableDates(calendarEvent, original);

if (this.state.recurringEventsPlugin == null && changes.rrule != null) {
if (process.env.NODE_ENV !== 'production') {
warnOnce([
'MUI X Scheduler: Recurring events are a premium feature. The `rrule` property will be ignored.',
'Use <EventCalendarPremium /> or <EventTimelinePremium /> to enable recurring events.',
]);
}
this.updateEvents({ updated: [{ ...calendarEvent, rrule: undefined }] });
this.updateEvents({ updated: [{ ...changes, rrule: undefined }] });
return;
}

this.updateEvents({
updated: [calendarEvent],
updated: [changes],
});
};

Expand Down Expand Up @@ -805,6 +853,22 @@ export class SchedulerStore<
);
}

// A cut moves the original event's dates, a copy writes the whole model — including `start`
// and `end` — into a brand new one. Neither can be represented when `eventModelStructure`
// declares a date with a getter and no setter, so the paste is refused instead of corrupting
// the model. The cut is still allowed when it moves no date (a resource-only paste).
const canMoveDates = schedulerEventSelectors.canMoveDates(this.state, copiedEvent.id);
if (!canMoveDates && (copiedEvent.action === 'copy' || cleanChanges.start != null)) {
if (process.env.NODE_ENV !== 'production') {
warnOnce([
`MUI X Scheduler: The event with id="${String(copiedEvent.id)}" was not pasted.`,
'`eventModelStructure` declares `start` and / or `end` with a getter but no setter, so the pasted dates cannot be written back to your event model.',
'Add a `setter` to `eventModelStructure.start` and `eventModelStructure.end` to make the dates editable.',
]);
}
return null;
}

if (copiedEvent.action === 'cut') {
const updatedEvent = { id: copiedEvent.id, ...cleanChanges };
const result = this.updateEvents({ updated: [updatedEvent] }).updated[0];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,147 @@ storeClasses.forEach((storeClass) => {
expect(duplicated.priority).to.equal('high');
});

describe('dates declared without a setter', () => {
// `start` is readable but not writable, so the store must never move the event's dates:
// there is nowhere to write them, and the built-in key would land next to `myStart`.
const readOnlyStartStructure: SchedulerEventModelStructure<MyEvent> = {
...eventModelStructure,
start: { getter: (event) => event.myStart },
};

const UNCHANGED_EVENT: MyEvent = {
myId: '1',
myTitle: 'Event 1',
myStart: '2025-07-01T09:00:00.000Z',
myEnd: '2025-07-01T10:00:00.000Z',
allDay: false,
};

const UPDATE_WARNING =
'MUI X Scheduler: The `start` and `end` dates of the event with id="1" were not updated.';
const PASTE_WARNING = 'MUI X Scheduler: The event with id="1" was not pasted.';

const createStore = (onEventsChange: (...args: any[]) => void) =>
new storeClass.Value(
{
resources: TEST_RESOURCES,
events: [{ ...UNCHANGED_EVENT }],
eventModelStructure: readOnlyStartStructure,
onEventsChange,
},
adapter,
);

it('should drop start/end from updateEvent and never write the built-in keys', () => {
const onEventsChange = vi.fn();
const store = createStore(onEventsChange);

expect(() => {
store.updateEvent({
id: '1',
title: 'Event 1 updated',
start: adapter.date('2025-07-02T09:00:00.000Z', 'default'),
end: adapter.date('2025-07-02T10:00:00.000Z', 'default'),
});
}).toWarnDev([UPDATE_WARNING]);

expect(onEventsChange.mock.calls.length).to.equal(1);
expect(onEventsChange.mock.lastCall?.[0]).to.deep.equal([
{ ...UNCHANGED_EVENT, myTitle: 'Event 1 updated' },
]);
});

// The event dialog resubmits the locked dates unchanged on every save.
it('should not warn when updateEvent resubmits the current dates', () => {
const onEventsChange = vi.fn();
const store = createStore(onEventsChange);

expect(() => {
store.updateEvent({
id: '1',
title: 'Event 1 updated',
start: adapter.date('2025-07-01T09:00:00.000Z', 'default'),
end: adapter.date('2025-07-01T10:00:00.000Z', 'default'),
});
}).not.toWarnDev();

expect(onEventsChange.mock.lastCall?.[0]).to.deep.equal([
{ ...UNCHANGED_EVENT, myTitle: 'Event 1 updated' },
]);
});

// The dates are compared as instants: 11:00 in Paris is the stored 09:00Z, not a move.
it('should not warn when the resubmitted dates use another timezone', () => {
const store = createStore(vi.fn());

expect(() => {
store.updateEvent({
id: '1',
title: 'Event 1 updated',
start: adapter.date('2025-07-01T11:00:00.000Z', 'Europe/Paris'),
end: adapter.date('2025-07-01T12:00:00.000Z', 'Europe/Paris'),
});
}).not.toWarnDev();
});

it('should refuse to paste a cut event onto a new date and keep it in the clipboard', () => {
const onEventsChange = vi.fn();
const store = createStore(onEventsChange);
store.cutEvent('1');

expect(() => {
expect(
store.pasteEvent({ start: adapter.date('2025-07-02T09:00:00.000Z', 'default') }),
).to.equal(null);
}).toWarnDev([PASTE_WARNING]);

expect(onEventsChange.mock.calls.length).to.equal(0);
expect(store.state.copiedEvent).to.deep.equal({ id: '1', action: 'cut' });
});

it('should still paste a cut event when the paste moves no date', () => {
const onEventsChange = vi.fn();
const store = createStore(onEventsChange);
store.cutEvent('1');

expect(() => {
store.pasteEvent({ allDay: true });
}).not.toWarnDev();

expect(onEventsChange.mock.calls.length).to.equal(1);
expect(onEventsChange.mock.lastCall?.[0]).to.deep.equal([
{ ...UNCHANGED_EVENT, allDay: true },
]);
});

// A copy writes the whole model into a new event, dates included, so it is always refused.
it('should refuse to paste a copied event onto a new date', () => {
const onEventsChange = vi.fn();
const store = createStore(onEventsChange);
store.copyEvent('1');

expect(() => {
expect(
store.pasteEvent({ start: adapter.date('2025-07-02T09:00:00.000Z', 'default') }),
).to.equal(null);
}).toWarnDev([PASTE_WARNING]);

expect(onEventsChange.mock.calls.length).to.equal(0);
});

it('should refuse to paste a copied event even when the paste moves no date', () => {
const onEventsChange = vi.fn();
const store = createStore(onEventsChange);
store.copyEvent('1');

expect(() => {
expect(store.pasteEvent({ allDay: true })).to.equal(null);
}).toWarnDev([PASTE_WARNING]);

expect(onEventsChange.mock.calls.length).to.equal(0);
});
});

it('should only re-compute event models affected by updated processing parameters', () => {
interface MyEvent2 {
myId: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,8 @@ describe('schedulerEventSelectors', () => {
expect(schedulerEventSelectors.isResizable(state, defaultEvent.id, 'end')).to.equal(false);
});

it('should return false for the "start" side when the event start property is read-only', () => {
// A resize commits both dates, so a getter-only `start` blocks the "end" handle as well.
it('should return false for both sides when the event start property is read-only', () => {
const state = getEventCalendarStateFromParameters({
events: [defaultEvent],
areEventsResizable: true,
Expand All @@ -384,18 +385,18 @@ describe('schedulerEventSelectors', () => {
},
});
expect(schedulerEventSelectors.isResizable(state, defaultEvent.id, 'start')).to.equal(false);
expect(schedulerEventSelectors.isResizable(state, defaultEvent.id, 'end')).to.equal(true);
expect(schedulerEventSelectors.isResizable(state, defaultEvent.id, 'end')).to.equal(false);
});

it('should return false for the "end" side when the event end property is read-only', () => {
it('should return false for both sides when the event end property is read-only', () => {
const state = getEventCalendarStateFromParameters({
events: [defaultEvent],
areEventsResizable: true,
eventModelStructure: {
end: { getter: (event) => event.end },
},
});
expect(schedulerEventSelectors.isResizable(state, defaultEvent.id, 'start')).to.equal(true);
expect(schedulerEventSelectors.isResizable(state, defaultEvent.id, 'start')).to.equal(false);
expect(schedulerEventSelectors.isResizable(state, defaultEvent.id, 'end')).to.equal(false);
});

Expand Down Expand Up @@ -875,4 +876,86 @@ describe('schedulerEventSelectors', () => {
expect(schedulerEventSelectors.isReadOnly(state, event.id)).to.equal(true);
});
});

describe('canMoveDates', () => {
it('should return true by default', () => {
const state = getEventCalendarStateFromParameters({
events: [defaultEvent],
});
expect(schedulerEventSelectors.canMoveDates(state, defaultEvent.id)).to.equal(true);
});

it('should return false when the event is read-only', () => {
const state = getEventCalendarStateFromParameters({
events: [readOnlyEvent],
});
expect(schedulerEventSelectors.canMoveDates(state, readOnlyEvent.id)).to.equal(false);
});

it('should return false when resource.areEventsReadOnly is true', () => {
const resource = ResourceBuilder.new().areEventsReadOnly().build();
const event = EventBuilder.new().resource(resource).build();
const state = getEventCalendarStateFromParameters({
events: [event],
resources: [resource],
});
expect(schedulerEventSelectors.canMoveDates(state, event.id)).to.equal(false);
});

it('should return false when the calendar is read-only', () => {
const state = getEventCalendarStateFromParameters({
events: [defaultEvent],
readOnly: true,
});
expect(schedulerEventSelectors.canMoveDates(state, defaultEvent.id)).to.equal(false);
});

it('should return false when the start property is declared without a setter', () => {
const state = getEventCalendarStateFromParameters({
events: [defaultEvent],
eventModelStructure: {
start: { getter: (event) => event.start },
},
});
expect(schedulerEventSelectors.canMoveDates(state, defaultEvent.id)).to.equal(false);
});

it('should return false when the end property is declared without a setter', () => {
const state = getEventCalendarStateFromParameters({
events: [defaultEvent],
eventModelStructure: {
end: { getter: (event) => event.end },
},
});
expect(schedulerEventSelectors.canMoveDates(state, defaultEvent.id)).to.equal(false);
});

it('should return true when both date properties declare a setter', () => {
const state = getEventCalendarStateFromParameters({
events: [defaultEvent],
eventModelStructure: {
start: {
getter: (event) => event.start,
setter: (event, value) => ({ ...event, start: value }),
},
end: {
getter: (event) => event.end,
setter: (event, value) => ({ ...event, end: value }),
},
},
});
expect(schedulerEventSelectors.canMoveDates(state, defaultEvent.id)).to.equal(true);
});

// An absent key means "read and write the built-in property", not "read-only".
it('should return true when the structure does not declare the date properties', () => {
const state = getEventCalendarStateFromParameters({
events: [defaultEvent],
eventModelStructure: {
title: { getter: (event) => event.title },
},
});
expect(schedulerEventSelectors.canMoveDates(state, defaultEvent.id)).to.equal(true);
});
});
});
Loading
Loading