Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/pluggableWidgets/calendar-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions packages/pluggableWidgets/calendar-web/src/Calendar.xml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,14 @@
<caption>Show all events</caption>
<description>Auto-adjust calendar height to display all events without "more" links</description>
</property>
<property key="step" type="integer" defaultValue="30">
<caption>Step</caption>
<description>Determines the selectable time increments in week and day views</description>
</property>
<property key="timeslots" type="integer" defaultValue="1">
<caption>Time slots</caption>
<description>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</description>
</property>
</propertyGroup>
</propertyGroup>
<propertyGroup caption="Custom view">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -19,6 +20,8 @@ jest.mock("react-big-calendar", () => {
min,
max,
events,
step,
timeslots,
...domProps
}: any) => (
<div
Expand All @@ -31,6 +34,8 @@ jest.mock("react-big-calendar", () => {
data-min={min?.toISOString()}
data-max={max?.toISOString()}
data-events-count={events?.length ?? 0}
data-step={step}
data-timeslots={timeslots}
{...domProps}
>
{children}
Expand Down Expand Up @@ -86,6 +91,8 @@ const customViewProps: CalendarContainerProps = {
customViewShowFriday: true,
customViewShowSaturday: false,
showAllEvents: true,
step: 60,
timeslots: 2,
toolbarItems: [],
topBarDateFormat: undefined
};
Expand Down Expand Up @@ -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(<MxCalendar {...customViewProps} />);
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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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]"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CalendarEvent>,
"onSelectEvent" | "onDoubleClickEvent" | "onKeyPressEvent" | "onSelectSlot" | "onNavigate" | "selected"
| "onSelectEvent"
| "onDoubleClickEvent"
| "onKeyPressEvent"
| "onSelectSlot"
| "onNavigate"
| "selected"
| "onRangeChange"
> & {
onEventDrop: (event: EventDropOrResize) => void;
onEventResize: (event: EventDropOrResize) => void;
Expand Down Expand Up @@ -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<string | undefined>(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
});
}
},
Expand All @@ -152,7 +169,8 @@ export function useCalendarEvents(props: CalendarContainerProps): CalendarEventH
onSelectSlot: handleCreateEvent,
onEventDrop: handleEventDropOrResize,
onEventResize: handleEventDropOrResize,
onNavigate: handleRangeChange,
onNavigate: handleNavigate,
onRangeChange: handleRangeChange,
selected
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<CalendarEvent>);

Expand Down Expand Up @@ -90,23 +68,6 @@ export function getRange(date: Date, visibleDays: Set<number>): 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ export interface CalendarContainerProps {
minHour: number;
maxHour: number;
showAllEvents: boolean;
step: number;
timeslots: number;
toolbarItems: ToolbarItemsType[];
customViewShowMonday: boolean;
customViewShowTuesday: boolean;
Expand Down Expand Up @@ -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;
Expand Down