Skip to content

Commit 7f8ac32

Browse files
authored
[scheduler] Add an option to render the empty days in the agenda view (#20045)
1 parent 11b81af commit 7f8ac32

File tree

28 files changed

+539
-123
lines changed

28 files changed

+539
-123
lines changed

docs/data/scheduler/event-calendar/event-calendar.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ Available properties:
171171
- `toggleWeekendVisibility`: show/hide the menu item that toggles weekend visibility.
172172
- `toggleWeekNumberVisibility`: show/hide the menu item that toggles week number visibility.
173173
- `toggleAmpm`: show/hide the menu item that toggles 12/24‑hour time format.
174+
- `toggleEmptyDaysInAgenda`: show/hide the menu item that toggles the visibility of days with no events in the Agenda view.
174175

175176
```ts
176177
// will hide the menu

packages/x-scheduler-headless/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"./timeline": "./src/timeline/index.ts",
2525
"./timeline-provider": "./src/timeline-provider/index.ts",
2626
"./use-adapter": "./src/use-adapter/index.ts",
27+
"./use-agenda-event-occurrences-grouped-by-day": "./src/use-agenda-event-occurrences-grouped-by-day/index.ts",
2728
"./use-day-list": "./src/use-day-list/index.ts",
2829
"./use-event-calendar": "./src/use-event-calendar/index.ts",
2930
"./use-event-calendar-store-context": "./src/use-event-calendar-store-context/index.ts",

packages/x-scheduler-headless/src/constants/constants.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,13 @@ export const SCHEDULER_RECURRING_EDITING_SCOPE: RecurringEventUpdateScope =
1313
export const EVENT_DRAG_PRECISION_MINUTE = 15;
1414

1515
export const EVENT_DRAG_PRECISION_MS = EVENT_DRAG_PRECISION_MINUTE * 60 * 1000;
16+
17+
/**
18+
* Maximum number of days the Agenda view is allowed to scan forward
19+
* when looking for event occurrences.
20+
* This acts as a hard limit to prevent excessive iteration
21+
*/
22+
export const AGENDA_MAX_HORIZON_DAYS = 180;
23+
24+
// TODO: Create a prop to allow users to customize the number of days in agenda view
25+
export const AGENDA_VIEW_DAYS_AMOUNT = 12;

packages/x-scheduler-headless/src/models/preferences.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export interface EventCalendarPreferences extends SchedulerPreferences {
2222
* @default true
2323
*/
2424
isSidePanelOpen: boolean;
25+
/**
26+
* Whether empty days are shown in the agenda view.
27+
* @default true
28+
*/
29+
showEmptyDaysInAgenda: boolean;
2530
}
2631

2732
export interface TimelinePreferences extends SchedulerPreferences {}
@@ -42,4 +47,9 @@ export interface EventCalendarPreferencesMenuConfig {
4247
* @default false
4348
*/
4449
toggleAmpm: boolean;
50+
/**
51+
* Whether the menu item to toggle empty days in agenda view is visible.
52+
* @default true
53+
*/
54+
toggleEmptyDaysInAgenda: boolean;
4555
}

packages/x-scheduler-headless/src/standalone-event/StandaloneEvent.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/elem
55
import { useEventCallback } from '@base-ui-components/utils/useEventCallback';
66
import { useButton } from '../base-ui-copy/utils/useButton';
77
import { useRenderElement } from '../base-ui-copy/utils/useRenderElement';
8-
import { BaseUIComponentProps } from '../base-ui-copy/utils/types';
8+
import { BaseUIComponentProps, NonNativeButtonProps } from '../base-ui-copy/utils/types';
99
import { CalendarOccurrencePlaceholderExternalDragData } from '../models';
1010
import { useDragPreview } from '../utils/useDragPreview';
1111

@@ -30,7 +30,10 @@ export const StandaloneEvent = React.forwardRef(function StandaloneEvent(
3030
const isInteractive = true;
3131

3232
const ref = React.useRef<HTMLDivElement>(null);
33-
const { getButtonProps, buttonRef } = useButton({ disabled: !isInteractive });
33+
const { getButtonProps, buttonRef } = useButton({
34+
disabled: !isInteractive,
35+
native: false,
36+
});
3437

3538
const preview = useDragPreview({
3639
type: 'standalone-event',
@@ -95,6 +98,7 @@ export namespace StandaloneEvent {
9598

9699
export interface Props
97100
extends BaseUIComponentProps<'div', State>,
101+
NonNativeButtonProps,
98102
Pick<useDragPreview.Parameters, 'renderDragPreview' | 'data'> {
99103
/**
100104
* Callback fired when the event is dropped into the Event Calendar.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './useAgendaEventOccurrencesGroupedByDay';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import * as React from 'react';
2+
import { renderHook } from '@mui/internal-test-utils';
3+
import { adapter } from 'test/utils/scheduler';
4+
import { processDate } from '../process-date';
5+
import { CalendarEvent, SchedulerValidDate } from '../models';
6+
import {
7+
useAgendaEventOccurrencesGroupedByDay,
8+
useAgendaEventOccurrencesGroupedByDayOptions,
9+
} from './useAgendaEventOccurrencesGroupedByDay';
10+
import { EventCalendarProvider } from '../event-calendar-provider/EventCalendarProvider';
11+
import { getIdsFromOccurrencesMap } from '../utils/SchedulerStore/tests/utils';
12+
import { AGENDA_VIEW_DAYS_AMOUNT } from '../constants';
13+
14+
describe('useAgendaEventOccurrencesGroupedByDay', () => {
15+
const createEvent = (
16+
id: string,
17+
startISO: string,
18+
endISO: string,
19+
extra: Partial<CalendarEvent> = {},
20+
): CalendarEvent => ({
21+
id,
22+
start: adapter.date(startISO),
23+
end: adapter.date(endISO),
24+
title: `Event ${id}`,
25+
...extra,
26+
});
27+
28+
function testHook({
29+
events = [],
30+
visibleDate,
31+
showWeekends,
32+
showEmptyDaysInAgenda,
33+
}: {
34+
events?: CalendarEvent[];
35+
visibleDate: SchedulerValidDate;
36+
showWeekends: boolean;
37+
showEmptyDaysInAgenda: boolean;
38+
}): useAgendaEventOccurrencesGroupedByDayOptions.ReturnValue {
39+
const wrapper = ({ children }: { children: React.ReactNode }) => (
40+
<EventCalendarProvider
41+
events={events}
42+
resources={[]}
43+
visibleDate={visibleDate}
44+
preferences={{ showWeekends, showEmptyDaysInAgenda }}
45+
>
46+
{children}
47+
</EventCalendarProvider>
48+
);
49+
50+
const { result } = renderHook(() => useAgendaEventOccurrencesGroupedByDay(), { wrapper });
51+
return result.current;
52+
}
53+
54+
it('should return exactly AGENDA_VIEW_DAYS_AMOUNT days and fills occurrences with [] when there are no events and showEmptyDays=true', () => {
55+
const res = testHook({
56+
visibleDate: adapter.date('2024-01-01'),
57+
showWeekends: true,
58+
showEmptyDaysInAgenda: true,
59+
});
60+
61+
expect(res.days).to.have.length(12);
62+
for (const day of res.days) {
63+
expect(res.occurrencesMap.get(day.key)).to.deep.equal([]);
64+
}
65+
});
66+
67+
it('should extend forward until it fills AGENDA_VIEW_DAYS_AMOUNT days that contain events when showEmptyDays=false', () => {
68+
const events: CalendarEvent[] = [
69+
createEvent('1', '2025-01-01', '2025-01-01'),
70+
createEvent('2', '2025-01-03', '2025-01-03'),
71+
createEvent('3', '2025-01-05', '2025-01-05'),
72+
createEvent('4', '2025-01-08', '2025-01-08'),
73+
createEvent('5', '2025-01-09', '2025-01-09'),
74+
createEvent('6', '2025-01-10', '2025-01-10'),
75+
createEvent('7', '2025-01-11', '2025-01-11'),
76+
createEvent('8', '2025-01-13', '2025-01-13'),
77+
createEvent('9', '2025-01-14', '2025-01-14'),
78+
createEvent('10', '2025-01-16', '2025-01-16'),
79+
createEvent('11', '2025-01-18', '2025-01-18'),
80+
createEvent('12', '2025-01-20', '2025-01-20'),
81+
createEvent('13', '2025-01-22', '2025-01-22'),
82+
createEvent('14', '2025-01-24', '2025-01-24'),
83+
];
84+
85+
const res = testHook({
86+
events,
87+
visibleDate: adapter.date('2025-01-01'),
88+
showWeekends: true,
89+
showEmptyDaysInAgenda: false,
90+
});
91+
92+
expect(res.days).to.have.length(AGENDA_VIEW_DAYS_AMOUNT);
93+
const expectedKeys = [
94+
processDate(adapter.date('2025-01-01'), adapter).key,
95+
processDate(adapter.date('2025-01-03'), adapter).key,
96+
processDate(adapter.date('2025-01-05'), adapter).key,
97+
processDate(adapter.date('2025-01-08'), adapter).key,
98+
processDate(adapter.date('2025-01-09'), adapter).key,
99+
processDate(adapter.date('2025-01-10'), adapter).key,
100+
processDate(adapter.date('2025-01-11'), adapter).key,
101+
processDate(adapter.date('2025-01-13'), adapter).key,
102+
processDate(adapter.date('2025-01-14'), adapter).key,
103+
processDate(adapter.date('2025-01-16'), adapter).key,
104+
processDate(adapter.date('2025-01-18'), adapter).key,
105+
processDate(adapter.date('2025-01-20'), adapter).key,
106+
];
107+
expect(res.days.map((day) => day.key)).to.deep.equal(expectedKeys);
108+
for (const day of res.days) {
109+
expect((res.occurrencesMap.get(day.key) ?? []).length).to.greaterThan(0);
110+
}
111+
});
112+
113+
it('should respect showWeekends preference when building the day list', () => {
114+
const events: CalendarEvent[] = [
115+
createEvent('1', '2025-10-03', '2025-10-03'), // Fri
116+
createEvent('2', '2025-10-04', '2025-10-04'), // Sat
117+
createEvent('3', '2025-10-05', '2025-10-05'), // Sun
118+
createEvent('4', '2025-10-06', '2025-10-06'), // Mon
119+
createEvent('5', '2025-10-07', '2025-10-07'), // Tue
120+
createEvent('6', '2025-10-08', '2025-10-08'), // Wed
121+
createEvent('7', '2025-10-09', '2025-10-09'), // Thu
122+
createEvent('8', '2025-10-10', '2025-10-10'), // Fri
123+
createEvent('9', '2025-10-11', '2025-10-11'), // Sat
124+
createEvent('10', '2025-10-12', '2025-10-12'), // Sun
125+
createEvent('11', '2025-10-13', '2025-10-13'), // Mon
126+
createEvent('12', '2025-10-14', '2025-10-14'), // Tue
127+
createEvent('13', '2025-10-15', '2025-10-15'), // Wed
128+
createEvent('14', '2025-10-16', '2025-10-16'), // Thu
129+
createEvent('15', '2025-10-17', '2025-10-17'), // Fri
130+
createEvent('16', '2025-10-18', '2025-10-18'), // Sat
131+
createEvent('17', '2025-10-19', '2025-10-19'), // Sun
132+
createEvent('18', '2025-10-20', '2025-10-20'), // Mon
133+
];
134+
135+
const res = testHook({
136+
events,
137+
visibleDate: adapter.date('2025-10-03'), // Friday
138+
showWeekends: false,
139+
showEmptyDaysInAgenda: true,
140+
});
141+
expect(res.days).to.have.length(12);
142+
const weekendIds = ['2', '3', '9', '10', '16', '17'];
143+
const includedIds = getIdsFromOccurrencesMap(res.occurrencesMap);
144+
for (const id of weekendIds) {
145+
expect(includedIds).to.not.include(id);
146+
}
147+
});
148+
});
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
import { useStore } from '@base-ui-components/utils/store';
5+
import { useAdapter, diffIn } from '../use-adapter/useAdapter';
6+
import { useEventCalendarStoreContext } from '../use-event-calendar-store-context';
7+
import { selectors } from '../use-event-calendar';
8+
import { useDayList } from '../use-day-list';
9+
import { CalendarProcessedDate, CalendarEventOccurrence } from '../models';
10+
import { innerGetEventOccurrencesGroupedByDay } from '../use-event-occurrences-grouped-by-day';
11+
import { AGENDA_VIEW_DAYS_AMOUNT, AGENDA_MAX_HORIZON_DAYS } from '../constants';
12+
13+
/**
14+
* Agenda-specific hook that:
15+
* - Builds the day list starting at `date`
16+
* - Groups event occurrences by day
17+
* - If `showEmptyDays` is false, extends the range forward until it fills `amount` days that contain events (up to a horizon limit)
18+
*/
19+
export function useAgendaEventOccurrencesGroupedByDay(): useAgendaEventOccurrencesGroupedByDayOptions.ReturnValue {
20+
const adapter = useAdapter();
21+
const store = useEventCalendarStoreContext();
22+
23+
const getDayList = useDayList();
24+
25+
const events = useStore(store, selectors.events);
26+
const visibleDate = useStore(store, selectors.visibleDate);
27+
const showWeekends = useStore(store, selectors.showWeekends);
28+
const showEmptyDays = useStore(store, selectors.showEmptyDaysInAgenda);
29+
const visibleResources = useStore(store, selectors.visibleResourcesMap);
30+
31+
const amount = AGENDA_VIEW_DAYS_AMOUNT;
32+
33+
return React.useMemo(() => {
34+
if (process.env.NODE_ENV !== 'production') {
35+
if (amount <= 0) {
36+
throw new Error(
37+
`useAgendaEventOccurrencesGroupedByDay: The 'amount' parameter must be a positive number, but received ${amount}.`,
38+
);
39+
}
40+
}
41+
42+
// 1) First chunk of days
43+
let accumulatedDays = getDayList({
44+
date: visibleDate,
45+
amount,
46+
excludeWeekends: !showWeekends,
47+
});
48+
49+
// Compute occurrences for the current accumulated range
50+
let occurrenceMap = innerGetEventOccurrencesGroupedByDay(
51+
adapter,
52+
accumulatedDays,
53+
events,
54+
visibleResources,
55+
);
56+
57+
const hasEvents = (day: CalendarProcessedDate) => (occurrenceMap.get(day.key)?.length ?? 0) > 0;
58+
59+
// 2) If we show empty days, just return the amount days
60+
if (showEmptyDays) {
61+
const finalOccurrences = new Map(
62+
accumulatedDays.map((d) => [d.key, occurrenceMap.get(d.key) ?? []]),
63+
);
64+
return { days: accumulatedDays, occurrencesMap: finalOccurrences };
65+
}
66+
67+
// 3) If we hide empty days, keep extending forward in blocks until we fill `amount` days with events
68+
let daysWithEvents = accumulatedDays.filter(hasEvents).slice(0, amount);
69+
70+
while (daysWithEvents.length < amount) {
71+
// Stop if the calendar span already reaches the horizon
72+
const first = accumulatedDays[0]?.value;
73+
const last = accumulatedDays[accumulatedDays.length - 1]?.value;
74+
75+
if (first && last) {
76+
const spanDays =
77+
diffIn(adapter, adapter.startOfDay(last), adapter.startOfDay(first), 'days') + 1;
78+
79+
// Hard stop to avoid scanning too far into the future
80+
if (spanDays >= AGENDA_MAX_HORIZON_DAYS) {
81+
break;
82+
}
83+
}
84+
85+
// Extend forward by one more chunk and recompute occurrences over the accumulated range
86+
const nextStart = adapter.addDays(
87+
accumulatedDays[accumulatedDays.length - 1]?.value ?? visibleDate,
88+
1,
89+
);
90+
91+
const more = getDayList({
92+
date: nextStart,
93+
amount,
94+
excludeWeekends: !showWeekends,
95+
});
96+
97+
accumulatedDays = accumulatedDays.concat(more);
98+
99+
occurrenceMap = innerGetEventOccurrencesGroupedByDay(
100+
adapter,
101+
accumulatedDays,
102+
events,
103+
visibleResources,
104+
);
105+
106+
daysWithEvents = accumulatedDays.filter(hasEvents).slice(0, amount);
107+
}
108+
109+
// Keep occurrences only for the final visible days
110+
const filledKeys = new Set(daysWithEvents.map((d) => d.key));
111+
const finalOccurrences = new Map([...occurrenceMap].filter(([key]) => filledKeys.has(key)));
112+
113+
return { days: daysWithEvents, occurrencesMap: finalOccurrences };
114+
}, [
115+
getDayList,
116+
visibleDate,
117+
amount,
118+
showWeekends,
119+
adapter,
120+
events,
121+
visibleResources,
122+
showEmptyDays,
123+
]);
124+
}
125+
126+
export namespace useAgendaEventOccurrencesGroupedByDayOptions {
127+
export type ReturnValue = {
128+
/**
129+
* Final visible days in the agenda.
130+
*/
131+
days: CalendarProcessedDate[];
132+
/**
133+
* The occurrences Map as returned by `useEventOccurrences()`.
134+
* It should contain the occurrences for each requested day but can also contain occurrences for other days.
135+
*/
136+
occurrencesMap: Map<string, CalendarEventOccurrence[]>;
137+
};
138+
}

packages/x-scheduler-headless/src/use-event-calendar/EventCalendarStore.selectors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const selectors = {
1212
ampm: createSelector((state: State) => state.preferences.ampm),
1313
showWeekends: createSelector((state: State) => state.preferences.showWeekends),
1414
showWeekNumber: createSelector((state: State) => state.preferences.showWeekNumber),
15+
showEmptyDaysInAgenda: createSelector((state: State) => state.preferences.showEmptyDaysInAgenda),
1516
hasDayView: createSelector((state: State) => state.views.includes('day')),
1617
isEventDraggable: createSelector(
1718
schedulerSelectors.isEventReadOnly,

packages/x-scheduler-headless/src/use-event-calendar/EventCalendarStore.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ export const DEFAULT_VIEW: CalendarView = 'week';
1515
export const DEFAULT_PREFERENCES: EventCalendarPreferences = {
1616
showWeekends: true,
1717
showWeekNumber: false,
18+
showEmptyDaysInAgenda: true,
1819
isSidePanelOpen: true,
1920
ampm: true,
2021
};
2122
export const DEFAULT_PREFERENCES_MENU_CONFIG: EventCalendarPreferencesMenuConfig = {
2223
toggleWeekendVisibility: true,
2324
toggleWeekNumberVisibility: true,
25+
toggleEmptyDaysInAgenda: true,
2426
toggleAmpm: true,
2527
};
2628

0 commit comments

Comments
 (0)