diff --git a/packages/pluggableWidgets/calendar-web/CHANGELOG.md b/packages/pluggableWidgets/calendar-web/CHANGELOG.md
index 793aefd87e..722f431b1f 100644
--- a/packages/pluggableWidgets/calendar-web/CHANGELOG.md
+++ b/packages/pluggableWidgets/calendar-web/CHANGELOG.md
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased]
+### Added
+
+- We added support for configuring calendar time grid density via timeslots and step properties to control the widget’s required space.
+
+### Fixed
+
+- We fixed an issue where the “On view range changed” event nanoflow did not trigger when switching from Day/Week to Month view, causing Month view to only load events from the last week instead of the full month range.
+
## [2.2.0] - 2025-11-11
### Added
diff --git a/packages/pluggableWidgets/calendar-web/src/Calendar.xml b/packages/pluggableWidgets/calendar-web/src/Calendar.xml
index f9850a7a66..0af2436b56 100644
--- a/packages/pluggableWidgets/calendar-web/src/Calendar.xml
+++ b/packages/pluggableWidgets/calendar-web/src/Calendar.xml
@@ -131,6 +131,14 @@
diff --git a/packages/pluggableWidgets/calendar-web/src/__tests__/Calendar.spec.tsx b/packages/pluggableWidgets/calendar-web/src/__tests__/Calendar.spec.tsx
index 2bd2baeee0..5898ddd82e 100644
--- a/packages/pluggableWidgets/calendar-web/src/__tests__/Calendar.spec.tsx
+++ b/packages/pluggableWidgets/calendar-web/src/__tests__/Calendar.spec.tsx
@@ -3,6 +3,7 @@ import { dynamic, ListValueBuilder } from "@mendix/widget-plugin-test-utils";
import MxCalendar from "../Calendar";
import { CalendarContainerProps } from "../../typings/CalendarProps";
+import { CalendarPropsBuilder } from "../helpers/CalendarPropsBuilder";
// Mock react-big-calendar to avoid View.title issues
jest.mock("react-big-calendar", () => {
@@ -19,6 +20,8 @@ jest.mock("react-big-calendar", () => {
min,
max,
events,
+ step,
+ timeslots,
...domProps
}: any) => (
{
data-min={min?.toISOString()}
data-max={max?.toISOString()}
data-events-count={events?.length ?? 0}
+ data-step={step}
+ data-timeslots={timeslots}
{...domProps}
>
{children}
@@ -86,6 +91,8 @@ const customViewProps: CalendarContainerProps = {
customViewShowFriday: true,
customViewShowSaturday: false,
showAllEvents: true,
+ step: 60,
+ timeslots: 2,
toolbarItems: [],
topBarDateFormat: undefined
};
@@ -122,4 +129,71 @@ describe("Calendar", () => {
// Since we're mocking the calendar, we can't test for specific text content
// but we can verify the component renders without errors
});
+
+ it("passes step and timeslots to the calendar", () => {
+ const { getByTestId } = render();
+ const calendar = getByTestId("mock-calendar");
+ expect(calendar.getAttribute("data-step")).toBe("60");
+ expect(calendar.getAttribute("data-timeslots")).toBe("2");
+ });
+});
+
+describe("CalendarPropsBuilder validation", () => {
+ const mockLocalizer = {
+ format: jest.fn(),
+ parse: jest.fn(),
+ startOfWeek: jest.fn(),
+ getDay: jest.fn(),
+ messages: {}
+ } as any;
+
+ const buildWithStepTimeslots = (step: number, timeslots: number) => {
+ const props = { ...customViewProps, step, timeslots };
+ const builder = new CalendarPropsBuilder(props);
+ return builder.build(mockLocalizer, "en");
+ };
+
+ it("clamps step=0 to 1", () => {
+ const result = buildWithStepTimeslots(0, 2);
+ expect(result.step).toBe(1);
+ expect(result.timeslots).toBe(2);
+ });
+
+ it("clamps negative step to 1", () => {
+ const result = buildWithStepTimeslots(-5, 1);
+ expect(result.step).toBe(1);
+ });
+
+ it("clamps step above 60 to 60", () => {
+ const result = buildWithStepTimeslots(100, 1);
+ expect(result.step).toBe(60);
+ });
+
+ it("clamps timeslots=0 to 1", () => {
+ const result = buildWithStepTimeslots(30, 0);
+ expect(result.timeslots).toBe(1);
+ });
+
+ it("clamps timeslots above 4 to 4", () => {
+ const result = buildWithStepTimeslots(30, 100);
+ expect(result.timeslots).toBe(4);
+ });
+
+ it("preserves boundary values (step=1, timeslots=1)", () => {
+ const result = buildWithStepTimeslots(1, 1);
+ expect(result.step).toBe(1);
+ expect(result.timeslots).toBe(1);
+ });
+
+ it("preserves upper boundary values (step=60, timeslots=4)", () => {
+ const result = buildWithStepTimeslots(60, 4);
+ expect(result.step).toBe(60);
+ expect(result.timeslots).toBe(4);
+ });
+
+ it("accepts valid step and timeslots without clamping", () => {
+ const result = buildWithStepTimeslots(30, 2);
+ expect(result.step).toBe(30);
+ expect(result.timeslots).toBe(2);
+ });
});
diff --git a/packages/pluggableWidgets/calendar-web/src/__tests__/__snapshots__/Calendar.spec.tsx.snap b/packages/pluggableWidgets/calendar-web/src/__tests__/__snapshots__/Calendar.spec.tsx.snap
index b7d86299ff..53096ea284 100644
--- a/packages/pluggableWidgets/calendar-web/src/__tests__/__snapshots__/Calendar.spec.tsx.snap
+++ b/packages/pluggableWidgets/calendar-web/src/__tests__/__snapshots__/Calendar.spec.tsx.snap
@@ -15,7 +15,9 @@ exports[`Calendar renders correctly with basic props 1`] = `
data-resizable="true"
data-selectable="true"
data-show-all-events="true"
+ data-step="60"
data-testid="mock-calendar"
+ data-timeslots="2"
formats="[object Object]"
localizer="[object Object]"
messages="[object Object]"
diff --git a/packages/pluggableWidgets/calendar-web/src/helpers/CalendarPropsBuilder.ts b/packages/pluggableWidgets/calendar-web/src/helpers/CalendarPropsBuilder.ts
index 72b492d25f..6d3c119b8e 100644
--- a/packages/pluggableWidgets/calendar-web/src/helpers/CalendarPropsBuilder.ts
+++ b/packages/pluggableWidgets/calendar-web/src/helpers/CalendarPropsBuilder.ts
@@ -14,6 +14,8 @@ export class CalendarPropsBuilder {
private minTime: Date;
private maxTime: Date;
private toolbarItems?: ResolvedToolbarItem[];
+ private step: number;
+ private timeSlots: number;
constructor(private props: CalendarContainerProps) {
this.isCustomView = props.view === "custom";
@@ -23,6 +25,17 @@ export class CalendarPropsBuilder {
this.minTime = this.buildTime(props.minHour ?? 0);
this.maxTime = this.buildTime(props.maxHour ?? 24);
this.toolbarItems = this.buildToolbarItems();
+ this.step = Math.max(1, Math.min(props.step, 60));
+ this.timeSlots = Math.max(1, Math.min(props.timeslots, 4));
+
+ if (props.step !== this.step) {
+ console.warn(`[Calendar] step value ${props.step} was clamped to ${this.step}. Must be between 1 and 60.`);
+ }
+ if (props.timeslots !== this.timeSlots) {
+ console.warn(
+ `[Calendar] timeslots value ${props.timeslots} was clamped to ${this.timeSlots}. Must be between 1 and 4.`
+ );
+ }
}
updateProps(props: CalendarContainerProps): void {
@@ -71,7 +84,9 @@ export class CalendarPropsBuilder {
titleAccessor: (event: CalendarEvent) => event.title,
showAllEvents: this.props.showAllEvents,
min: this.minTime,
- max: this.maxTime
+ max: this.maxTime,
+ step: this.step,
+ timeslots: this.timeSlots
};
}
diff --git a/packages/pluggableWidgets/calendar-web/src/helpers/CustomWeekController.ts b/packages/pluggableWidgets/calendar-web/src/helpers/CustomWeekController.ts
index d27406bbf6..50019444c6 100644
--- a/packages/pluggableWidgets/calendar-web/src/helpers/CustomWeekController.ts
+++ b/packages/pluggableWidgets/calendar-web/src/helpers/CustomWeekController.ts
@@ -7,6 +7,7 @@ import { addWeeks, differenceInCalendarDays, getRange } from "../utils/calendar-
type CustomWeekComponent = ((viewProps: CalendarProps) => ReactElement) & {
navigate: (date: Date, action: NavigateAction) => Date;
title: (date: Date, options: any) => string;
+ range: (date: Date, options?: { localizer?: any }) => Date[];
};
export class CustomWeekController {
@@ -76,6 +77,7 @@ export class CustomWeekController {
Component.navigate = CustomWeekController.navigate;
Component.title = (date: Date, options: any): string =>
CustomWeekController.title(date, options, visibleDays, titlePattern);
+ Component.range = (date: Date): Date[] => getRange(date, visibleDays);
return Component;
}
diff --git a/packages/pluggableWidgets/calendar-web/src/helpers/useCalendarEvents.ts b/packages/pluggableWidgets/calendar-web/src/helpers/useCalendarEvents.ts
index 89e54f88a9..ff514ca641 100644
--- a/packages/pluggableWidgets/calendar-web/src/helpers/useCalendarEvents.ts
+++ b/packages/pluggableWidgets/calendar-web/src/helpers/useCalendarEvents.ts
@@ -1,12 +1,17 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { CalendarEvent, EventDropOrResize } from "../utils/typings";
import { CalendarContainerProps } from "../../typings/CalendarProps";
-import { CalendarProps, NavigateAction } from "react-big-calendar";
-import { getViewRange } from "../utils/calendar-utils";
+import { CalendarProps, NavigateAction, View } from "react-big-calendar";
type CalendarEventHandlers = Pick<
CalendarProps,
- "onSelectEvent" | "onDoubleClickEvent" | "onKeyPressEvent" | "onSelectSlot" | "onNavigate" | "selected"
+ | "onSelectEvent"
+ | "onDoubleClickEvent"
+ | "onKeyPressEvent"
+ | "onSelectSlot"
+ | "onNavigate"
+ | "selected"
+ | "onRangeChange"
> & {
onEventDrop: (event: EventDropOrResize) => void;
onEventResize: (event: EventDropOrResize) => void;
@@ -116,16 +121,28 @@ export function useCalendarEvents(props: CalendarContainerProps): CalendarEventH
[onDragDropResize]
);
+ // Track the current view so we can pass it to onRangeChange.
+ // RBC calls onNavigate (with view) synchronously before onRangeChange (without view)
+ // during navigation, so the ref is always up-to-date when handleRangeChange reads it.
+ const currentViewRef = useRef(undefined);
+
+ const handleNavigate = useCallback((_date: Date, view: string, _action: NavigateAction) => {
+ currentViewRef.current = view;
+ }, []);
+
const handleRangeChange = useCallback(
- (date: Date, view: string, _action: NavigateAction) => {
+ (range: Date[] | { start: Date; end: Date }, view?: View) => {
const action = onViewRangeChange;
if (action?.canExecute) {
- const { start, end } = getViewRange(view, date);
+ const start = Array.isArray(range) ? range[0] : range.start;
+ const end = Array.isArray(range) ? range[range.length - 1] : range.end;
+ const resolvedView = view ?? currentViewRef.current;
+
action.execute({
rangeStart: start,
rangeEnd: end,
- currentView: view
+ currentView: resolvedView
});
}
},
@@ -152,7 +169,8 @@ export function useCalendarEvents(props: CalendarContainerProps): CalendarEventH
onSelectSlot: handleCreateEvent,
onEventDrop: handleEventDropOrResize,
onEventResize: handleEventDropOrResize,
- onNavigate: handleRangeChange,
+ onNavigate: handleNavigate,
+ onRangeChange: handleRangeChange,
selected
};
}
diff --git a/packages/pluggableWidgets/calendar-web/src/utils/calendar-utils.ts b/packages/pluggableWidgets/calendar-web/src/utils/calendar-utils.ts
index 6ef481ce3b..2d1afaa119 100644
--- a/packages/pluggableWidgets/calendar-web/src/utils/calendar-utils.ts
+++ b/packages/pluggableWidgets/calendar-web/src/utils/calendar-utils.ts
@@ -3,18 +3,7 @@ import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop";
import { CalendarEvent } from "./typings";
import "react-big-calendar/lib/addons/dragAndDrop/styles.css";
import "react-big-calendar/lib/css/react-big-calendar.css";
-import {
- addDays,
- addWeeks,
- differenceInCalendarDays,
- endOfMonth,
- endOfWeek,
- format,
- getDay,
- parse,
- startOfMonth,
- startOfWeek
-} from "date-fns";
+import { addDays, addWeeks, differenceInCalendarDays, format, getDay, parse, startOfWeek } from "date-fns";
import type { MXLocaleDates, MXLocaleNumbers, MXLocalePatterns, MXSessionData } from "../../typings/global";
// Utility to lighten hex colors. Accepts #RGB or #RRGGBB.
@@ -41,18 +30,7 @@ function lightenColor(color: string, amount = 0.2): string {
return color;
}
-export {
- format,
- parse,
- startOfWeek,
- getDay,
- addDays,
- startOfMonth,
- endOfMonth,
- endOfWeek,
- addWeeks,
- differenceInCalendarDays
-};
+export { format, parse, startOfWeek, getDay, addDays, addWeeks, differenceInCalendarDays };
export const DnDCalendar = withDragAndDrop(Calendar);
@@ -90,23 +68,6 @@ export function getRange(date: Date, visibleDays: Set): Date[] {
);
}
-export function getViewRange(view: string, date: Date): { start: Date; end: Date } {
- switch (view) {
- case "month":
- return { start: startOfMonth(date), end: endOfMonth(date) };
- case "week":
- return { start: startOfWeek(date), end: endOfWeek(date) };
- case "work_week": {
- const start = startOfWeek(date);
- return { start, end: addDays(start, 4) };
- }
- case "day":
- return { start: date, end: date };
- default:
- return { start: date, end: date };
- }
-}
-
/**
* Converts empty or whitespace-only strings to undefined.
* Useful for handling optional textTemplate values from Mendix.
diff --git a/packages/pluggableWidgets/calendar-web/typings/CalendarProps.d.ts b/packages/pluggableWidgets/calendar-web/typings/CalendarProps.d.ts
index 519f41a392..b0a1628d5e 100644
--- a/packages/pluggableWidgets/calendar-web/typings/CalendarProps.d.ts
+++ b/packages/pluggableWidgets/calendar-web/typings/CalendarProps.d.ts
@@ -90,6 +90,8 @@ export interface CalendarContainerProps {
minHour: number;
maxHour: number;
showAllEvents: boolean;
+ step: number;
+ timeslots: number;
toolbarItems: ToolbarItemsType[];
customViewShowMonday: boolean;
customViewShowTuesday: boolean;
@@ -143,6 +145,8 @@ export interface CalendarPreviewProps {
minHour: number | null;
maxHour: number | null;
showAllEvents: boolean;
+ step: number | null;
+ timeslots: number | null;
toolbarItems: ToolbarItemsPreviewType[];
customViewShowMonday: boolean;
customViewShowTuesday: boolean;