Skip to content
Merged
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
4 changes: 4 additions & 0 deletions packages/pluggableWidgets/calendar-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Fixed

- We fixed an issue in Custom view where the "Header day format" was only applied to the toolbar title and not to the day, week, and month column headers.

## [2.4.0] - 2026-03-20

### Fixed
Expand Down
16 changes: 12 additions & 4 deletions packages/pluggableWidgets/calendar-web/src/Calendar.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Fragment, ReactElement, useMemo } from "react";
import { Fragment, ReactElement, useCallback, useMemo, useState } from "react";
import classNames from "classnames";

Check warning on line 2 in packages/pluggableWidgets/calendar-web/src/Calendar.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

`classnames` import should occur before import of `react`
import { View } from "react-big-calendar";
import { CalendarContainerProps } from "../typings/CalendarProps";
import { CalendarPropsBuilder } from "./helpers/CalendarPropsBuilder";
import { DnDCalendar } from "./utils/calendar-utils";
import { constructWrapperStyle } from "./utils/style-utils";
import "./ui/Calendar.scss";
import { useCalendarEvents } from "./helpers/useCalendarEvents";

Check warning on line 9 in packages/pluggableWidgets/calendar-web/src/Calendar.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

`./helpers/useCalendarEvents` import should occur before import of `./utils/calendar-utils`
import { useLocalizer } from "./helpers/useLocalizer";

Check warning on line 10 in packages/pluggableWidgets/calendar-web/src/Calendar.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

`./helpers/useLocalizer` import should occur before import of `./utils/calendar-utils`

export default function MxCalendar(props: CalendarContainerProps): ReactElement {
// useMemo with empty dependency array is used
Expand All @@ -20,10 +21,17 @@
// Get locale-aware localizer
const { localizer, culture } = useLocalizer();

// The calendar is controlled on `view` so the props builder always knows which view is on
// screen — needed to resolve the shared RBC `dayFormat` key (week/work_week/day column
// headers) to that view's own custom pattern instead of a sibling view's. Undefined until
// RBC reports its first view (via defaultView), at which point the builder picks a safe one.
const [activeView, setActiveView] = useState<View | undefined>(undefined);
const handleView = useCallback((view: View) => setActiveView(view), []);

const calendarProps = useMemo(() => {
calendarController.updateProps(props);
return calendarController.build(localizer, culture);
}, [props, calendarController, localizer, culture]);
return calendarController.build(localizer, culture, activeView);
}, [props, calendarController, localizer, culture, activeView]);

const calendarEvents = useCalendarEvents(props);

Expand All @@ -33,7 +41,7 @@
<progress className="widget-calendar-loading-bar" />
) : (
<div className={classNames("widget-calendar", props.class)} style={wrapperStyle}>
<DnDCalendar {...calendarProps} {...calendarEvents} />
<DnDCalendar {...calendarProps} {...calendarEvents} onView={handleView} />
</div>
)}
</Fragment>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { render, screen } from "@testing-library/react";
import { dynamic, ListValueBuilder } from "@mendix/widget-plugin-test-utils";

import MxCalendar from "../Calendar";
import { CalendarContainerProps } from "../../typings/CalendarProps";
import MxCalendar from "../Calendar";
import { CalendarPropsBuilder } from "../helpers/CalendarPropsBuilder";

// Mock react-big-calendar to avoid View.title issues
Expand Down Expand Up @@ -256,3 +256,105 @@ describe("CalendarPropsBuilder validation", () => {
expect(result.timeslots).toBe(2);
});
});

describe("CalendarPropsBuilder column header formats", () => {
const buildFormats = (toolbarItems: CalendarContainerProps["toolbarItems"], activeView?: string): any => {
const localizer = {
format: jest.fn((_date: Date, pattern: string) => pattern),
parse: jest.fn(),
startOfWeek: jest.fn(),
getDay: jest.fn(),
messages: {}
} as any;
const builder = new CalendarPropsBuilder({ ...customViewProps, toolbarItems });
const built = builder.build(localizer, "en", activeView as any);
return { formats: built.formats, view: built.view, localizer };
};

const toolbarItem = (
itemType: string,
overrides: Partial<CalendarContainerProps["toolbarItems"][number]> = {}
): CalendarContainerProps["toolbarItems"][number] =>
({
itemType,
position: "left",
renderMode: "button",
buttonStyle: "default",
...overrides
}) as any;

it("wires 'Header day format' on a day item into RBC dayFormat (the column header)", () => {
const { formats, localizer } = buildFormats([
toolbarItem("day", { customViewHeaderDayFormat: dynamic.available("EE dd-MM") })
]);

expect(typeof formats.dayFormat).toBe("function");
formats.dayFormat(new Date("2025-04-28T12:00:00Z"), "en", localizer);
expect(localizer.format).toHaveBeenCalledWith(expect.any(Date), "EE dd-MM", "en");
});

it("wires 'Header day format' on a month item into RBC weekdayFormat", () => {
const { formats, localizer } = buildFormats([
toolbarItem("month", { customViewHeaderDayFormat: dynamic.available("EEEE") })
]);

expect(typeof formats.weekdayFormat).toBe("function");
formats.weekdayFormat(new Date("2025-04-28T12:00:00Z"), "en", localizer);
expect(localizer.format).toHaveBeenCalledWith(expect.any(Date), "EEEE", "en");
});

it("resolves the shared dayFormat from whichever view is active, not a sibling view", () => {
// Week and work_week both configured with their OWN, different "Header day format".
// RBC shares a single `dayFormat` key across both (and day), so the builder must pick
// the pattern that belongs to whatever view is actually on screen (`activeView`).
const items = [
toolbarItem("day", { customViewHeaderDayFormat: dynamic.available("dd") }),
toolbarItem("week", { customViewHeaderDayFormat: dynamic.available("EE dd-MM") }),
toolbarItem("work_week", { customViewHeaderDayFormat: dynamic.available("MMM dd") })
];

const week = buildFormats(items, "week");
week.formats.dayFormat(new Date("2025-04-28T12:00:00Z"), "en", week.localizer);
expect(week.localizer.format).toHaveBeenCalledWith(expect.any(Date), "EE dd-MM", "en");

const workWeek = buildFormats(items, "work_week");
workWeek.formats.dayFormat(new Date("2025-04-28T12:00:00Z"), "en", workWeek.localizer);
expect(workWeek.localizer.format).toHaveBeenCalledWith(expect.any(Date), "MMM dd", "en");

const day = buildFormats(items, "day");
day.formats.dayFormat(new Date("2025-04-28T12:00:00Z"), "en", day.localizer);
expect(day.localizer.format).toHaveBeenCalledWith(expect.any(Date), "dd", "en");
});

it("falls back to RBC's default when the active view (work_week) has no pattern of its own", () => {
// Regression: work_week previously inherited week's pattern instead of falling back.
// week HAS a custom pattern, work_week does NOT — while work_week is active, dayFormat
// must stay unset so RBC's own default ("dd eee") is used, not week's pattern.
const { formats } = buildFormats(
[
toolbarItem("week", { customViewHeaderDayFormat: dynamic.available("EE dd-MM") }),
toolbarItem("work_week")
],
"work_week"
);

expect(formats.dayFormat).toBeUndefined();
});

it("leaves dayFormat/weekdayFormat unset when no header format is configured (RBC defaults preserved)", () => {
const { formats } = buildFormats([toolbarItem("day"), toolbarItem("month")], "day");

expect(formats.dayFormat).toBeUndefined();
expect(formats.weekdayFormat).toBeUndefined();
});

it("wires 'Time gutter format' on a day item into RBC timeGutterFormat", () => {
const { formats, localizer } = buildFormats([
toolbarItem("day", { customViewGutterTimeFormat: dynamic.available("HH:mm") })
]);

expect(typeof formats.timeGutterFormat).toBe("function");
formats.timeGutterFormat(new Date("2025-04-28T14:00:00Z"), "en", localizer);
expect(localizer.format).toHaveBeenCalledWith(expect.any(Date), "HH:mm", "en");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ exports[`Calendar renders correctly with basic props 1`] = `
max="Mon Apr 28 2025 23:59:59 GMT+0000 (Coordinated Universal Time)"
messages="[object Object]"
min="Mon Apr 28 2025 00:00:00 GMT+0000 (Coordinated Universal Time)"
view="day"
views="[object Object]"
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { ObjectItem } from "mendix";
import { DateLocalizer, Formats, ViewsProps } from "react-big-calendar";
import { DateLocalizer, Formats, View, ViewsProps } from "react-big-calendar";
import { CustomWeekController } from "./CustomWeekController";
import { CalendarContainerProps } from "../../typings/CalendarProps";
import { createConfigurableToolbar, CustomToolbar, ResolvedToolbarItem } from "../components/Toolbar";
import { eventPropGetter, getTextValue } from "../utils/calendar-utils";
import { CalendarEvent, DragAndDropCalendarProps } from "../utils/typings";
import { CustomWeekController } from "./CustomWeekController";

export class CalendarPropsBuilder {
private visibleDays: Set<number>;
private defaultView: "month" | "week" | "work_week" | "day" | "agenda";
private defaultView: View;
private isCustomView: boolean;
private events: CalendarEvent[];
private minTime: Date;
Expand Down Expand Up @@ -48,8 +48,7 @@ export class CalendarPropsBuilder {
this.defaultDate = props.startDateAttribute?.value;
}

build(localizer: DateLocalizer, culture: string): DragAndDropCalendarProps<CalendarEvent> {
const formats = this.buildFormats(localizer);
build(localizer: DateLocalizer, culture: string, activeView?: View): DragAndDropCalendarProps<CalendarEvent> {
const views = this.buildVisibleViews();
const toolbar =
this.isCustomView && this.toolbarItems && this.toolbarItems.length > 0
Expand All @@ -62,15 +61,22 @@ export class CalendarPropsBuilder {
// Ensure defaultView is actually enabled in views, otherwise pick the first enabled view
const enabledViews = Object.entries(views)
.filter(([_, enabled]) => enabled !== false)
.map(([view]) => view as "day" | "week" | "work_week" | "month" | "agenda");
.map(([view]) => view as View);
const safeDefaultView = enabledViews.includes(this.defaultView) ? this.defaultView : enabledViews[0];

// The view RBC is actually showing right now. Falls back to safeDefaultView when the
// caller hasn't told us yet (first render) or the reported view is no longer enabled.
const effectiveView = activeView && enabledViews.includes(activeView) ? activeView : safeDefaultView;

const formats = this.buildFormats(localizer, effectiveView);

return {
localizer,
culture,
components: {
toolbar
},
view: effectiveView,
defaultView: safeDefaultView,
messages: this.buildMessages(workWeekCaption),
events: this.events,
Expand Down Expand Up @@ -141,7 +147,10 @@ export class CalendarPropsBuilder {
}
}

private buildFormats(_localizer: DateLocalizer): Formats {
private buildFormats(
_localizer: DateLocalizer,
activeView: "day" | "week" | "work_week" | "month" | "agenda"
): Formats {
const formats: Formats = {};

const timePattern = this.getSafeTimePattern();
Expand Down Expand Up @@ -207,24 +216,57 @@ export class CalendarPropsBuilder {
loc.format(date, dayHeaderPattern, culture);
}

const weekHeaderPattern = getPattern(
byType.get("week")?.customViewHeaderDayFormat || byType.get("work_week")?.customViewHeaderDayFormat
);
if (weekHeaderPattern) {
formats.dayRangeHeaderFormat = (
range: { start: Date; end: Date },
culture: string,
loc: DateLocalizer
) =>
`${loc.format(range.start, weekHeaderPattern, culture)} – ${loc.format(range.end, weekHeaderPattern, culture)}`;
}
const weekOwnPattern = getPattern(byType.get("week")?.customViewHeaderDayFormat);
const workWeekOwnPattern = getPattern(byType.get("work_week")?.customViewHeaderDayFormat);

const monthHeaderPattern = getPattern(byType.get("month")?.customViewHeaderDayFormat);
if (monthHeaderPattern) {
formats.monthHeaderFormat = (date: Date, culture: string, loc: DateLocalizer) =>
loc.format(date, monthHeaderPattern, culture);
}

// Per-column headers — distinct from the toolbar title above.
// RBC renders the "07 Tue" day-column headers via a single, global `dayFormat` key shared
// by Day, Week AND our custom work_week view (all render through TimeGridHeader.js), and
// the month weekday headers via `weekdayFormat` (Month.js). These are separate from
// dayHeaderFormat/monthHeaderFormat (the toolbar title), so we must set them explicitly or
// RBC's date-fns defaults ("dd eee") always win.
if (monthHeaderPattern) {
formats.weekdayFormat = (date: Date, culture: string, loc: DateLocalizer) =>
loc.format(date, monthHeaderPattern, culture);
}

// Because `dayFormat` is shared, we can't set it once for all views — a pattern meant for
// Week would leak into work_week (and vice versa). Instead resolve it from whichever view
// is actually on screen right now, and only for the views that render through TimeGridHeader.
// When that view has no custom pattern of its own, leave `dayFormat` unset so RBC's default
// ("dd eee") applies — no more inheriting a sibling view's pattern.
const columnDayPattern: string | undefined =
activeView === "day"
? dayHeaderPattern
: activeView === "week"
? weekOwnPattern
: activeView === "work_week"
? workWeekOwnPattern
: undefined;
if (columnDayPattern) {
formats.dayFormat = (date: Date, culture: string, loc: DateLocalizer) =>
loc.format(date, columnDayPattern, culture);
}

// Toolbar title range for week/work_week — same active-view resolution as the column
// header above, so the title and the columns always agree on which pattern is showing.
const weekRangePattern =
activeView === "week" ? weekOwnPattern : activeView === "work_week" ? workWeekOwnPattern : undefined;
if (weekRangePattern) {
formats.dayRangeHeaderFormat = (
range: { start: Date; end: Date },
culture: string,
loc: DateLocalizer
) =>
`${loc.format(range.start, weekRangePattern, culture)} – ${loc.format(range.end, weekRangePattern, culture)}`;
}

const agendaHeaderPattern = getPattern(byType.get("agenda")?.customViewHeaderDayFormat);
if (agendaHeaderPattern) {
formats.agendaHeaderFormat = (range: { start: Date; end: Date }, culture: string, loc: DateLocalizer) =>
Expand Down
Loading