Skip to content

Commit 001fd47

Browse files
committed
fix(calendar-web): resolve dayFormat by active view to prevent week/work_week header leak
1 parent 0ff2f93 commit 001fd47

4 files changed

Lines changed: 103 additions & 54 deletions

File tree

packages/pluggableWidgets/calendar-web/src/Calendar.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { Fragment, ReactElement, useMemo } from "react";
1+
import { Fragment, ReactElement, useCallback, useMemo, useState } from "react";
22
import classNames from "classnames";
3+
import { View } from "react-big-calendar";
34
import { CalendarContainerProps } from "../typings/CalendarProps";
45
import { CalendarPropsBuilder } from "./helpers/CalendarPropsBuilder";
56
import { DnDCalendar } from "./utils/calendar-utils";
@@ -20,10 +21,17 @@ export default function MxCalendar(props: CalendarContainerProps): ReactElement
2021
// Get locale-aware localizer
2122
const { localizer, culture } = useLocalizer();
2223

24+
// The calendar is controlled on `view` so the props builder always knows which view is on
25+
// screen — needed to resolve the shared RBC `dayFormat` key (week/work_week/day column
26+
// headers) to that view's own custom pattern instead of a sibling view's. Undefined until
27+
// RBC reports its first view (via defaultView), at which point the builder picks a safe one.
28+
const [activeView, setActiveView] = useState<View | undefined>(undefined);
29+
const handleView = useCallback((view: View) => setActiveView(view), []);
30+
2331
const calendarProps = useMemo(() => {
2432
calendarController.updateProps(props);
25-
return calendarController.build(localizer, culture);
26-
}, [props, calendarController, localizer, culture]);
33+
return calendarController.build(localizer, culture, activeView);
34+
}, [props, calendarController, localizer, culture, activeView]);
2735

2836
const calendarEvents = useCalendarEvents(props);
2937

@@ -33,7 +41,7 @@ export default function MxCalendar(props: CalendarContainerProps): ReactElement
3341
<progress className="widget-calendar-loading-bar" />
3442
) : (
3543
<div className={classNames("widget-calendar", props.class)} style={wrapperStyle}>
36-
<DnDCalendar {...calendarProps} {...calendarEvents} />
44+
<DnDCalendar {...calendarProps} {...calendarEvents} onView={handleView} />
3745
</div>
3846
)}
3947
</Fragment>

packages/pluggableWidgets/calendar-web/src/__tests__/Calendar.spec.tsx

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ describe("CalendarPropsBuilder validation", () => {
258258
});
259259

260260
describe("CalendarPropsBuilder column header formats", () => {
261-
const buildFormats = (toolbarItems: CalendarContainerProps["toolbarItems"]): any => {
261+
const buildFormats = (toolbarItems: CalendarContainerProps["toolbarItems"], activeView?: string): any => {
262262
const localizer = {
263263
format: jest.fn((_date: Date, pattern: string) => pattern),
264264
parse: jest.fn(),
@@ -267,7 +267,8 @@ describe("CalendarPropsBuilder column header formats", () => {
267267
messages: {}
268268
} as any;
269269
const builder = new CalendarPropsBuilder({ ...customViewProps, toolbarItems });
270-
return { formats: builder.build(localizer, "en").formats, localizer };
270+
const built = builder.build(localizer, "en", activeView as any);
271+
return { formats: built.formats, view: built.view, localizer };
271272
};
272273

273274
const toolbarItem = (
@@ -284,7 +285,7 @@ describe("CalendarPropsBuilder column header formats", () => {
284285

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

290291
expect(typeof formats.dayFormat).toBe("function");
@@ -294,37 +295,62 @@ describe("CalendarPropsBuilder column header formats", () => {
294295

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

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

305-
it("prefers the week pattern for the shared dayFormat when day and week both set it", () => {
306-
const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
307-
const { formats, localizer } = buildFormats([
308-
toolbarItem("day", { customViewHeaderDayFormat: dynamic("dd") }),
309-
toolbarItem("week", { customViewHeaderDayFormat: dynamic("EE dd-MM") })
310-
]);
306+
it("resolves the shared dayFormat from whichever view is active, not a sibling view", () => {
307+
// Week and work_week both configured with their OWN, different "Header day format".
308+
// RBC shares a single `dayFormat` key across both (and day), so the builder must pick
309+
// the pattern that belongs to whatever view is actually on screen (`activeView`).
310+
const items = [
311+
toolbarItem("day", { customViewHeaderDayFormat: dynamic.available("dd") }),
312+
toolbarItem("week", { customViewHeaderDayFormat: dynamic.available("EE dd-MM") }),
313+
toolbarItem("work_week", { customViewHeaderDayFormat: dynamic.available("MMM dd") })
314+
];
315+
316+
const week = buildFormats(items, "week");
317+
week.formats.dayFormat(new Date("2025-04-28T12:00:00Z"), "en", week.localizer);
318+
expect(week.localizer.format).toHaveBeenCalledWith(expect.any(Date), "EE dd-MM", "en");
319+
320+
const workWeek = buildFormats(items, "work_week");
321+
workWeek.formats.dayFormat(new Date("2025-04-28T12:00:00Z"), "en", workWeek.localizer);
322+
expect(workWeek.localizer.format).toHaveBeenCalledWith(expect.any(Date), "MMM dd", "en");
323+
324+
const day = buildFormats(items, "day");
325+
day.formats.dayFormat(new Date("2025-04-28T12:00:00Z"), "en", day.localizer);
326+
expect(day.localizer.format).toHaveBeenCalledWith(expect.any(Date), "dd", "en");
327+
});
311328

312-
formats.dayFormat(new Date("2025-04-28T12:00:00Z"), "en", localizer);
313-
expect(localizer.format).toHaveBeenCalledWith(expect.any(Date), "EE dd-MM", "en");
314-
expect(warn).toHaveBeenCalledWith(expect.stringContaining("shares a single"));
315-
warn.mockRestore();
329+
it("falls back to RBC's default when the active view (work_week) has no pattern of its own", () => {
330+
// Regression: work_week previously inherited week's pattern instead of falling back.
331+
// week HAS a custom pattern, work_week does NOT — while work_week is active, dayFormat
332+
// must stay unset so RBC's own default ("dd eee") is used, not week's pattern.
333+
const { formats } = buildFormats(
334+
[
335+
toolbarItem("week", { customViewHeaderDayFormat: dynamic.available("EE dd-MM") }),
336+
toolbarItem("work_week")
337+
],
338+
"work_week"
339+
);
340+
341+
expect(formats.dayFormat).toBeUndefined();
316342
});
317343

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

321347
expect(formats.dayFormat).toBeUndefined();
322348
expect(formats.weekdayFormat).toBeUndefined();
323349
});
324350

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

330356
expect(typeof formats.timeGutterFormat).toBe("function");

packages/pluggableWidgets/calendar-web/src/__tests__/__snapshots__/Calendar.spec.tsx.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ exports[`Calendar renders correctly with basic props 1`] = `
2121
max="Mon Apr 28 2025 23:59:59 GMT+0000 (Coordinated Universal Time)"
2222
messages="[object Object]"
2323
min="Mon Apr 28 2025 00:00:00 GMT+0000 (Coordinated Universal Time)"
24+
view="day"
2425
views="[object Object]"
2526
/>
2627
</div>

packages/pluggableWidgets/calendar-web/src/helpers/CalendarPropsBuilder.ts

Lines changed: 48 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ObjectItem } from "mendix";
2-
import { DateLocalizer, Formats, ViewsProps } from "react-big-calendar";
2+
import { DateLocalizer, Formats, View, ViewsProps } from "react-big-calendar";
33
import { CustomWeekController } from "./CustomWeekController";
44
import { CalendarContainerProps } from "../../typings/CalendarProps";
55
import { createConfigurableToolbar, CustomToolbar, ResolvedToolbarItem } from "../components/Toolbar";
@@ -8,7 +8,7 @@ import { CalendarEvent, DragAndDropCalendarProps } from "../utils/typings";
88

99
export class CalendarPropsBuilder {
1010
private visibleDays: Set<number>;
11-
private defaultView: "month" | "week" | "work_week" | "day" | "agenda";
11+
private defaultView: View;
1212
private isCustomView: boolean;
1313
private events: CalendarEvent[];
1414
private minTime: Date;
@@ -48,8 +48,7 @@ export class CalendarPropsBuilder {
4848
this.defaultDate = props.startDateAttribute?.value;
4949
}
5050

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

67+
// The view RBC is actually showing right now. Falls back to safeDefaultView when the
68+
// caller hasn't told us yet (first render) or the reported view is no longer enabled.
69+
const effectiveView = activeView && enabledViews.includes(activeView) ? activeView : safeDefaultView;
70+
71+
const formats = this.buildFormats(localizer, effectiveView);
72+
6873
return {
6974
localizer,
7075
culture,
7176
components: {
7277
toolbar
7378
},
79+
view: effectiveView,
7480
defaultView: safeDefaultView,
7581
messages: this.buildMessages(workWeekCaption),
7682
events: this.events,
@@ -141,7 +147,10 @@ export class CalendarPropsBuilder {
141147
}
142148
}
143149

144-
private buildFormats(_localizer: DateLocalizer): Formats {
150+
private buildFormats(
151+
_localizer: DateLocalizer,
152+
activeView: "day" | "week" | "work_week" | "month" | "agenda"
153+
): Formats {
145154
const formats: Formats = {};
146155

147156
const timePattern = this.getSafeTimePattern();
@@ -207,17 +216,8 @@ export class CalendarPropsBuilder {
207216
loc.format(date, dayHeaderPattern, culture);
208217
}
209218

210-
const weekHeaderPattern = getPattern(
211-
byType.get("week")?.customViewHeaderDayFormat || byType.get("work_week")?.customViewHeaderDayFormat
212-
);
213-
if (weekHeaderPattern) {
214-
formats.dayRangeHeaderFormat = (
215-
range: { start: Date; end: Date },
216-
culture: string,
217-
loc: DateLocalizer
218-
) =>
219-
`${loc.format(range.start, weekHeaderPattern, culture)}${loc.format(range.end, weekHeaderPattern, culture)}`;
220-
}
219+
const weekOwnPattern = getPattern(byType.get("week")?.customViewHeaderDayFormat);
220+
const workWeekOwnPattern = getPattern(byType.get("work_week")?.customViewHeaderDayFormat);
221221

222222
const monthHeaderPattern = getPattern(byType.get("month")?.customViewHeaderDayFormat);
223223
if (monthHeaderPattern) {
@@ -226,33 +226,47 @@ export class CalendarPropsBuilder {
226226
}
227227

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

238-
// RBC exposes a single global `dayFormat` shared by the day AND week column headers, so
239-
// when a day item and a week item each carry a different "Header day format" we can only
240-
// honor one. Precedence matches `chosenTimeGutter` below (week → day → work_week) for
241-
// consistency across the shared-key formats. weekHeaderPattern already folds in
242-
// week/work_week; dayHeaderPattern is the "day" item.
243-
const columnDayPattern: string | undefined = weekHeaderPattern || dayHeaderPattern;
244-
if (weekHeaderPattern && dayHeaderPattern && weekHeaderPattern !== dayHeaderPattern) {
245-
console.warn(
246-
`[Calendar] Both week and day "Header day format" are set to different patterns ` +
247-
`("${weekHeaderPattern}" vs "${dayHeaderPattern}"). react-big-calendar shares a single ` +
248-
`column-header format, so "${columnDayPattern}" will be used for both views.`
249-
);
250-
}
239+
// Because `dayFormat` is shared, we can't set it once for all views — a pattern meant for
240+
// Week would leak into work_week (and vice versa). Instead resolve it from whichever view
241+
// is actually on screen right now, and only for the views that render through TimeGridHeader.
242+
// When that view has no custom pattern of its own, leave `dayFormat` unset so RBC's default
243+
// ("dd eee") applies — no more inheriting a sibling view's pattern.
244+
const columnDayPattern: string | undefined =
245+
activeView === "day"
246+
? dayHeaderPattern
247+
: activeView === "week"
248+
? weekOwnPattern
249+
: activeView === "work_week"
250+
? workWeekOwnPattern
251+
: undefined;
251252
if (columnDayPattern) {
252253
formats.dayFormat = (date: Date, culture: string, loc: DateLocalizer) =>
253254
loc.format(date, columnDayPattern, culture);
254255
}
255256

257+
// Toolbar title range for week/work_week — same active-view resolution as the column
258+
// header above, so the title and the columns always agree on which pattern is showing.
259+
const weekRangePattern =
260+
activeView === "week" ? weekOwnPattern : activeView === "work_week" ? workWeekOwnPattern : undefined;
261+
if (weekRangePattern) {
262+
formats.dayRangeHeaderFormat = (
263+
range: { start: Date; end: Date },
264+
culture: string,
265+
loc: DateLocalizer
266+
) =>
267+
`${loc.format(range.start, weekRangePattern, culture)}${loc.format(range.end, weekRangePattern, culture)}`;
268+
}
269+
256270
const agendaHeaderPattern = getPattern(byType.get("agenda")?.customViewHeaderDayFormat);
257271
if (agendaHeaderPattern) {
258272
formats.agendaHeaderFormat = (range: { start: Date; end: Date }, culture: string, loc: DateLocalizer) =>

0 commit comments

Comments
 (0)