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
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]

### Added

- We added a `showMultiDayTimes` property to control whether start/end times are displayed for multi-day events in the calendar.

## [2.4.0] - 2026-03-20

### Fixed
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-23
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
## Why

In the Calendar widget's week and day views, `react-big-calendar` places any event that
spans more than one day into the top "all-day" row, hiding its start and end times. For
apps where multi-day events have meaningful times (e.g. a booking that starts at 22:00 on
one day and ends at 02:00 the next), authors need those events rendered as timed blocks in
the time grid, showing their start/end times, instead of being flattened into the all-day
row.

`react-big-calendar` already exposes a `showMultiDayTimes` flag for exactly this, but the
Calendar widget did not surface it, so authors had no way to opt in.

## What Changes

Add a `showMultiDayTimes` boolean property (default `false`) to the Calendar widget and pass
it through to `react-big-calendar`. When enabled, multi-day events render in the time grid
with their start/end times in week and day views. To make those times readable on the
event blocks, the widget also supplies `eventTimeRangeStartFormat` and
`eventTimeRangeEndFormat` derived from the widget's configured time format, and keeps them
consistent with the existing `eventTimeRangeFormat` and `showEventDate` behavior.

- `src/Calendar.xml` — add `showMultiDayTimes` boolean property (defaultValue `false`) in
the week/day view group, with caption and description.
- `typings/CalendarProps.d.ts` — add `showMultiDayTimes: boolean` to the container props and
`showMultiDayTimes: boolean` to the preview props (generated typings).
- `src/helpers/CalendarPropsBuilder.ts`:
- Pass `showMultiDayTimes: this.props.showMultiDayTimes` through to the calendar props.
- Add `eventTimeRangeStartFormat` (`"<start> – "`) and `eventTimeRangeEndFormat`
(`" – <end>"`) using the same configured time pattern as `eventTimeRangeFormat`.
- When `showEventDate` is `false`, blank out `eventTimeRangeStartFormat` and
`eventTimeRangeEndFormat` alongside the existing `eventTimeRangeFormat`.
- Unit tests + snapshot update for the above.
- User-facing changelog entry.

## Impact

- **Consumers:** Purely additive. The default is `false`, which preserves the existing
behavior (multi-day events stay in the all-day row), so existing apps are unaffected. Not
breaking.
- **Must NOT break:** `showEventDate=false` must continue to hide all event time ranges,
including the new start/end formats; existing single-day timed event rendering and the
existing `eventTimeRangeFormat` behavior are unchanged.
- **Users:** Authors can opt in to see start/end times for multi-day events in week and day
views. Version bump deferred to release time per repo convention.
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
## ADDED Requirements

### Requirement: Show multi-day times property

The Calendar widget SHALL expose a `showMultiDayTimes` boolean property (default `false`)
that controls whether events spanning multiple days are rendered as timed blocks (showing
start/end times) in the week and day views instead of being placed in the all-day row.

#### Scenario: Property is available in Studio Pro

- **WHEN** a developer configures the Calendar widget in Studio Pro
- **THEN** a "Show multi-day times" boolean property is available, defaulting to off

#### Scenario: Enabled flag is passed through to the calendar

- **WHEN** `showMultiDayTimes` is set to `true`
- **THEN** the Calendar passes `showMultiDayTimes=true` to `react-big-calendar`, so multi-day
events render as timed blocks in the week and day views

#### Scenario: Disabled by default preserves all-day rendering

- **WHEN** `showMultiDayTimes` is left at its default (`false`)
- **THEN** the Calendar passes `showMultiDayTimes=false`, and multi-day events continue to be
placed in the all-day row (existing behavior is unchanged)

### Requirement: Multi-day event time-range formats

When a time format is configured, the Calendar widget SHALL provide
`eventTimeRangeStartFormat` and `eventTimeRangeEndFormat` for `react-big-calendar`, derived
from the same configured time pattern used by `eventTimeRangeFormat`, so the start and end
times of multi-day events are shown in a locale-aware way.

#### Scenario: Start format renders the start time with a trailing separator

- **WHEN** a time format (e.g. `HH:mm`) is configured and `eventTimeRangeStartFormat` is
called for an event
- **THEN** it returns the event's start time formatted with that pattern, followed by a
" – " separator

#### Scenario: End format renders the end time with a leading separator

- **WHEN** a time format (e.g. `HH:mm`) is configured and `eventTimeRangeEndFormat` is called
for an event
- **THEN** it returns the event's end time formatted with that pattern, preceded by a " – "
separator

#### Scenario: Start, end, and range formats share the same time pattern

- **WHEN** a time format (e.g. `h:mm a`) is configured
- **THEN** `eventTimeRangeFormat`, `eventTimeRangeStartFormat`, and `eventTimeRangeEndFormat`
all format times using that same pattern

#### Scenario: No time format leaves start/end formats unset

- **WHEN** no time format is configured
- **THEN** the widget does not set `eventTimeRangeStartFormat` or `eventTimeRangeEndFormat`

### Requirement: Show event date hides multi-day time ranges

When `showEventDate` is disabled, the Calendar widget SHALL hide all event time-range labels,
including the multi-day start and end formats, so no times are shown on events.

#### Scenario: showEventDate=false blanks all time-range formats

- **WHEN** `showEventDate` is `false` (with a time format configured)
- **THEN** `eventTimeRangeFormat`, `eventTimeRangeStartFormat`, and `eventTimeRangeEndFormat`
all return an empty string

#### Scenario: showEventDate=true preserves all time-range formats

- **WHEN** `showEventDate` is `true` (with a time format configured)
- **THEN** `eventTimeRangeFormat`, `eventTimeRangeStartFormat`, and `eventTimeRangeEndFormat`
all return a non-empty formatted label
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## 1. Property definition

- [x] 1.1 Add `showMultiDayTimes` boolean property (defaultValue `false`) with caption and description to `src/Calendar.xml` in the week/day view group
- [x] 1.2 Regenerate typings: `showMultiDayTimes: boolean` in `CalendarContainerProps` and `CalendarPreviewProps` (`typings/CalendarProps.d.ts`)

## 2. Implementation

- [x] 2.1 Pass `showMultiDayTimes: this.props.showMultiDayTimes` through to the calendar props in `CalendarPropsBuilder.build`
- [x] 2.2 Add `eventTimeRangeStartFormat` (`"<start> – "`) and `eventTimeRangeEndFormat` (`" – <end>"`) using the same configured time pattern as `eventTimeRangeFormat`
- [x] 2.3 When `showEventDate?.value === false`, blank `eventTimeRangeStartFormat` and `eventTimeRangeEndFormat` alongside the existing `eventTimeRangeFormat`

## 3. Tests

- [x] 3.1 Test `showMultiDayTimes=true`/`false` is passed through to calendar props
- [x] 3.2 Test start/end formats use the configured time pattern with the correct leading/trailing separator; and are unset when no time format is configured
- [x] 3.3 Test `showEventDate=false` blanks range/start/end formats; `showEventDate=true` preserves them
- [x] 3.4 Update the Calendar snapshot for the new `data-show-multi-day-times` (and `data-min`/`data-max`) attributes

## 4. Verification

- [x] 4.1 `calendar-web` unit suite passes
- [x] 4.2 Add user-facing changelog entry (new `showMultiDayTimes` property)
- [x] 4.3 `/code-review` before merge; PR ready (⚠️ Approved with suggestions — low-severity only, no medium/high findings)

## Notes

- Change is additive; default `false` preserves existing all-day rendering for multi-day events.
- Version bump deferred to release time per repo convention.
4 changes: 4 additions & 0 deletions packages/pluggableWidgets/calendar-web/src/Calendar.xml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@
<caption>Show all events</caption>
<description>Auto-adjust calendar height to display all events without "more" links</description>
</property>
<property key="showMultiDayTimes" type="boolean" defaultValue="false">
<caption>Show multi-day times</caption>
<description>Show start and end times for events that span multiple days in the week and day views instead of placing them in the all-day row</description>
</property>
<property key="step" type="integer" defaultValue="30">
<caption>Step</caption>
<description>Determines the selectable time increments in week and day views</description>
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 All @@ -19,6 +19,9 @@
resizable,
selectable,
showAllEvents,
showMultiDayTimes,
min,
max,
events,
step,
timeslots,
Expand All @@ -34,6 +37,9 @@
data-resizable={resizable}
data-selectable={selectable}
data-show-all-events={showAllEvents}
data-show-multi-day-times={showMultiDayTimes}
data-min={min?.toISOString()}
data-max={max?.toISOString()}
data-events-count={events?.length ?? 0}
data-step={step}
data-timeslots={timeslots}
Expand Down Expand Up @@ -93,6 +99,7 @@
customViewShowFriday: true,
customViewShowSaturday: false,
showAllEvents: true,
showMultiDayTimes: true,
step: 60,
timeslots: 2,
toolbarItems: [],
Expand Down Expand Up @@ -256,3 +263,179 @@
expect(result.timeslots).toBe(2);
});
});

describe("CalendarPropsBuilder showMultiDayTimes", () => {
const mockLocalizer = {
format: jest.fn(),
parse: jest.fn(),
startOfWeek: jest.fn(),
getDay: jest.fn(),
messages: {}
} as any;

it("passes showMultiDayTimes=true to calendar props", () => {
const props = { ...customViewProps, showMultiDayTimes: true };
const builder = new CalendarPropsBuilder(props);
const result = builder.build(mockLocalizer, "en");
expect(result.showMultiDayTimes).toBe(true);
});

it("passes showMultiDayTimes=false to calendar props", () => {
const props = { ...customViewProps, showMultiDayTimes: false };
const builder = new CalendarPropsBuilder(props);
const result = builder.build(mockLocalizer, "en");
expect(result.showMultiDayTimes).toBe(false);
});
});

describe("CalendarPropsBuilder multi-day time formats", () => {
const mockLocalizer = {
format: jest.fn((date: Date, pattern: string, _culture: string) => {
// Simulate locale-aware formatting using the pattern
const hours = date.getHours();
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes} (${pattern})`;
}),
parse: jest.fn(),
startOfWeek: jest.fn(),
getDay: jest.fn(),
messages: {}
} as any;

const buildWithTimeFormat = (timeFormatValue: string) => {

Check warning on line 305 in packages/pluggableWidgets/calendar-web/src/__tests__/Calendar.spec.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

Missing return type on function
const props = {
...customViewProps,
timeFormat: dynamic.available(timeFormatValue)
};
const builder = new CalendarPropsBuilder(props);
return builder.build(mockLocalizer, "en");
};

it("sets eventTimeRangeStartFormat using the configured time pattern", () => {
const result = buildWithTimeFormat("HH:mm");
const start = new Date("2025-04-28T22:00:00Z");
const end = new Date("2025-04-29T02:00:00Z");

expect(result.formats!.eventTimeRangeStartFormat).toBeDefined();
const label = (result.formats!.eventTimeRangeStartFormat as Function)({ start, end }, "en", mockLocalizer);
expect(label).toContain("HH:mm");
expect(label).toMatch(/– $/);
});

it("sets eventTimeRangeEndFormat using the configured time pattern", () => {
const result = buildWithTimeFormat("HH:mm");
const start = new Date("2025-04-28T22:00:00Z");
const end = new Date("2025-04-29T02:00:00Z");

expect(result.formats!.eventTimeRangeEndFormat).toBeDefined();
const label = (result.formats!.eventTimeRangeEndFormat as Function)({ start, end }, "en", mockLocalizer);
expect(label).toContain("HH:mm");
expect(label).toMatch(/^ – /);
});

it("uses the same pattern for eventTimeRangeFormat, start, and end formats", () => {
const result = buildWithTimeFormat("h:mm a");
const start = new Date("2025-04-28T22:00:00Z");
const end = new Date("2025-04-29T02:00:00Z");

const rangeLabel = (result.formats!.eventTimeRangeFormat as Function)({ start, end }, "en", mockLocalizer);
const startLabel = (result.formats!.eventTimeRangeStartFormat as Function)({ start, end }, "en", mockLocalizer);
const endLabel = (result.formats!.eventTimeRangeEndFormat as Function)({ start, end }, "en", mockLocalizer);

// All three should use the same "h:mm a" pattern passed to localizer.format
expect(rangeLabel).toContain("h:mm a");
expect(startLabel).toContain("h:mm a");
expect(endLabel).toContain("h:mm a");
});

it("does not set start/end formats when no timeFormat is configured", () => {
const props = { ...customViewProps, timeFormat: undefined };
const builder = new CalendarPropsBuilder(props);
const result = builder.build(mockLocalizer, "en");

expect(result.formats!.eventTimeRangeStartFormat).toBeUndefined();
expect(result.formats!.eventTimeRangeEndFormat).toBeUndefined();
});
});

describe("CalendarPropsBuilder showEventDate hides multi-day formats", () => {
const mockLocalizer = {
format: jest.fn((_date: Date, pattern: string) => `formatted(${pattern})`),
parse: jest.fn(),
startOfWeek: jest.fn(),
getDay: jest.fn(),
messages: {}
} as any;

it("blanks eventTimeRangeStartFormat when showEventDate=false", () => {
const props = {
...customViewProps,
showEventDate: dynamic.available(false),
timeFormat: dynamic.available("HH:mm")
};
const builder = new CalendarPropsBuilder(props);
const result = builder.build(mockLocalizer, "en");

const label = (result.formats!.eventTimeRangeStartFormat as Function)(
{ start: new Date(), end: new Date() },
"en",
mockLocalizer
);
expect(label).toBe("");
});

it("blanks eventTimeRangeEndFormat when showEventDate=false", () => {
const props = {
...customViewProps,
showEventDate: dynamic.available(false),
timeFormat: dynamic.available("HH:mm")
};
const builder = new CalendarPropsBuilder(props);
const result = builder.build(mockLocalizer, "en");

const label = (result.formats!.eventTimeRangeEndFormat as Function)(
{ start: new Date(), end: new Date() },
"en",
mockLocalizer
);
expect(label).toBe("");
});

it("blanks eventTimeRangeFormat when showEventDate=false", () => {
const props = {
...customViewProps,
showEventDate: dynamic.available(false),
timeFormat: dynamic.available("HH:mm")
};
const builder = new CalendarPropsBuilder(props);
const result = builder.build(mockLocalizer, "en");

const label = (result.formats!.eventTimeRangeFormat as Function)(
{ start: new Date(), end: new Date() },
"en",
mockLocalizer
);
expect(label).toBe("");
});

it("preserves all time range formats when showEventDate=true", () => {
const props = {
...customViewProps,
showEventDate: dynamic.available(true),
timeFormat: dynamic.available("p")
};
const builder = new CalendarPropsBuilder(props);
const result = builder.build(mockLocalizer, "en");

const start = new Date("2025-04-28T22:00:00Z");
const end = new Date("2025-04-29T02:00:00Z");

const rangeLabel = (result.formats!.eventTimeRangeFormat as Function)({ start, end }, "en", mockLocalizer);
const startLabel = (result.formats!.eventTimeRangeStartFormat as Function)({ start, end }, "en", mockLocalizer);
const endLabel = (result.formats!.eventTimeRangeEndFormat as Function)({ start, end }, "en", mockLocalizer);

expect(rangeLabel).not.toBe("");
expect(startLabel).not.toBe("");
expect(endLabel).not.toBe("");
});
});
Loading
Loading