From 9103b274a74a7393207ed5f2fa270b4a54c7f291 Mon Sep 17 00:00:00 2001 From: Stefan Keller Date: Mon, 15 Dec 2025 13:04:22 +0100 Subject: [PATCH 1/3] feat(calendar-web): new props step and timeslots and fix for day/week/month view change --- .../calendar-web/src/Calendar.xml | 8 +++++ .../src/__tests__/Calendar.spec.tsx | 2 ++ .../__snapshots__/Calendar.spec.tsx.snap | 2 ++ .../src/helpers/CalendarPropsBuilder.ts | 8 ++++- .../src/helpers/useCalendarEvents.ts | 33 ++++++++++++++++--- .../calendar-web/typings/CalendarProps.d.ts | 4 +++ 6 files changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/pluggableWidgets/calendar-web/src/Calendar.xml b/packages/pluggableWidgets/calendar-web/src/Calendar.xml index f9850a7a66..70f0d23d74 100644 --- a/packages/pluggableWidgets/calendar-web/src/Calendar.xml +++ b/packages/pluggableWidgets/calendar-web/src/Calendar.xml @@ -131,6 +131,14 @@ Show all events Auto-adjust calendar height to display all events without "more" links + + Step + Determines the selectable time increments in week and day views + + + Time slots + The number of slots per "section" in the time grid views. Adjust with step to change the default of 1 hour long groups, with 30 minute slots. + diff --git a/packages/pluggableWidgets/calendar-web/src/__tests__/Calendar.spec.tsx b/packages/pluggableWidgets/calendar-web/src/__tests__/Calendar.spec.tsx index 2bd2baeee0..5ed19a7919 100644 --- a/packages/pluggableWidgets/calendar-web/src/__tests__/Calendar.spec.tsx +++ b/packages/pluggableWidgets/calendar-web/src/__tests__/Calendar.spec.tsx @@ -86,6 +86,8 @@ const customViewProps: CalendarContainerProps = { customViewShowFriday: true, customViewShowSaturday: false, showAllEvents: true, + step: 60, + timeslots: 2, toolbarItems: [], topBarDateFormat: undefined }; 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..5bce2efd12 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 @@ -19,6 +19,8 @@ exports[`Calendar renders correctly with basic props 1`] = ` formats="[object Object]" localizer="[object Object]" messages="[object Object]" + step="60" + timeslots="2" views="[object Object]" /> diff --git a/packages/pluggableWidgets/calendar-web/src/helpers/CalendarPropsBuilder.ts b/packages/pluggableWidgets/calendar-web/src/helpers/CalendarPropsBuilder.ts index 72b492d25f..c896a01ddd 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,8 @@ export class CalendarPropsBuilder { this.minTime = this.buildTime(props.minHour ?? 0); this.maxTime = this.buildTime(props.maxHour ?? 24); this.toolbarItems = this.buildToolbarItems(); + this.step = props.step; + this.timeSlots = props.timeslots; } updateProps(props: CalendarContainerProps): void { @@ -71,7 +75,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/useCalendarEvents.ts b/packages/pluggableWidgets/calendar-web/src/helpers/useCalendarEvents.ts index 89e54f88a9..494aa2d6e3 100644 --- a/packages/pluggableWidgets/calendar-web/src/helpers/useCalendarEvents.ts +++ b/packages/pluggableWidgets/calendar-web/src/helpers/useCalendarEvents.ts @@ -1,12 +1,18 @@ 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 { CalendarProps, NavigateAction, View } from "react-big-calendar"; import { getViewRange } from "../utils/calendar-utils"; 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,7 +122,7 @@ export function useCalendarEvents(props: CalendarContainerProps): CalendarEventH [onDragDropResize] ); - const handleRangeChange = useCallback( + const handleNavigate = useCallback( (date: Date, view: string, _action: NavigateAction) => { const action = onViewRangeChange; @@ -132,6 +138,24 @@ export function useCalendarEvents(props: CalendarContainerProps): CalendarEventH [onViewRangeChange] ); + const handleRangeChange = useCallback( + (range: Date[] | { start: Date; end: Date }, view?: View) => { + const action = onViewRangeChange; + + if (action?.canExecute) { + const start = Array.isArray(range) ? range[0] : range.start; + const end = Array.isArray(range) ? range[range.length - 1] : range.end; + + action.execute({ + rangeStart: start, + rangeEnd: end, + currentView: view + }); + } + }, + [onViewRangeChange] + ); + useEffect(() => { /** * What Is This? @@ -152,7 +176,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/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; From afdb8896bc66d85d1618abb9cf2db844161af606 Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 20 Jan 2026 16:40:21 +0100 Subject: [PATCH 2/3] fix(calendar-web): set current default step and timeslots, update changelog --- packages/pluggableWidgets/calendar-web/CHANGELOG.md | 8 ++++++++ packages/pluggableWidgets/calendar-web/src/Calendar.xml | 6 +++--- 2 files changed, 11 insertions(+), 3 deletions(-) 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 70f0d23d74..0af2436b56 100644 --- a/packages/pluggableWidgets/calendar-web/src/Calendar.xml +++ b/packages/pluggableWidgets/calendar-web/src/Calendar.xml @@ -131,13 +131,13 @@ Show all events Auto-adjust calendar height to display all events without "more" links - + Step Determines the selectable time increments in week and day views - + Time slots - The number of slots per "section" in the time grid views. Adjust with step to change the default of 1 hour long groups, with 30 minute slots. + The number of slots per "section" in the time grid views. Adjust with step to change the default of 1 hour long groups, with 30 minute slots From 1d8b2899f3a2c0c319b6cb2893752f0bcb994ab6 Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 12 Feb 2026 16:02:45 +0100 Subject: [PATCH 3/3] fix(calendar-web): resolve onRangeChange errors, input validation, and currentView tracking --- .../src/__tests__/Calendar.spec.tsx | 72 +++++++++++++++++++ .../__snapshots__/Calendar.spec.tsx.snap | 4 +- .../src/helpers/CalendarPropsBuilder.ts | 13 +++- .../src/helpers/CustomWeekController.ts | 2 + .../src/helpers/useCalendarEvents.ts | 25 +++---- .../calendar-web/src/utils/calendar-utils.ts | 43 +---------- 6 files changed, 98 insertions(+), 61 deletions(-) diff --git a/packages/pluggableWidgets/calendar-web/src/__tests__/Calendar.spec.tsx b/packages/pluggableWidgets/calendar-web/src/__tests__/Calendar.spec.tsx index 5ed19a7919..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} @@ -124,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 5bce2efd12..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,12 +15,12 @@ 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]" - step="60" - timeslots="2" views="[object Object]" />
diff --git a/packages/pluggableWidgets/calendar-web/src/helpers/CalendarPropsBuilder.ts b/packages/pluggableWidgets/calendar-web/src/helpers/CalendarPropsBuilder.ts index c896a01ddd..6d3c119b8e 100644 --- a/packages/pluggableWidgets/calendar-web/src/helpers/CalendarPropsBuilder.ts +++ b/packages/pluggableWidgets/calendar-web/src/helpers/CalendarPropsBuilder.ts @@ -25,8 +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 = props.step; - this.timeSlots = props.timeslots; + 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 { 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 494aa2d6e3..ff514ca641 100644 --- a/packages/pluggableWidgets/calendar-web/src/helpers/useCalendarEvents.ts +++ b/packages/pluggableWidgets/calendar-web/src/helpers/useCalendarEvents.ts @@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { CalendarEvent, EventDropOrResize } from "../utils/typings"; import { CalendarContainerProps } from "../../typings/CalendarProps"; import { CalendarProps, NavigateAction, View } from "react-big-calendar"; -import { getViewRange } from "../utils/calendar-utils"; type CalendarEventHandlers = Pick< CalendarProps, @@ -122,21 +121,14 @@ export function useCalendarEvents(props: CalendarContainerProps): CalendarEventH [onDragDropResize] ); - const handleNavigate = useCallback( - (date: Date, view: string, _action: NavigateAction) => { - const action = onViewRangeChange; + // 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); - if (action?.canExecute) { - const { start, end } = getViewRange(view, date); - action.execute({ - rangeStart: start, - rangeEnd: end, - currentView: view - }); - } - }, - [onViewRangeChange] - ); + const handleNavigate = useCallback((_date: Date, view: string, _action: NavigateAction) => { + currentViewRef.current = view; + }, []); const handleRangeChange = useCallback( (range: Date[] | { start: Date; end: Date }, view?: View) => { @@ -145,11 +137,12 @@ export function useCalendarEvents(props: CalendarContainerProps): CalendarEventH if (action?.canExecute) { 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 }); } }, 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.