diff --git a/packages/pluggableWidgets/calendar-web/CHANGELOG.md b/packages/pluggableWidgets/calendar-web/CHANGELOG.md index 3d7b78ae98..705000df86 100644 --- a/packages/pluggableWidgets/calendar-web/CHANGELOG.md +++ b/packages/pluggableWidgets/calendar-web/CHANGELOG.md @@ -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 diff --git a/packages/pluggableWidgets/calendar-web/openspec/changes/add-show-multi-day-times/.openspec.yaml b/packages/pluggableWidgets/calendar-web/openspec/changes/add-show-multi-day-times/.openspec.yaml new file mode 100644 index 0000000000..9e5b8a1905 --- /dev/null +++ b/packages/pluggableWidgets/calendar-web/openspec/changes/add-show-multi-day-times/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-23 diff --git a/packages/pluggableWidgets/calendar-web/openspec/changes/add-show-multi-day-times/proposal.md b/packages/pluggableWidgets/calendar-web/openspec/changes/add-show-multi-day-times/proposal.md new file mode 100644 index 0000000000..e68b8ed643 --- /dev/null +++ b/packages/pluggableWidgets/calendar-web/openspec/changes/add-show-multi-day-times/proposal.md @@ -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` (`" – "`) and `eventTimeRangeEndFormat` + (`" – "`) 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. diff --git a/packages/pluggableWidgets/calendar-web/openspec/changes/add-show-multi-day-times/specs/calendar-configuration/spec.md b/packages/pluggableWidgets/calendar-web/openspec/changes/add-show-multi-day-times/specs/calendar-configuration/spec.md new file mode 100644 index 0000000000..c98f7a7187 --- /dev/null +++ b/packages/pluggableWidgets/calendar-web/openspec/changes/add-show-multi-day-times/specs/calendar-configuration/spec.md @@ -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 diff --git a/packages/pluggableWidgets/calendar-web/openspec/changes/add-show-multi-day-times/tasks.md b/packages/pluggableWidgets/calendar-web/openspec/changes/add-show-multi-day-times/tasks.md new file mode 100644 index 0000000000..f8d61380c6 --- /dev/null +++ b/packages/pluggableWidgets/calendar-web/openspec/changes/add-show-multi-day-times/tasks.md @@ -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` (`" – "`) and `eventTimeRangeEndFormat` (`" – "`) 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. diff --git a/packages/pluggableWidgets/calendar-web/src/Calendar.xml b/packages/pluggableWidgets/calendar-web/src/Calendar.xml index 25e9204adf..ed16f9e0f0 100644 --- a/packages/pluggableWidgets/calendar-web/src/Calendar.xml +++ b/packages/pluggableWidgets/calendar-web/src/Calendar.xml @@ -124,6 +124,10 @@ Show all events Auto-adjust calendar height to display all events without "more" links + + Show multi-day times + 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 + Step Determines the selectable time increments in week and day views diff --git a/packages/pluggableWidgets/calendar-web/src/__tests__/Calendar.spec.tsx b/packages/pluggableWidgets/calendar-web/src/__tests__/Calendar.spec.tsx index e31691c5cf..471d6c4236 100644 --- a/packages/pluggableWidgets/calendar-web/src/__tests__/Calendar.spec.tsx +++ b/packages/pluggableWidgets/calendar-web/src/__tests__/Calendar.spec.tsx @@ -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 @@ -19,6 +19,9 @@ jest.mock("react-big-calendar", () => { resizable, selectable, showAllEvents, + showMultiDayTimes, + min, + max, events, step, timeslots, @@ -34,6 +37,9 @@ jest.mock("react-big-calendar", () => { 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} @@ -93,6 +99,7 @@ const customViewProps: CalendarContainerProps = { customViewShowFriday: true, customViewShowSaturday: false, showAllEvents: true, + showMultiDayTimes: true, step: 60, timeslots: 2, toolbarItems: [], @@ -256,3 +263,179 @@ describe("CalendarPropsBuilder validation", () => { 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) => { + 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(""); + }); +}); 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 63c3b68dfc..af790dfb71 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 @@ -10,17 +10,18 @@ exports[`Calendar renders correctly with basic props 1`] = ` data-culture="en-US" data-default-view="day" data-events-count="0" + data-max="2025-04-28T23:59:59.000Z" + data-min="2025-04-28T00:00:00.000Z" data-resizable="true" data-selectable="true" data-show-all-events="true" + data-show-multi-day-times="true" data-step="60" data-testid="mock-calendar" data-timeslots="2" formats="[object Object]" localizer="[object Object]" - 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)" views="[object Object]" /> diff --git a/packages/pluggableWidgets/calendar-web/src/helpers/CalendarPropsBuilder.ts b/packages/pluggableWidgets/calendar-web/src/helpers/CalendarPropsBuilder.ts index d6176e11a6..d6e1d907ab 100644 --- a/packages/pluggableWidgets/calendar-web/src/helpers/CalendarPropsBuilder.ts +++ b/packages/pluggableWidgets/calendar-web/src/helpers/CalendarPropsBuilder.ts @@ -86,6 +86,7 @@ export class CalendarPropsBuilder { startAccessor: (event: CalendarEvent) => event.start, titleAccessor: (event: CalendarEvent) => event.title, showAllEvents: this.props.showAllEvents, + showMultiDayTimes: this.props.showMultiDayTimes, min: this.minTime, max: this.maxTime, step: this.step, @@ -165,6 +166,16 @@ export class CalendarPropsBuilder { culture: string, loc: DateLocalizer ) => `${formatWith(start, culture, loc)} – ${formatWith(end, culture, loc)}`; + formats.eventTimeRangeStartFormat = ( + { start }: { start: Date; end: Date }, + culture: string, + loc: DateLocalizer + ) => `${formatWith(start, culture, loc)} – `; + formats.eventTimeRangeEndFormat = ( + { end }: { start: Date; end: Date }, + culture: string, + loc: DateLocalizer + ) => ` – ${formatWith(end, culture, loc)}`; formats.agendaTimeRangeFormat = ( { start, end }: { start: Date; end: Date }, culture: string, @@ -264,6 +275,8 @@ export class CalendarPropsBuilder { // Ensure showEventDate=false always hides event time ranges if (this.props.showEventDate?.value === false) { formats.eventTimeRangeFormat = () => ""; + formats.eventTimeRangeStartFormat = () => ""; + formats.eventTimeRangeEndFormat = () => ""; } return formats; diff --git a/packages/pluggableWidgets/calendar-web/typings/CalendarProps.d.ts b/packages/pluggableWidgets/calendar-web/typings/CalendarProps.d.ts index cc0721cc46..2beb55f550 100644 --- a/packages/pluggableWidgets/calendar-web/typings/CalendarProps.d.ts +++ b/packages/pluggableWidgets/calendar-web/typings/CalendarProps.d.ts @@ -89,6 +89,7 @@ export interface CalendarContainerProps { minHour: number; maxHour: number; showAllEvents: boolean; + showMultiDayTimes: boolean; step: number; timeslots: number; startDateAttribute?: EditableValue; @@ -144,6 +145,7 @@ export interface CalendarPreviewProps { minHour: number | null; maxHour: number | null; showAllEvents: boolean; + showMultiDayTimes: boolean; step: number | null; timeslots: number | null; startDateAttribute: string; diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4c37007e1c..2988723ef3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ +recursiveInstall: true packages: - - "packages/*/*" - - "automation/*" + - "packages/*/*" + - "automation/*" catalog: - rollup: "3.29" + rollup: "3.29"