diff --git a/HANDOVER-calendar.md b/HANDOVER-calendar.md new file mode 100644 index 0000000000..820c363fc3 --- /dev/null +++ b/HANDOVER-calendar.md @@ -0,0 +1,144 @@ +# Calendar widget (PR #970) — session handover + +_Working notes for picking this back up. **Delete this file before merge.**_ + +Branch: `wip/calendar-widget` (winter core) · tip when written: `170649685 wip` +Companion: `wintercms/wn-test-plugin` branch `wip/calendar-events` → **PR #25** +Core PR: **wintercms/winter#970** — un-drafted, CI green, ready for review. + +--- + +## TL;DR + +The widget is **working end-to-end and browser-verified**. The roadmap in #970's comment is done except the two explicitly-optional items (full de-vendor of FullCalendar into the build; a broader in-repo Dusk suite). The PHP surface stayed stable as intended; the real work was assets, the Snowboard/v6 JS port, recurrence/timezone, tests, docs — plus fixing several latent bugs the browser pass surfaced. + +Automated coverage: **33 tests green** — 15 `CalendarWidgetTest` + 4 `CalendarControllerTest` + 9 `EventDataTest` (core) + 5 `EventCalendarTest` (test plugin). + +Screenshots + a screen recording of the working widget: `~/Repositories/WinterCMS/Core/calendar-pr-screenshots/` (`01-month-view.png` … `08-event-edit-form.png`, `calendar-walkthrough.webm`). + +--- + +## Commits (this session, on top of the original PR) + +| Commit | What | +|---|---| +| `d4d034edc` | Remove dead FullCalendar **v4** `packages/*` tree (~90 files) | +| `fe9e8fe46` | Recurrence (#3): `applyDateRangeFilter` opt-out + `setApplyDateRangeFilter()` + tests | +| `a0e5b1016` | Timezone (#4): `timezone` config → event output + `data-timezone` + tests | +| `ef8355bc4` | Baseline tests: EventData, cacheKey stability, the 4 extension events | +| `1721b3d38` | `CalendarController` behavior tests | +| `b280cfbfb` | Docs: config options, recurrence patterns, real click-handler examples | +| `4fa3f9212` | **JS → Snowboard port** + FullCalendar v6 API fixes | +| `dbf1985ad` | Pass the visible window (`$startTime`,`$endTime`) to `extendQueryBefore`/`extendQuery` | +| `a09939620` | Core: keep point events with a null end in the window filter | +| `0d37bd02a` | **Snowboard port bug-fixes found in browser testing** (see below) | +| `c851e192a` | phpcs autofixes (style only) | + +Added on the branch **after** my session (by you / other work): `4f6e440b1` NestedForm fix (#1522/#1523), `cf3d94b0e` merge develop, `170649685 wip` (a LESS→CSS recompile of `calendar.css` — colour casing + corrected `.tooltip-arrow` `left:calc(50% - 5px)`). + +--- + +## Key decisions + +- **Build = laravel-mix, not Vite.** The roadmap said "Vite pipeline" but this module builds JS/LESS with `modules/backend/winter.mix.js`. The calendar bundle compiles `assets/js/src/*.js` → `assets/js/dist/calendar.js`, registered in `winter.mix.js` next to the other widgets. `loadAssets()` serves `js/dist/calendar.js`. +- **FullCalendar stays vendored (v6.1.15)** at `assets/vendor/fullcalendar/*`; the plugin uses the `FullCalendar` global. Full de-vendor into npm+mix was deliberately **skipped** — the dead v4 tree (the big diff win) is already gone; the remaining bundle is the real library, so de-vendoring is marginal diff-for-risk. Optional follow-up. +- **Recurrence = server-side (roadmap option b), default-safe.** `applyDateRangeFilter` (config, default `true`) + `setApplyDateRangeFilter(false)` let a consumer keep recurring masters in the query and expand them in `backend.calendar.extendRecords`. Additionally `extendQueryBefore`/`extendQuery` now receive the visible window so consumers can write an efficient recurrence-aware query (`window-intersecting OR has-rrule`). Client-side `rrule` (option a) is documented as an alternative. +- **Timezone**: `timezone` config controls event output (offset-qualified ISO) and is surfaced via `data-timezone`; defaults to `app.timezone`. `moment-timezone` dropped (was in the deleted v4 tree); v6 handles local/UTC natively — **named zones need a FC named-tz plugin** we don't ship (documented). +- **Snowboard port keeps the month-window cache** (`CalendarCache`, moved to an ES module) and bridges to the **still-Storm** toolbar-search/filter widgets via jQuery framework events. + +--- + +## Bugs found & fixed in browser testing (the important part) + +All were latent in the original port; the browser pass surfaced them. + +1. **Month cache timezone mismatch** (`CalendarCache.getMonthRequestData`) — computed day-of-week / month boundaries with the *browser's* local zone while FullCalendar reports timestamps in the *calendar's* zone. When they differ the "is this a month grid?" check failed and snapped to the wrong 42-day window → FullCalendar got the wrong month's events. Now does the math in the calendar's frame (UTC when the calendar runs in UTC). +2. **Point events (null end) dropped** — twice: the SQL filter (`recordEnd >= start` is NULL) and the client bucketing (`Date.parse(undefined)` = NaN). Both now treat a missing end as ending at the start. Core fix in `applyDateRangeToQuery`; JS fix in `saveFirstThreeMonthsData`. +3. **Search/filter wired to the wrong framework events** — the Storm framework fires **`oc.beforeRequest`** (not `wn.beforeRequest`), so the current month window was never injected into search/filter requests and recurring events vanished on search; and the onRefresh payload arrives via **`ajaxSuccess`** (`[context, data, …]`), not the jQuery-native `ajaxComplete`. `onFilterUpdate` now scans its args for the payload rather than assuming a position. + +After these: month/week/day/list, month paging + cache, event click → edit form, search, all-day & date-range filtering, and recurrence expansion all work with **zero console errors**. + +--- + +## How to build the JS/CSS + +```bash +cd modules/backend +export PATH="$(git rev-parse --show-toplevel)/node_modules/.bin:$PATH" +# whole backend bundle (Snowboard, widgets, …): +mix --production # or: mix (dev, unminified) +``` +To build **only** the calendar during iteration (avoids rebuilding every widget's dist), use a throwaway config: +```bash +cat > calendar.mix.js <<'JS' +const mix = require('laravel-mix'); +mix.setPublicPath(__dirname); +mix.js('./widgets/calendar/assets/js/src/Calendar.js', './widgets/calendar/assets/js/dist/calendar.js'); +JS +mix --production --mix-config=calendar.mix.js +rm -f calendar.mix.js mix-manifest.json # mix-manifest.json is NOT tracked — don't commit it +``` +`calendar.css` is compiled from `assets/less/calendar.less` (registered as a LESS bundle in `ServiceProvider::registerAssetBundles`). + +## How to run the tests + +Use the **root** phpunit config, not `modules/backend/phpunit.xml` — the backend config declares no bootstrap, so the Winter ClassLoader isn't registered and new fixtures (`CalendarEventFixture`) fail to autoload. +```bash +vendor/bin/phpunit modules/backend/tests/widgets/CalendarWidgetTest.php +vendor/bin/phpunit modules/backend/tests/widgets/EventDataTest.php +vendor/bin/phpunit modules/backend/tests/behaviors/CalendarControllerTest.php +vendor/bin/phpunit plugins/winter/test/tests/EventCalendarTest.php +``` +Run **phpcs before pushing** (CI's "PHP" job): `vendor/bin/phpcs -nq --extensions=php `; `vendor/bin/phpcbf` autofixes. The original calendar code had 31 style violations (inline control structures, spacing). + +## How to browser-test + +- Site: `https://winter.test` (Herd, MySQL `winter.test`). Backend admin created for automation: **`claude-test` / `ClaudeTest1234`** (superuser — delete when done). +- **Serving quirk:** the docroot is a Winter **public mirror** (`public/` = symlinks). After adding a *new* directory (e.g. the calendar widget dir or a new plugin), run **`php artisan winter:mirror public`** or its assets 404 (served as HTML → Chromium `ERR_BLOCKED_BY_ORB`, silent). +- Playwright is installed (chromium + ffmpeg). Reusable helper + the scripts I used are in this session's scratchpad (`pw-lib.js`, `cal-*.js`) — they log in, drive views/search/filter, and record video/screenshots. Run node with `NODE_PATH=/node_modules`. (Memory also notes a chrome-devtools-MCP + `vite:watch` HMR loop as the preferred fast loop for *other* assets, but the calendar bundle is mix, so rebuild with `mix` between JS edits.) + +Seed demo data (August 2026, incl. recurring masters that start before the window): +```bash +php artisan tinker # then create Winter\Test\Models\Event rows; see EventCalendarTest for shapes +``` + +--- + +## Test plugin (wn-test-plugin, PR #25) + +Adds the fixture the calendar was verified against (supersedes the closed draft #21): +- `models/Event.php` — `winter_test_events` + a minimal RRULE expander `expandOccurrences()` (`FREQ=DAILY/WEEKLY/MONTHLY`, `INTERVAL`, `COUNT`, `UNTIL`; UNTIL compared by day). +- `controllers/Events.php` + `controllers/events/*` — CalendarController + List/Form, toolbar, search, all-day + date-range filter. +- `Plugin::boot()` wires the recommended server-side recurrence pattern for Event calendars. +- `tests/EventCalendarTest.php`, a migration, and a nav item. + +Gotchas for that repo: +- Its `origin` is **SSH** (no key here) — I pushed over HTTPS: `git push https://github.com/wintercms/wn-test-plugin.git wip/calendar-events`. +- Your unrelated WIP there (Record.php, `v2.2.0/` translate-demo migration) was **left untouched** — I staged only calendar files and committed `version.yaml` with *only* my `2.3.0` entry via `git update-index --cacheinfo` so your `2.2.0` stayed uncommitted. If you switch that plugin back to `main`, your WIP restores cleanly. + +--- + +## CI notes + +- **PHP** (phpcs) is green after `c851e192a`. **JavaScript** (eslint) is green — eslint ignores the widget `assets/` paths by pattern, so the ported JS isn't linted. +- **Sub-split** flaked once (`splitsh-lite` >60s splitting `modules/system`) then passed on retry — infra timeout, not a code issue. +- CodeRabbit runs now that the PR is un-drafted (was skipped while draft). + +--- + +## Remaining / follow-ups + +- [ ] (optional) De-vendor FullCalendar v6 into the mix build; pull locales from `@fullcalendar/core/locales-all`. +- [ ] (optional) Broader in-repo Dusk e2e (PR #25 is the fixture). +- [ ] Named-timezone display needs a FullCalendar named-tz plugin if you want non-local/UTC zones rendered correctly. +- [ ] Review the `170649685 wip` CSS recompile and fold it into a real commit / squash before merge. +- [ ] The `recordColor` docblock in `Calendar.php` still says "the default background color in the calendar.less" — fine, just noting. + +## File map + +- Behavior: `modules/backend/behaviors/CalendarController.php` (+ `calendarcontroller/partials/_container.php`, `docs/example.*`) +- Widget: `modules/backend/widgets/Calendar.php`, `widgets/calendar/classes/EventData.php`, `widgets/calendar/partials/_calendar.php` +- JS: `widgets/calendar/assets/js/src/{Calendar,CalendarCache}.js` → `dist/calendar.js` +- Styles: `widgets/calendar/assets/less/calendar.less` → `css/calendar.css` +- Registration: `modules/backend/ServiceProvider.php`, `modules/backend/winter.mix.js` +- Tests: `modules/backend/tests/{widgets/CalendarWidgetTest.php,widgets/EventDataTest.php,behaviors/CalendarControllerTest.php,fixtures/models/CalendarEventFixture.php}` diff --git a/modules/backend/ServiceProvider.php b/modules/backend/ServiceProvider.php index 9ff5e505ad..8a7e327229 100644 --- a/modules/backend/ServiceProvider.php +++ b/modules/backend/ServiceProvider.php @@ -93,6 +93,7 @@ protected function registerAssetBundles() $combiner->registerBundle('~/modules/backend/widgets/table/assets/js/build.js'); $combiner->registerBundle('~/modules/backend/assets/vendor/ace-codeeditor/build.js'); $combiner->registerBundle('~/modules/backend/widgets/mediamanager/assets/js/mediamanager-browser.js'); + $combiner->registerBundle('~/modules/backend/widgets/calendar/assets/less/calendar.less'); $combiner->registerBundle('~/modules/backend/widgets/mediamanager/assets/less/mediamanager.less'); $combiner->registerBundle('~/modules/backend/widgets/reportcontainer/assets/less/reportcontainer.less'); $combiner->registerBundle('~/modules/backend/widgets/table/assets/less/table.less'); diff --git a/modules/backend/behaviors/CalendarController.php b/modules/backend/behaviors/CalendarController.php new file mode 100644 index 0000000000..b6a6bf0470 --- /dev/null +++ b/modules/backend/behaviors/CalendarController.php @@ -0,0 +1,230 @@ +calendarConfig ?: $this->calendarConfig; + $this->setConfig($config, $this->requiredConfig); + } + + /** + * Calendar Controller action + */ + public function calendar(): void + { + $this->controller->pageTitle = $this->controller->pageTitle ? : Lang::get($this->getConfig( + 'title', + 'backend::lang.calendar.title' + )); + $this->controller->bodyClass = 'slim-container'; + $this->makeCalendar(); + } + + /** + * Creates the Calendar widget used by this behavior + */ + public function makeCalendar(): CalendarWidget + { + $model = $this->controller->calendarCreateModelObject(); + + $config = $this->config; + $config->model = $model; + $config->alias = $this->primaryDefinition; + + // Initialize the Calendar widget + $widget = $this->makeWidget(CalendarWidget::class, $config); + $widget->model = $model; + $widget->bindToController(); + $this->calendarWidget = $widget; + + // Initialize the Toolbar & Filter widgets + $this->initToolbar($config, $widget); + $this->initFilter($config, $widget); + + return $widget; + } + + /** + * Prepare the Toolbar widget if necessary + */ + protected function initToolbar(stdClass $config, CalendarWidget $widget): void + { + if (empty($config->toolbar)) { + return; + } + + // Prepare the config and intialize the Toolbar widget + $toolbarConfig = $this->makeConfig($config->toolbar); + $toolbarConfig->alias = $widget->alias . 'Toolbar'; + $toolbarWidget = $this->makeWidget(ToolbarWidget::class, $toolbarConfig); + $toolbarWidget->bindToController(); + $toolbarWidget->cssClasses[] = 'list-header'; + + /* + * Link the Search widget to the Calendar widget + */ + if ($searchWidget = $toolbarWidget->getSearchWidget()) { + $searchWidget->bindEvent('search.submit', function () use ($widget, $searchWidget) { + $widget->setSearchTerm($searchWidget->getActiveTerm()); + return $widget->onRefresh(); + }); + + $widget->setSearchOptions([ + 'mode' => $searchWidget->mode, + 'scope' => $searchWidget->scope, + ]); + + // Find predefined search term + $widget->setSearchTerm($searchWidget->getActiveTerm()); + } + + $this->toolbarWidget = $toolbarWidget; + } + + /** + * Prepare the Filter widget if necessary + */ + protected function initFilter(stdClass $config, CalendarWidget $widget): void + { + if (empty($config->filter)) { + return; + } + + $widget->cssClasses[] = 'list-flush'; + + // Prepare the config and intialize the Toolbar widget + $filterConfig = $this->makeConfig($config->filter); + $filterConfig->alias = $widget->alias . 'Filter'; + $filterWidget = $this->makeWidget(FilterWidget::class, $filterConfig); + $filterWidget->bindToController(); + + /* + * Filter the Calendar when the scopes are changed + */ + $filterWidget->bindEvent('filter.update', function () use ($widget, $filterWidget) { + return $widget->onFilter(); + }); + + // Apply predefined filter values + $widget->addFilter([$filterWidget, 'applyAllScopesToQuery']); + $this->filterWidget = $filterWidget; + $widget->filterWidget = $this->filterWidget; + } + + /** + * Creates a new instance of a calendar model. This logic can be changed by overriding it in the controller. + */ + public function calendarCreateModelObject(): Model + { + $class = $this->config->modelClass; + return new $class; + } + + /** + * Render the calendar widget + * + * @throws ApplicationException if the calendar widget has not been initialized + */ + public function calendarRender($options = []): string + { + if (empty($this->calendarWidget)) { + throw new ApplicationException(Lang::get('backend::lang.calendar.behavior_not_ready')); + } + + if (!empty($options['readOnly']) || !empty($options['disabled'])) { + $this->calendarWidget->previewMode = true; + } + + if (isset($options['preview'])) { + $this->calendarWidget->previewMode = $options['preview']; + } + + return $this->calendarMakePartial('container', [ + 'toolbar' => $this->toolbarWidget, + 'filter' => $this->filterWidget, + 'calendar' => $this->calendarWidget, + ]); + } + + /** + * Render the requested partial, providing opportunity for the controller to take over + */ + public function calendarMakePartial(string $partial, array $params = []): string + { + $contents = $this->controller->makePartial('calendar_' . $partial, $params, false); + if (!$contents) { + $contents = $this->makePartial($partial, $params); + } + return $contents; + } +} diff --git a/modules/backend/behaviors/calendarcontroller/docs/example.config_calendar.yaml b/modules/backend/behaviors/calendarcontroller/docs/example.config_calendar.yaml new file mode 100644 index 0000000000..3b824c3358 --- /dev/null +++ b/modules/backend/behaviors/calendarcontroller/docs/example.config_calendar.yaml @@ -0,0 +1,122 @@ +# =================================== +# Calendar Behavior Config +# =================================== + +# Model to use for getting the records to display on the calendar +modelClass: Author\Plugin\Models\Event + +# Calendar Title +title: 'backend::lang.calendar.title' + +# Search columns +# Used for configuration of additional columns to search by (columns.yaml format) +searchList: $/author/plugin/models/event/columns.yaml + +# Record URL +# Link opened when an event is clicked. Replace :id with the record id. +recordUrl: author/plugin/events/update/:id + +# Record property used as the title displayed on the calendar +recordTitle: name + +# Record property used as the start time +recordStart: start_at + +# Record property used as the end time +recordEnd: end_at + +# Record property used as all day long event +recordAllDay: all_day + +# Record property used as the background color for the event +# '' = the default background color defined by the calendar styles +recordColor: event_color + +# Record property (or array of config keys) used as the content of the tooltip +recordTooltip: [recordTitle] + +# Calendar widget theme color for buttons ('' for default, primary or secondary) +calendarTheme: + +# Available display modes to be supported in this instance +availableDisplayModes: [month, week, day, list] + +# Default view for calendar widget (month, week, day or list) +initialView: month + +# First day of week, 0=Sun, 1=Mon ... +firstDay: 0 + +# Timezone used when rendering event times. +# Event start/end values are emitted as offset-qualified ISO-8601 strings computed in this +# timezone, and the value is passed to the frontend so FullCalendar buckets events into the +# same zone. Defaults to the application timezone (app.timezone) when omitted. +# timezone: America/New_York + +# Whether the widget applies its built-in visible-window date-range filter (default true). +# Leave enabled for regular (non-recurring) records - it keeps month views efficient by only +# fetching records that intersect the visible window. See "Recurring events" below for when to +# disable it. +applyDateRangeFilter: true + +# Flag for whether calendar is read only or editable +previewMode: true + +# Toolbar widget configuration +toolbar: + # Partial for toolbar buttons + buttons: calendar_toolbar + + # Search widget configuration + search: + prompt: backend::lang.list.search_prompt + +# The filter config file for the controller +# When a filter is applied the client's event cache is cleared and reloaded for the current +# window with the new filters applied. +filter: calendar_filter.yaml + +# =================================== +# Recurring events +# =================================== +# The widget filters records to the visible window at the database level *before* the +# backend.calendar.extendRecords event fires. A recurring master row whose base start date +# falls outside the window would therefore be dropped before you could expand it. There are +# two supported patterns: +# +# (a) Client-side expansion - emit an `rrule` property on the event objects (in +# backend.calendar.extendEvents) and let FullCalendar's rrule plugin expand occurrences in +# the browser. Simplest for consumers. +# +# (b) Server-side expansion (recommended default) - set `applyDateRangeFilter: false` (or call +# $calendarWidget->setApplyDateRangeFilter(false) from a backend.calendar.extendQueryBefore +# listener) so recurring master rows survive the query, then expand them into concrete +# occurrences for the window in a backend.calendar.extendRecords listener. Keeping the +# window constraint out of the base query also keeps the client-side month cache key stable. +# The extendQueryBefore / extendQuery events receive the visible window ($startTime, +# $endTime as Unix timestamps) so you can add a recurrence-aware constraint that still keeps +# non-recurring rows efficient, e.g. "rows intersecting the window OR rows with an rrule". + +# =================================== +# Click handlers +# =================================== +# Record on click +# @see example.custom.calendar.js for a sample controller implementation. +# The handler receives the following arguments: +# data: a plain object { startDate, endDate, event, eventEl } +# startDate: a JS Date object +# endDate: a JS Date object, may be null +# event: the FullCalendar event object (id, title, start, end, ...) +# eventEl: the HTML element for this event +# recordOnClick: $.wn.eventCalendar.onEventClick(:data, :startDate, :endDate, :event, :eventEl) + +# Triggered when the user clicks on a date or a time. +# The handler receives the following arguments: +# data: a plain object { date, dateStr, allDay, dayEl, event, view } +# date: a JS Date object for the clicked day/time +# dateStr: an ISO-8601 string representation of the date +# allDay: true or false +# dayEl: the HTML element representing the clicked day +# event: the native JS event (click coordinates, etc.) +# view: the current view @see https://fullcalendar.io/docs/view-object +onClickDate: $.wn.eventCalendar.onClickDate(:data, :date, :dateStr, :allDay, :dayEl, :event, :view) diff --git a/modules/backend/behaviors/calendarcontroller/docs/example.custom.calendar.js b/modules/backend/behaviors/calendarcontroller/docs/example.custom.calendar.js new file mode 100644 index 0000000000..86dbd41bb0 --- /dev/null +++ b/modules/backend/behaviors/calendarcontroller/docs/example.custom.calendar.js @@ -0,0 +1,36 @@ +/* + * Sample click handlers for the Calendar behavior. + * + * Wire these up from config_calendar.yaml: + * + * recordOnClick: $.wn.eventCalendar.onEventClick(:data, :startDate, :endDate, :event, :eventEl) + * onClickDate: $.wn.eventCalendar.onClickDate(:data, :date, :dateStr, :allDay, :dayEl, :event, :view) + * + * The object referenced by the config (here `$.wn.eventCalendar`) is resolved and invoked when + * the corresponding interaction occurs. + */ ++function ($) { + "use strict"; + + var EventCalendar = function () { + + // Called when an existing event is clicked. + this.onEventClick = function (data, startDate, endDate, event, eventEl) { + // `event` is the FullCalendar event object; open the record it points to. + if (event.url) { + window.location.href = event.url; + } + }; + + // Called when an empty date/time cell is clicked, e.g. to create a new event. + this.onClickDate = function (data, date, dateStr, allDay, dayEl, event, view) { + // For example, open the create form pre-filled with the clicked date. + window.location.href = 'author/plugin/events/create?start_at=' + encodeURIComponent(dateStr); + }; + + }; + + $.wn = $.wn || {}; + $.wn.eventCalendar = new EventCalendar(); + +}(window.jQuery); diff --git a/modules/backend/behaviors/calendarcontroller/partials/_container.php b/modules/backend/behaviors/calendarcontroller/partials/_container.php new file mode 100644 index 0000000000..6f9a9a19f2 --- /dev/null +++ b/modules/backend/behaviors/calendarcontroller/partials/_container.php @@ -0,0 +1,9 @@ + + render() ?> + + + + render() ?> + + +render() ?> diff --git a/modules/backend/lang/en/lang.php b/modules/backend/lang/en/lang.php index c519e8b48e..ef7f61c140 100644 --- a/modules/backend/lang/en/lang.php +++ b/modules/backend/lang/en/lang.php @@ -215,6 +215,10 @@ 'trashed_hint_title' => 'This account has been deleted', 'trashed_hint_desc' => 'This account has been deleted and will be unable to be signed in under. To restore it, click the restore user icon in the bottom right', ], + 'calendar' => [ + 'title' => 'Calendar', + 'behavior_not_ready' => 'Calendar behavior has not been initialized, check that you have called makeCalendar() in your controller.', + ], 'list' => [ 'default_title' => 'List', 'search_prompt' => 'Search...', diff --git a/modules/backend/tests/behaviors/CalendarControllerTest.php b/modules/backend/tests/behaviors/CalendarControllerTest.php new file mode 100644 index 0000000000..9bf5a6d4ed --- /dev/null +++ b/modules/backend/tests/behaviors/CalendarControllerTest.php @@ -0,0 +1,124 @@ + CalendarEventFixture::class, + 'searchList' => ['columns' => ['name' => ['label' => 'Name', 'searchable' => true]]], + 'recordTitle' => 'name', + 'recordStart' => 'start_at', + 'recordEnd' => 'end_at', + 'recordAllDay' => 'all_day', + 'initialView' => 'week', + 'firstDay' => 1, + ]; +} + +/** + * A controller wired with a toolbar (search) so the search-linking path in makeCalendar() + * is exercised. + */ +class CalendarSearchController extends Controller +{ + public $implement = [\Backend\Behaviors\CalendarController::class]; + + public $calendarConfig = [ + 'modelClass' => CalendarEventFixture::class, + 'searchList' => ['columns' => ['name' => ['label' => 'Name', 'searchable' => true]]], + 'toolbar' => [ + 'search' => ['prompt' => 'Search events'], + ], + ]; +} + +/** + * A controller missing the required `modelClass` config key. + */ +class CalendarInvalidController extends Controller +{ + public $implement = [\Backend\Behaviors\CalendarController::class]; + + public $calendarConfig = [ + 'searchList' => ['columns' => []], + ]; +} + +/** + * Coverage for the CalendarController behavior's config loading and widget wiring. + */ +class CalendarControllerTest extends PluginTestCase +{ + public function setUp(): void + { + parent::setUp(); + + CalendarEventFixture::migrateUp(); + + $this->actingAs(new UserFixture); + } + + public function tearDown(): void + { + CalendarEventFixture::migrateDown(); + + parent::tearDown(); + } + + public function testMakeCalendarBuildsWidgetBoundToModelAndConfig() + { + $controller = new CalendarTestController; + $widget = $controller->makeCalendar(); + + $this->assertInstanceOf(CalendarWidget::class, $widget); + $this->assertInstanceOf(CalendarEventFixture::class, $widget->model); + + // Config values flow through to the widget. + $this->assertSame('start_at', $widget->recordStart); + $this->assertSame('end_at', $widget->recordEnd); + $this->assertSame('week', $widget->initialView); + $this->assertSame(1, $widget->firstDay); + } + + public function testCalendarCreateModelObjectReturnsAFreshModel() + { + $controller = new CalendarTestController; + + $first = $controller->calendarCreateModelObject(); + $second = $controller->calendarCreateModelObject(); + + $this->assertInstanceOf(CalendarEventFixture::class, $first); + $this->assertNotSame($first, $second); + } + + public function testMissingModelClassThrows() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/modelClass/'); + + new CalendarInvalidController; + } + + public function testToolbarSearchWiringDoesNotBreakMakeCalendar() + { + $controller = new CalendarSearchController; + + // Exercises initToolbar()'s search widget construction and the search.submit binding. + $widget = $controller->makeCalendar(); + + $this->assertInstanceOf(CalendarWidget::class, $widget); + } +} diff --git a/modules/backend/tests/fixtures/models/CalendarEventFixture.php b/modules/backend/tests/fixtures/models/CalendarEventFixture.php new file mode 100644 index 0000000000..2c96e11d3c --- /dev/null +++ b/modules/backend/tests/fixtures/models/CalendarEventFixture.php @@ -0,0 +1,58 @@ + 'boolean', + ]; + + /** + * Create the backing table if it does not already exist. + */ + public static function migrateUp(): void + { + if (Schema::hasTable('backend_test_calendar_events')) { + return; + } + + Schema::create('backend_test_calendar_events', function ($table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->dateTime('start_at')->nullable(); + $table->dateTime('end_at')->nullable(); + $table->boolean('all_day')->default(false); + $table->string('color')->nullable(); + $table->string('rrule')->nullable(); + }); + } + + /** + * Drop the backing table. + */ + public static function migrateDown(): void + { + Schema::dropIfExists('backend_test_calendar_events'); + } +} diff --git a/modules/backend/tests/widgets/CalendarWidgetTest.php b/modules/backend/tests/widgets/CalendarWidgetTest.php new file mode 100644 index 0000000000..f12faf005a --- /dev/null +++ b/modules/backend/tests/widgets/CalendarWidgetTest.php @@ -0,0 +1,352 @@ +windowStart = Carbon::parse('2026-03-01 00:00:00', 'UTC')->timestamp; + $this->windowEnd = Carbon::parse('2026-04-01 00:00:00', 'UTC')->timestamp; + } + + public function tearDown(): void + { + CalendarEventFixture::migrateDown(); + + parent::tearDown(); + } + + /** + * Builds a Calendar widget bound to the fixture model with the default record mapping. + */ + protected function makeCalendarWidget(array $config = []): Calendar + { + $model = new CalendarEventFixture; + + $widget = new Calendar(null, array_merge([ + 'alias' => 'calendar', + 'recordTitle' => 'name', + 'recordStart' => 'start_at', + 'recordEnd' => 'end_at', + 'recordAllDay' => 'all_day', + ], $config)); + + $widget->model = $model; + + return $widget; + } + + /** + * Seeds a single event and returns the created model. + */ + protected function seedEvent(string $name, string $start, ?string $end = null, array $attributes = []): CalendarEventFixture + { + Model::unguard(); + $event = CalendarEventFixture::create(array_merge([ + 'name' => $name, + 'start_at' => $start, + 'end_at' => $end, + 'all_day' => false, + ], $attributes)); + Model::reguard(); + + return $event; + } + + /** + * Returns the event titles from a getRecords() result payload. + */ + protected function titlesFrom(array $result): array + { + return array_map(fn ($event) => $event['title'], $result['events']); + } + + public function testDateRangeFilterLimitsRecordsToVisibleWindow() + { + $this->seedEvent('inside', '2026-03-10 09:00:00', '2026-03-10 10:00:00'); + $this->seedEvent('before', '2026-02-10 09:00:00', '2026-02-10 10:00:00'); + $this->seedEvent('after', '2026-04-10 09:00:00', '2026-04-10 10:00:00'); + $this->seedEvent('spanning', '2026-02-25 09:00:00', '2026-03-02 10:00:00'); + + $widget = $this->makeCalendarWidget(); + $titles = $this->titlesFrom($widget->getRecords($this->windowStart, $this->windowEnd)); + + sort($titles); + $this->assertSame(['inside', 'spanning'], $titles); + } + + public function testGetRecordsWithoutWindowReturnsEverything() + { + $this->seedEvent('inside', '2026-03-10 09:00:00', '2026-03-10 10:00:00'); + $this->seedEvent('before', '2026-02-10 09:00:00', '2026-02-10 10:00:00'); + + $widget = $this->makeCalendarWidget(); + + // No window supplied - the range filter is a no-op and every record is returned. + $this->assertCount(2, $widget->getRecords()['events']); + } + + public function testDateRangeFilterKeepsPointEventsWithoutAnEnd() + { + // A point event (no end) whose start is inside the window must survive the filter... + $this->seedEvent('point-inside', '2026-03-10 09:00:00', null); + // ...while one before the window is still excluded. + $this->seedEvent('point-before', '2026-02-10 09:00:00', null); + + $widget = $this->makeCalendarWidget(); + $titles = $this->titlesFrom($widget->getRecords($this->windowStart, $this->windowEnd)); + + $this->assertSame(['point-inside'], $titles); + } + + public function testDateRangeFilterCanBeDisabledViaConfig() + { + $this->seedEvent('inside', '2026-03-10 09:00:00', '2026-03-10 10:00:00'); + $this->seedEvent('before', '2026-02-10 09:00:00', '2026-02-10 10:00:00'); + $this->seedEvent('after', '2026-04-10 09:00:00', '2026-04-10 10:00:00'); + + $widget = $this->makeCalendarWidget(['applyDateRangeFilter' => false]); + $titles = $this->titlesFrom($widget->getRecords($this->windowStart, $this->windowEnd)); + + // With the built-in filter disabled the widget no longer constrains to the window; + // the consumer is responsible for any windowing (e.g. in extendQuery). + sort($titles); + $this->assertSame(['after', 'before', 'inside'], $titles); + } + + public function testRecurringMasterOutsideWindowIsDroppedByDefault() + { + // A monthly recurring master whose base start sits well before the visible window. + $this->seedEvent('recurring', '2026-01-01 09:00:00', '2026-01-01 10:00:00', [ + 'rrule' => 'FREQ=MONTHLY;COUNT=12', + ]); + + $widget = $this->makeCalendarWidget(); + + // Without an opt-out the master is filtered out at the database level before a consumer + // ever sees it - this is the behaviour the recurrence opt-out exists to work around. + $this->assertCount(0, $widget->getRecords($this->windowStart, $this->windowEnd)['events']); + } + + public function testExtendQueryListenerCanDisableFilterSoRecurrenceExpandsInExtendRecords() + { + $this->seedEvent('recurring', '2026-01-01 09:00:00', '2026-01-01 10:00:00', [ + 'rrule' => 'FREQ=MONTHLY;COUNT=12', + ]); + + $widget = $this->makeCalendarWidget(); + + // A consumer that expands recurrence server-side turns off the built-in window filter + // so the master row survives the query... + $widget->bindEvent('calendar.extendQueryBefore', function () use ($widget) { + $widget->setApplyDateRangeFilter(false); + }); + + // ...then expands the surviving master into concrete occurrences for the window. + $widget->bindEvent('calendar.extendRecords', function (&$records, $startTime, $endTime) { + $this->assertGreaterThan(0, $records->count(), 'Recurring master should survive the query'); + + $occurrences = collect(); + foreach ($records as $master) { + $occurrence = new CalendarEventFixture([ + 'name' => $master->name, + 'start_at' => Carbon::createFromTimestamp($startTime, 'UTC')->addDays(9)->setTime(9, 0)->format('Y-m-d H:i:s'), + 'end_at' => Carbon::createFromTimestamp($startTime, 'UTC')->addDays(9)->setTime(10, 0)->format('Y-m-d H:i:s'), + 'all_day' => false, + ]); + $occurrences->push($occurrence); + } + + return $occurrences; + }); + + $result = $widget->getRecords($this->windowStart, $this->windowEnd); + + $this->assertCount(1, $result['events']); + $this->assertSame('recurring', $result['events'][0]['title']); + $this->assertStringStartsWith('2026-03-10', $result['events'][0]['start']); + } + + public function testTimezoneDefaultsToApplicationTimezone() + { + Config::set('app.timezone', 'America/Toronto'); + + $widget = $this->makeCalendarWidget(); + + $this->assertSame('America/Toronto', $widget->getTimezone()); + } + + public function testTimezoneConfigControlsEventOutputOffset() + { + $this->seedEvent('meeting', '2026-03-10 09:00:00', '2026-03-10 10:00:00'); + + // Tokyo has no DST, so the offset is unambiguous. + $tokyo = $this->makeCalendarWidget(['timezone' => 'Asia/Tokyo']); + $tokyoEvent = $tokyo->getRecords($this->windowStart, $this->windowEnd)['events'][0]; + $this->assertSame('2026-03-10T09:00:00+09:00', $tokyoEvent['start']); + + $utc = $this->makeCalendarWidget(['timezone' => 'UTC']); + $utcEvent = $utc->getRecords($this->windowStart, $this->windowEnd)['events'][0]; + $this->assertSame('2026-03-10T09:00:00+00:00', $utcEvent['start']); + } + + public function testCacheKeyIsStableAcrossWindows() + { + $this->seedEvent('inside', '2026-03-10 09:00:00', '2026-03-10 10:00:00'); + + $widget = $this->makeCalendarWidget(); + + // The cache key is derived from the base query only (not the visible window), so the + // client can key its per-month cache by it and reuse it as the user pages months. + $march = $widget->getRecords($this->windowStart, $this->windowEnd)['cacheKey']; + $april = $widget->getRecords( + Carbon::parse('2026-04-01 00:00:00', 'UTC')->timestamp, + Carbon::parse('2026-05-01 00:00:00', 'UTC')->timestamp + )['cacheKey']; + + $this->assertNotEmpty($march); + $this->assertSame($march, $april); + } + + public function testCacheKeyChangesWhenTheQueryChanges() + { + $this->seedEvent('inside', '2026-03-10 09:00:00', '2026-03-10 10:00:00'); + + $baseline = $this->makeCalendarWidget()->getRecords($this->windowStart, $this->windowEnd)['cacheKey']; + + $constrained = $this->makeCalendarWidget(); + $constrained->bindEvent('calendar.extendQueryBefore', function ($query) { + $query->where('color', '#ff0000'); + }); + $constrainedKey = $constrained->getRecords($this->windowStart, $this->windowEnd)['cacheKey']; + + $this->assertNotSame($baseline, $constrainedKey); + } + + public function testAllExtensionEventsFireInOrder() + { + $this->seedEvent('inside', '2026-03-10 09:00:00', '2026-03-10 10:00:00'); + + $widget = $this->makeCalendarWidget(); + + $fired = []; + $widget->bindEvent('calendar.extendQueryBefore', function () use (&$fired) { + $fired[] = 'extendQueryBefore'; + }); + $widget->bindEvent('calendar.extendQuery', function () use (&$fired) { + $fired[] = 'extendQuery'; + }); + $widget->bindEvent('calendar.extendRecords', function () use (&$fired) { + $fired[] = 'extendRecords'; + }); + $widget->bindEvent('calendar.extendEvents', function () use (&$fired) { + $fired[] = 'extendEvents'; + }); + + $widget->getRecords($this->windowStart, $this->windowEnd); + + $this->assertSame(['extendQueryBefore', 'extendQuery', 'extendRecords', 'extendEvents'], $fired); + } + + public function testExtendQueryCanReplaceTheQuery() + { + $this->seedEvent('inside', '2026-03-10 09:00:00', '2026-03-10 10:00:00'); + + $widget = $this->makeCalendarWidget(); + $widget->bindEvent('calendar.extendQuery', function ($query) { + return $query->whereRaw('1 = 0'); + }); + + $this->assertCount(0, $widget->getRecords($this->windowStart, $this->windowEnd)['events']); + } + + public function testExtendEventsCanMutateOutput() + { + $this->seedEvent('inside', '2026-03-10 09:00:00', '2026-03-10 10:00:00'); + + $widget = $this->makeCalendarWidget(); + $widget->bindEvent('calendar.extendEvents', function (&$events) { + $events[] = ['title' => 'injected', 'start' => '2026-03-15']; + return $events; + }); + + $titles = $this->titlesFrom($widget->getRecords($this->windowStart, $this->windowEnd)); + $this->assertContains('injected', $titles); + $this->assertContains('inside', $titles); + } + + public function testExtendQueryEventsReceiveTheVisibleWindow() + { + $this->seedEvent('inside', '2026-03-10 09:00:00', '2026-03-10 10:00:00'); + + $widget = $this->makeCalendarWidget(); + $received = []; + $widget->bindEvent('calendar.extendQueryBefore', function ($query, $startTime, $endTime) use (&$received) { + $received['before'] = [$startTime, $endTime]; + }); + $widget->bindEvent('calendar.extendQuery', function ($query, $startTime, $endTime) use (&$received) { + $received['query'] = [$startTime, $endTime]; + }); + + $widget->getRecords($this->windowStart, $this->windowEnd); + + $this->assertSame([$this->windowStart, $this->windowEnd], $received['before']); + $this->assertSame([$this->windowStart, $this->windowEnd], $received['query']); + } + + public function testRecurrenceAwareQueryUsingTheWindowTimes() + { + $this->seedEvent('before', '2026-02-10 09:00:00', '2026-02-10 10:00:00'); + $this->seedEvent('inside', '2026-03-10 09:00:00', '2026-03-10 10:00:00'); + $this->seedEvent('recurring', '2026-01-01 09:00:00', '2026-01-01 10:00:00', ['rrule' => 'FREQ=MONTHLY']); + + $widget = $this->makeCalendarWidget(); + + // The efficient server-side recurrence pattern the window times enable: replace the + // built-in filter with one that keeps rows intersecting the window OR recurring masters. + $widget->bindEvent('calendar.extendQueryBefore', function ($query, $startTime, $endTime) use ($widget) { + $widget->setApplyDateRangeFilter(false); + $start = Carbon::createFromTimestamp($startTime); + $end = Carbon::createFromTimestamp($endTime); + $query->where(function ($q) use ($start, $end) { + $q->whereNotNull('rrule') + ->orWhere(function ($inner) use ($start, $end) { + $inner->where('end_at', '>=', $start)->where('start_at', '<', $end); + }); + }); + }); + + $titles = $this->titlesFrom($widget->getRecords($this->windowStart, $this->windowEnd)); + sort($titles); + + // 'before' is dropped (outside window, not recurring); 'inside' and the 'recurring' master survive. + $this->assertSame(['inside', 'recurring'], $titles); + } +} diff --git a/modules/backend/tests/widgets/EventDataTest.php b/modules/backend/tests/widgets/EventDataTest.php new file mode 100644 index 0000000000..26e93663bf --- /dev/null +++ b/modules/backend/tests/widgets/EventDataTest.php @@ -0,0 +1,105 @@ + 'Holiday', 'start' => '2026-03-10']); + + $this->assertTrue($event->allDay); + $this->assertSame('2026-03-10', $event->toArray()['start']); + } + + public function testTreatsDatetimeStringsAsTimed() + { + $event = new EventData(['title' => 'Meeting', 'start' => '2026-03-10 09:00:00'], new DateTimeZone('UTC')); + + $this->assertFalse($event->allDay); + $this->assertSame('2026-03-10T09:00:00+00:00', $event->toArray()['start']); + } + + public function testExplicitAllDayOverridesDetection() + { + $event = new EventData([ + 'title' => 'All day meeting', + 'start' => '2026-03-10 09:00:00', + 'allDay' => true, + ]); + + $this->assertTrue($event->allDay); + // An explicit allDay event is emitted date-only regardless of the source time. + $this->assertSame('2026-03-10', $event->toArray()['start']); + } + + public function testForcesConfiguredTimezoneOnTimedEvents() + { + // Tokyo has no DST, so the offset is unambiguous. + $event = new EventData(['title' => 'Standup', 'start' => '2026-03-10 09:00:00'], new DateTimeZone('Asia/Tokyo')); + + $this->assertSame('2026-03-10T09:00:00+09:00', $event->toArray()['start']); + } + + public function testAllDayEventsIgnoreTimezone() + { + // A date-only value must not be shifted across a day boundary by the timezone. + $event = new EventData(['title' => 'Holiday', 'start' => '2026-03-10'], new DateTimeZone('Asia/Tokyo')); + + $this->assertTrue($event->allDay); + $this->assertSame('2026-03-10', $event->toArray()['start']); + } + + public function testIncludesEndWhenProvidedAndOmitsWhenNot() + { + $withEnd = new EventData([ + 'title' => 'Meeting', + 'start' => '2026-03-10 09:00:00', + 'end' => '2026-03-10 10:00:00', + ], new DateTimeZone('UTC')); + $this->assertSame('2026-03-10T10:00:00+00:00', $withEnd->toArray()['end']); + + $withoutEnd = new EventData(['title' => 'Meeting', 'start' => '2026-03-10 09:00:00'], new DateTimeZone('UTC')); + $this->assertArrayNotHasKey('end', $withoutEnd->toArray()); + } + + public function testPassesThroughAdditionalProperties() + { + $event = new EventData([ + 'title' => 'Meeting', + 'start' => '2026-03-10 09:00:00', + 'id' => 42, + 'url' => 'https://example.test/events/42', + 'color' => '#ff0000', + 'tooltip' => 'Weekly sync', + ], new DateTimeZone('UTC')); + + $array = $event->toArray(); + $this->assertSame(42, $array['id']); + $this->assertSame('https://example.test/events/42', $array['url']); + $this->assertSame('#ff0000', $array['color']); + $this->assertSame('Weekly sync', $array['tooltip']); + $this->assertSame('Meeting', $array['title']); + } + + public function testRequiresTitle() + { + $this->expectException(ApplicationException::class); + new EventData(['start' => '2026-03-10 09:00:00']); + } + + public function testRequiresStart() + { + $this->expectException(ApplicationException::class); + new EventData(['title' => 'Meeting']); + } +} diff --git a/modules/backend/widgets/Calendar.php b/modules/backend/widgets/Calendar.php new file mode 100644 index 0000000000..bf86fd5ec0 --- /dev/null +++ b/modules/backend/widgets/Calendar.php @@ -0,0 +1,1051 @@ + 'multiMonthYear', + 'month' => 'dayGridMonth', + 'week' => 'timeGridWeek', + 'day' => 'timeGridDay', + 'list' => 'listMonth' + ]; + + /** + * Collection of functions to apply to each list query. + */ + protected array $filterCallbacks = []; + + /** + * @inheritDoc + */ + protected $defaultAlias = 'calendar'; + + /** + * Render this form with uneditable preview data. + */ + public bool $previewMode = true; + + /** + * Instantiated search columns ['name' => ListColumn] + */ + protected array $searchColumns = []; + + public $searchTerm; + public $searchMode; + public $searchScope; + + public $filterWidget; + + public $searchableColumns = null; + public $visibleColumns = null; + public $calendarVisibleColumns = []; + + /** + * @inheritDoc + */ + public function init() + { + $this->fillFromConfig([ + // 'model', + 'columns', + 'recordUrl', + 'recordOnClick', + 'onClickDate', + 'recordTitle', + 'recordStart', + 'recordEnd', + 'recordAllDay', + 'recordColor', + 'recordTooltip', + 'previewMode', + 'searchList', + 'calendarTheme', + 'availableDisplayModes', + 'initialView', + 'firstDay', + 'applyDateRangeFilter', + 'timezone', + ]); + + // Initialize the search columns + $list = $this->makeConfig($this->searchList); + $columns = []; + if (!empty($list->columns)) { + foreach ($list->columns as $name => $config) { + $columns[$name] = $this->makeListColumn($name, $config); + } + } + $this->searchColumns = $columns; + + $this->calendarVisibleColumns = [ + $this->recordTitle, + $this->recordStart, + $this->recordEnd, + ]; + + // $this->validateModel(); + } + + /** + * Returns the record URL address for a calendar event. + */ + public function getRecordUrl(Model $record): ?string + { + if (!empty($this->recordOnClick)) { + // return 'javascript:;'; + return $this->recordOnClick; + } + + if (!isset($this->recordUrl)) { + return null; + } + + $url = RouterHelper::replaceParameters($record, $this->recordUrl); + return Backend::url($url); + } + + /** + * @inheritDoc + */ + protected function loadAssets() + { + $this->addJs('vendor/fullcalendar/index.global.min.js', '6.1.15'); + $this->addJs('vendor/fullcalendar/locales-all.global.min.js', '6.1.15'); + + // $this->addCss(['less/calendar.less'], 'Winter.Core'); + $this->addCss('css/calendar.css', 'Winter.Core'); + $this->addJs('js/dist/calendar.js', 'core'); + } + + /** + * @inheritDoc + */ + public function prepareVars() + { + if (!empty($this->calendarTheme)) { + $this->cssClasses[] = $this->calendarTheme; + } + + $this->vars['availableDisplayModes'] = $this->getDisplayModes(); + $this->vars['initialView'] = $this->getInitialView(); + $this->vars['firstDay'] = $this->firstDay; + $this->vars['timezone'] = $this->getTimezone(); + $this->vars['cssClasses'] = implode(' ', $this->cssClasses); + } + + /** + * Returns the timezone identifier used for rendering event times, falling back to the + * application timezone when one has not been explicitly configured. + */ + public function getTimezone(): string + { + return $this->timezone !== '' ? $this->timezone : Config::get('app.timezone', 'UTC'); + } + + /** + * Validate the supplied form model. + * + * @return mixed + */ + protected function validateModel() + { + if (!$this->model) { + throw new ApplicationException(Lang::get( + 'backend::lang.form.missing_model', + ['class'=>get_class($this->controller)] + )); + } + + $this->data = isset($this->data) + ? (object) $this->data + : $this->model; + + return $this->model; + } + + /** + * Get the fullcalendar.js initial view to be used + */ + protected function getInitialView(): string + { + if (!empty($this->fullCalendarModes[$this->initialView])) { + return $this->fullCalendarModes[$this->initialView]; + } + + return 'dayGridMonth'; + } + + /** + * Get the fullcalendar.js display modes to be used + */ + protected function getDisplayModes(): string + { + // Convert our display modes to FullCalendar display modes + if (!is_array($this->availableDisplayModes)) { + $this->availableDisplayModes = [$this->availableDisplayModes]; + } + + $selectedModes = []; + foreach ($this->availableDisplayModes as $mode) { + if (!empty($this->fullCalendarModes[$mode])) { + $selectedModes[] = $this->fullCalendarModes[$mode]; + } + } + + return implode(',', $selectedModes); + } + + /** + * Render the widget + */ + public function render(): string + { + $this->prepareVars(); + return $this->makePartial('calendar'); + } + + /** + * Copy from Lists.php + * Checks if a column refers to a pivot model specifically. + * @param ListColumn $column List column object + */ + protected function isColumnPivot($column): bool + { + if (!isset($column->relation) || $column->relation != 'pivot') { + return false; + } + + return true; + } + + protected function isColumnInCalendar($column) + { + return in_array($column->columnName, $this->calendarVisibleColumns); + } + + protected function isColumnRelated($column, $multi = false) + { + if (!isset($column->relation) || $this->isColumnPivot($column)) { + return false; + } + + if (!$this->model->hasRelation($column->relation)) { + throw new ApplicationException(Lang::get( + 'backend::lang.model.missing_relation', + ['class' => get_class($this->model), 'relation' => $column->relation] + )); + } + + if (!$multi) { + return true; + } + + $relationType = $this->model->getRelationType($column->relation); + + return in_array($relationType, [ + 'hasMany', + 'belongsToMany', + 'morphToMany', + 'morphedByMany', + 'morphMany', + 'attachMany', + 'hasManyThrough' + ]); + } + + /** + * Returns a collection of columns which can be searched. + * @return array + */ + protected function getSearchableColumns() + { + if ($this->searchableColumns != null) { + return $this->searchableColumns; + } + $searchable = []; + + foreach ($this->searchColumns as $column) { + if (empty($column->searchable)) { + continue; + } + + $searchable[] = $column; + } + $this->searchableColumns = $searchable; + return $searchable; + } + + protected function getVisibleRelationColumns() + { + if ($this->visibleColumns != null) { + return $this->visibleColumns; + } + + $defaultColumnNames = $this->calendarVisibleColumns; + $searchableColumns = $this->getSearchableColumns(); + $searchableColumnNames = []; + foreach ($searchableColumns as $column) { + $searchableColumnNames[] = $column->columnName; + } + + $visibleColumns = array_unique(array_merge($defaultColumnNames, $searchableColumnNames)); + + $this->visibleColumns = []; + foreach ($this->searchColumns as $name => $column) { + if (in_array($name, $visibleColumns)) { + $this->visibleColumns[$name] = $column; + } + } + return $this->visibleColumns; + } + + /** + * Replaces the @ symbol with a table name in a model + * @param string $sql + * @param string $table + * @return string + */ + protected function parseTableName($sql, $table) + { + return str_replace('@', $table.'.', $sql); + } + + /** + * Applies the search constraint to a query. + */ + protected function applySearchToQuery($query, $columns, $boolean = 'and') + { + $term = $this->searchTerm; + + if ($scopeMethod = $this->searchScope) { + $searchMethod = $boolean == 'and' ? 'where' : 'orWhere'; + $query->$searchMethod(function ($q) use ($term, $columns, $scopeMethod) { + $q->$scopeMethod($term, $columns); + }); + } + else { + $searchMethod = $boolean == 'and' ? 'searchWhere' : 'orSearchWhere'; + $query->$searchMethod($term, $columns, $this->searchMode); + } + } + + /** + * Creates a ListColumn object from its name and configuration. + * + * @param string $name + * @param array $config + * @return ListColumn + */ + protected function makeListColumn($name, $config) + { + if (is_string($config)) { + $label = $config; + } elseif (isset($config['label'])) { + $label = $config['label']; + } else { + $label = studly_case($name); + } + + /* + * Auto configure pivot relation + */ + if (starts_with($name, 'pivot[') && strpos($name, ']') !== false) { + $_name = HtmlHelper::nameToArray($name); + $relationName = array_shift($_name); + $valueFrom = array_shift($_name); + + if (count($_name) > 0) { + $valueFrom .= '['.implode('][', $_name).']'; + } + + $config['relation'] = $relationName; + $config['valueFrom'] = $valueFrom; + $config['searchable'] = false; + } + /* + * Auto configure standard relation + */ + elseif (strpos($name, '[') !== false && strpos($name, ']') !== false) { + $config['valueFrom'] = $name; + $config['sortable'] = false; + $config['searchable'] = false; + } + + $columnType = $config['type'] ?? null; + + $column = new ListColumn($name, $label); + $column->displayAs($columnType, $config); + + return $column; + } + + /** + * Applies any filters to the model. + * @param integer $startTime unixTimestamp, the current calendar month startTime, eg: 1546149600 + * @param integer $endTime unixTimestamp, the current calendar month endTime, eg: 1549778400 + */ + public function prepareQuery($startTime = 0, $endTime = 0) + { + $query = $this->model->newQuery(); + $primaryTable = $this->model->getTable(); + $selects = [$primaryTable.'.*']; + $joins = []; + $withs = []; + + /** + * @event backend.calendar.extendQueryBefore + * Provides an opportunity to modify the `$query` object before the Calendar widget applies its scopes to it. + * + * The visible calendar window (Unix timestamps, `0` when unbounded) is also passed so + * listeners can apply a recurrence-aware constraint - typically alongside + * `$calendarWidget->setApplyDateRangeFilter(false)` to replace the built-in window filter. + * + * Example usage: + * + * Event::listen('backend.calendar.extendQueryBefore', function($calendarWidget, $query, $startTime, $endTime) { + * $query->whereNull('deleted_at'); + * }); + * + * Or + * + * $calendarWidget->bindEvent('calendar.extendQueryBefore', function ($query, $startTime, $endTime) { + * $query->whereNull('deleted_at'); + * }); + * + */ + $this->fireSystemEvent('backend.calendar.extendQueryBefore', [$query, $startTime, $endTime]); + + /* + * Prepare searchable column names + */ + $primarySearchable = []; + $relationSearchable = []; + + $columnsToSearch = []; + if (!empty($this->searchTerm) && ($searchableColumns = $this->getSearchableColumns())) { + foreach ($searchableColumns as $column) { + /* + * Related + */ + if ($this->isColumnRelated($column)) { + $table = $this->model->makeRelation($column->relation)->getTable(); + $columnName = isset($column->sqlSelect) + ? DbDongle::raw($this->parseTableName($column->sqlSelect, $table)) + : $table . '.' . $column->valueFrom; + + $relationSearchable[$column->relation][] = $columnName; + } + /* + * Primary + */ + else { + $columnName = isset($column->sqlSelect) + ? DbDongle::raw($this->parseTableName($column->sqlSelect, $primaryTable)) + : DbDongle::cast(Db::getTablePrefix() . $primaryTable . '.' . $column->columnName, 'TEXT'); + + $primarySearchable[] = $columnName; + } + } + } + $visibleColumns = $this->getVisibleRelationColumns(); + foreach ($visibleColumns as $column) { + // If useRelationCount is enabled, eager load the count of the relation into $relation_count + if ($column->relation && @$column->config['useRelationCount']) { + $query->withCount($column->relation); + } + if (!$this->isColumnRelated($column) || (!isset($column->sqlSelect) && !isset($column->valueFrom))) { + continue; + } + if (isset($column->valueFrom)) { + $withs[] = $column->relation; + } + $joins[] = $column->relation; + } + + /* + * Add eager loads to the query + */ + if ($withs) { + $query->with(array_unique($withs)); + } + /* + * Apply search term and start_time end_time + */ + $query->where(function ($innerQuery) use ($primarySearchable, $relationSearchable, $joins, $startTime, $endTime) { + + /* + * Search primary columns + */ + if (count($primarySearchable) > 0) { + $this->applySearchToQuery($innerQuery, $primarySearchable, 'or'); + } + + /* + * Search relation columns + */ + if ($joins) { + foreach (array_unique($joins) as $join) { + /* + * Apply a supplied search term for relation columns and + * constrain the query only if there is something to search for + */ + $columnsToSearch = array_get($relationSearchable, $join, []); + + if (count($columnsToSearch) > 0) { + $innerQuery->orWhereHas($join, function ($_query) use ($columnsToSearch) { + $this->applySearchToQuery($_query, $columnsToSearch); + }); + } + } + } + }); + + /* + * Custom select queries + */ + foreach ($visibleColumns as $column) { + if (!isset($column->sqlSelect) || !$this->isColumnInCalendar($column)) { + continue; + } + + $alias = $query->getQuery()->getGrammar()->wrap($column->columnName); + + /* + * Relation column + */ + if (isset($column->relation)) { + // @todo Find a way... + $relationType = $this->model->getRelationType($column->relation); + if ($relationType == 'morphTo') { + throw new ApplicationException('The relationship morphTo is not supported for Calendar columns.'); + } + + $table = $this->model->makeRelation($column->relation)->getTable(); + $sqlSelect = $this->parseTableName($column->sqlSelect, $table); + + /* + * Manipulate a count query for the sub query + */ + $relationObj = $this->model->{$column->relation}(); + $countQuery = $relationObj->getRelationExistenceQuery($relationObj->getRelated()->newQueryWithoutScopes(), $query); + + $joinSql = $this->isColumnRelated($column, true) + ? DbDongle::raw("group_concat(" . $sqlSelect . " separator ', ')") + : DbDongle::raw($sqlSelect); + + $joinSql = $countQuery->select($joinSql)->toSql(); + + $selects[] = Db::raw("(".$joinSql.") as ".$alias); + } + /* + * Primary column + */ + else { + $sqlSelect = $this->parseTableName($column->sqlSelect, $primaryTable); + $selects[] = DbDongle::raw($sqlSelect . ' as '. $alias); + } + } + + /* + * Apply filters + */ + foreach ($this->filterCallbacks as $callback) { + $callback($query); + } + /* + * Add custom selects + */ + $query->addSelect($selects); + + /** + * @event backend.calendar.extendQuery + * Provides an opportunity to modify and / or return the `$query` object after the Calendar widget has applied its scopes to it and before it's used to get the records. + * + * The visible calendar window (Unix timestamps, `0` when unbounded) is also passed so + * listeners can apply a recurrence-aware window constraint of their own. + * + * Example usage: + * + * Event::listen('backend.calendar.extendQuery', function($calendarWidget, $query, $startTime, $endTime) { + * $newQuery = MyModel::newQuery(); + * return $newQuery; + * }); + * + * Or + * + * $calendarWidget->bindEvent('calendar.extendQuery', function ($query, $startTime, $endTime) { + * $query->whereNull('deleted_at'); + * }); + * + */ + if ($event = $this->fireSystemEvent('backend.calendar.extendQuery', [$query, $startTime, $endTime])) { + return $event; + } + return $query; + } + + /** + * + * Create a MD5 string based on current query SQL + * to set the cacheKey in calendar cache + * + * @see MemoryCache->hash() + * + * @param QueryBuilder $query + * @return string md5 + */ + protected function getCacheKey($query) + { + $bindings = array_map(function ($binding) { + return (string)$binding; + }, $query->getBindings()); + + $name = $query->getConnection()->getName(); + $md5 = md5($name . $query->toSql() . serialize($bindings)); + return $md5; + } + + /** + * Sets whether the built-in visible-window date-range filter is applied in getRecords(). + * + * Intended to be called from `backend.calendar.extendQueryBefore` / + * `backend.calendar.extendQuery` listeners that expand recurring events server-side and + * need master rows outside the visible window to survive the query. + */ + public function setApplyDateRangeFilter(bool $apply): static + { + $this->applyDateRangeFilter = $apply; + return $this; + } + + /** + * Constrains the query to records that intersect the visible calendar window. + * + * A record is considered visible when it ends on or after the window start and starts + * before the window end. Records without an end (point events) are treated as ending at + * their start, so they are not dropped when their start falls within the window. Timestamps + * are Unix timestamps as provided by FullCalendar. + * + * @param integer $startTime unixTimestamp, the current calendar window startTime, eg: 1546149600 + * @param integer $endTime unixTimestamp, the current calendar window endTime, eg: 1549778400 + */ + protected function applyDateRangeToQuery($query, $startTime = 0, $endTime = 0): void + { + $query->where(function ($innerQuery) use ($startTime, $endTime) { + if ($startTime > 0) { + $start = Carbon::createFromTimestamp($startTime); + $innerQuery->where(function ($endQuery) use ($start) { + $endQuery->whereRaw($this->recordEnd . ' >= ?', [$start]) + ->orWhere(function ($pointQuery) use ($start) { + $pointQuery->whereRaw($this->recordEnd . ' is null') + ->whereRaw($this->recordStart . ' >= ?', [$start]); + }); + }); + } + if ($endTime > 0) { + $innerQuery->whereRaw($this->recordStart . ' < ?', [Carbon::createFromTimestamp($endTime)]); + } + }); + } + + /** + * + * + * @param integer $startTime unixTimestamp, the current calendar month startTime, eg: 1546149600 + * @param integer $endTime unixTimestamp, the current calendar month endTime, eg: 1549778400 + * @return array ['events'=> [ {url, title, start, end}], 'cacheKey'=> 'MD5 String'] + */ + public function getRecords($startTime = 0, $endTime = 0) + { + $query = $this->prepareQuery($startTime, $endTime); + $cacheKey = $this->getCacheKey($query); + + /* + * Constrain records to the visible calendar window at the database level. + * + * This is applied *after* getCacheKey() (so the client-side cache key stays stable + * across months) but *before* the `backend.calendar.extendRecords` event fires below. + * That ordering matters for recurring events: a master row whose base start date falls + * outside the visible window would otherwise be filtered out before a consumer could + * expand its occurrences into the window in `extendRecords`. + * + * Consumers that expand recurrence server-side can disable this built-in filter - + * either through the `applyDateRangeFilter` config option or by calling + * `$calendarWidget->setApplyDateRangeFilter(false)` from a + * `backend.calendar.extendQueryBefore` / `backend.calendar.extendQuery` listener - and + * apply their own window-aware constraint instead. + */ + if ($this->applyDateRangeFilter && ($startTime > 0 || $endTime > 0)) { + $this->applyDateRangeToQuery($query, $startTime, $endTime); + } + + $records = $query->get(); + + /** + * @event 'backend.calendar.extendRecords' + * Provides an opportunity to modify and / or return the `$records` Collection object before the widget uses it. + * + * Example usage: + * + * Event::listen('backend.calendar.extendRecords', function($calendarWidget, &$records , $startTime, $endTime) { + * $records=$data; + * }); + * + */ + if ($event = $this->fireSystemEvent('backend.calendar.extendRecords', [&$records, $startTime, $endTime])) { + $records = $event; + } + + $events = []; + + $timeZone = new DateTimeZone($this->getTimezone()); + + foreach ($records as $record) { + if (empty($this->recordTooltip)) { + $tooltip = null; + } else { + if (is_array($this->recordTooltip)) { + $tooltip = ''; + foreach ($this->recordTooltip as $item) { + $keyName = $this->{$item}; + $tooltip .= $record->{$keyName} . ' '; + } + } else { + $tooltip = $record->{$this->recordTooltip}; + } + } + + $eventData = new EventData([ + 'id' => $record->getKey(), + 'url' => $this->getRecordUrl($record), + 'title' => $record->{$this->recordTitle}, + 'start' => $record->{$this->recordStart}, + 'end' => $record->{$this->recordEnd}, + 'allDay' => (bool) $record->{$this->recordAllDay}, + 'color' => empty($this->recordColor) ? '' : $record->{$this->recordColor}, + 'tooltip' => $tooltip + ], $timeZone); + $events[] = $eventData->toArray(); + } + + /** + * @event 'backend.calendar.extendEvents' + * Provides an opportunity to modify and / or return the `$event` Collection object before the widget uses it. + * + * Example usage: + * + * Event::listen('backend.calendar.extendEvents', function($calendarWidget, &$events) { + * $records=$data; + * }); + * + * + */ + if ($event = $this->fireSystemEvent('backend.calendar.extendEvents', [&$events])) { + $events = $event; + } + + return [ + 'events' => $events, + 'cacheKey' => $cacheKey, + 'startTime' => $startTime, + 'endTime' => $endTime, + ]; + } + + + public function onFetchEvents() + { + return Response::json($this->getRecords()); + } + + /** + * It has been called from the first refresh page + * and next or previous button click event + * + * @return json + */ + public function onRefreshEvents() + { + $startTime = post('startTime'); + $endTime = post('endTime'); + $timeZone = post('timeZone'); + + $data = [ + 'startTime' => $startTime, + 'endTime' => $endTime, + 'timeZone' => $timeZone + ]; + + if ($this->isFilteredByDateRange()) { + $startTime = 0; + $endTime = 0; + } + + return Response::json($this->getRecords($startTime, $endTime)); + } + + // search + + /** + * Applies a search term to the list results, searching will disable tree + * view if a value is supplied. + * @param string $term + */ + public function setSearchTerm($term) + { + $this->searchTerm = $term; + } + + /** + * Applies a search options to the list search. + * @param array $options + */ + public function setSearchOptions($options = []) + { + extract(array_merge([ + 'mode' => null, + 'scope' => null + ], $options)); + + $this->searchMode = $mode; + $this->searchScope = $scope; + } + + /** + * + * If filter has DateRange type and some values with that + * will ignore the current month startTime and endTime + * + * @return boolean + */ + protected function isFilteredByDateRange() + { + if ($this->filterWidget === null) { + return false; + } + + $filterScopes = $this->filterWidget->getScopes(); + + if (!empty($filterScopes)) { + foreach ($filterScopes as $scope) { + if ($scope->type === 'daterange' && !empty($scope->value)) { + return true; + } + } + } + + /** + * Need to double check the session + * + * scopes is config array + */ + $scopes = $this->filterWidget->scopes; + + if (empty($scopes)) { + return false; + } + + foreach ($scopes as $scopeName => $scopeConfig) { + if ($scopeConfig['type'] !== 'daterange') { + continue; + } + $cacheKey = 'scope-' . $scopeName; + $value = $this->filterWidget->getSession($cacheKey, null); + if (!empty($value)) { + return true; + } + } + + return false; + } + + + /** + * Get the startTime and endTime from the october.calendar.js Calendar.prototype.beforeFilterRequestSend + * + * @return array $data [startTime=>1546149600, endTime=>1549778400, timeZone=>America/Regina ] + */ + protected function getMonthStartEndTime() + { + $calendar_time = post('calendar_time'); + return $calendar_time; + } + + + /** + * Event handler for refreshing the calendar. + * The search widget will call onRefresh + * @see CalendarController->initToolbar + */ + public function onRefresh() + { + $startTime = 0; + $endTime = 0; + if (!$this->isFilteredByDateRange()) { + $dateData = $this->getMonthStartEndTime(); + if (!empty($dateData)) { + $startTime = $dateData['startTime']; + $endTime = $dateData['endTime']; + } + } + + $records = $this->getRecords($startTime, $endTime); + $records['id'] = 'calendar'; + $records['method'] = 'onRefresh'; + + return $records; + } + + /** + * Event handler for changing the filter + */ + public function onFilter() + { + // $this->currentPageNumber = 1; + return $this->onRefresh(); + } + + // + // Filtering + // + + public function addFilter(callable $filter) + { + $this->filterCallbacks[] = $filter; + } +} diff --git a/modules/backend/widgets/calendar/assets/css/calendar.css b/modules/backend/widgets/calendar/assets/css/calendar.css new file mode 100644 index 0000000000..06757f9032 --- /dev/null +++ b/modules/backend/widgets/calendar/assets/css/calendar.css @@ -0,0 +1,9 @@ +.calendar-container{--fc-event-bg-color:#0594CD;--fc-event-border-color:#035e82;--fc-list-event-hover-bg-color:#d3f2fe;--fc-button-text-color:#FFF;--fc-button-bg-color:#656d79;--fc-button-border-color:#656d79;--fc-button-hover-bg-color:#2896b2;--fc-button-hover-border-color:#2896b2;--fc-button-active-bg-color:#2896b2;--fc-button-active-border-color:#2896b2;padding:0 20px 1px;font-size:15px} +.calendar-container.primary{--fc-button-text-color:#fff;--fc-button-bg-color:#2896b2;--fc-button-border-color:#24849d;--fc-button-hover-bg-color:#2896b2;--fc-button-hover-border-color:#2896b2;--fc-button-active-bg-color:#2896b2;--fc-button-active-border-color:#2896b2} +.calendar-container.secondary{--fc-button-text-color:#405261;--fc-button-bg-color:#e1e2e4;--fc-button-border-color:#d4d5d8;--fc-button-hover-bg-color:#e1e2e4;--fc-button-hover-border-color:#e1e2e4;--fc-button-active-bg-color:#e1e2e4;--fc-button-active-border-color:#e1e2e4} +.calendar-container .loading-indicator{background-color:rgba(0,0,0,0.02)} +.calendar-container .calendar-control{margin:0 auto} +.calendar-container .calendar-tooltip{position:absolute;z-index:9999;color:black;width:150px;border-radius:3px;box-shadow:0 0 2px rgba(0,0,0,0.5);text-align:center} +.calendar-container .calendar-tooltip[x-placement^="top"]{margin-bottom:5px} +.calendar-container .calendar-tooltip[x-placement^="top"] .tooltip-arrow{border-width:5px 5px 0 5px;border-left-color:transparent;border-right-color:transparent;border-bottom-color:transparent;bottom:-5px;left:calc(50% - 5px);margin-top:0;margin-bottom:0} +.calendar-container .calendar-tooltip .tooltip-arrow{width:0;height:0;border-style:solid;position:absolute;margin:5px;border-color:#34495e} \ No newline at end of file diff --git a/modules/backend/widgets/calendar/assets/js/dist/calendar.js b/modules/backend/widgets/calendar/assets/js/dist/calendar.js new file mode 100644 index 0000000000..ab3f7d5f4c --- /dev/null +++ b/modules/backend/widgets/calendar/assets/js/dist/calendar.js @@ -0,0 +1 @@ +(()=>{"use strict";var __webpack_modules__={138:(e,t,n)=>{function a(e){return a="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},a(e)}function r(e,t){for(var n=0;ns});var o=86400,s=function(){return e=function e(t,n){var a=arguments.length>2&&void 0!==arguments[2]?arguments[2]:0,r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:12;!function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}(this,e),this.clearCache(),this.requestFn=n,this.firstDay=a,this.capcity=r,this._hideIndicatorCallback=null,this._showIndicatorCallback=null,this.methodName=t},t=[{key:"hideIndicatorCallback",get:function(){return this._hideIndicatorCallback},set:function(e){this._hideIndicatorCallback=e}},{key:"showIndicatorCallback",get:function(){return this._showIndicatorCallback},set:function(e){this._showIndicatorCallback=e}},{key:"isEmpty",value:function(){return 0===this.length}},{key:"count",value:function(){return this.length}},{key:"clearCache",value:function(){this.cache=[],this.lfuCache=[],this.cacheKey="0",this.lastMonthReqeustData=null,this.length=0,this.lastRequestStartTime=0}},{key:"incrLFUCount",value:function(e){var t=this.lfuCache[e];this.lfuCache[e]=void 0===t?1:++t}},{key:"removeOldCache",value:function(){if(!(this.count()=parseInt(o[1])&&n<=parseInt(o[2])){this.incrLFUCount(r),a=i;break}}return a}},{key:"getMonthRequestData",value:function(e){var t="UTC"===e.timeZone,n=new Date(1e3*e.startTime);if((t?n.getUTCDay():n.getDay())===this.firstDay&&e.endTime-e.startTime===3628800)return e;var a,r=t?new Date(Date.UTC(n.getUTCFullYear(),n.getUTCMonth(),1)):new Date(n.getFullYear(),n.getMonth(),1),i=t?r.getUTCDay():r.getDay(),s=i-this.firstDay;if(0!==s){s<0&&(s=i+this.firstDay);var c=r.getTime()/1e3-o*s;a={startTime:c,endTime:c+3628800,timeZone:e.timeZone}}else a={startTime:e.startTime,endTime:e.startTime+3628800,timeZone:e.timeZone};return a}},{key:"setLastRequestTime",value:function(e){this.lastRequestStartTime=e}},{key:"getLastMonthRequestData",value:function(){return this.lastMonthReqeustData}},{key:"saveCache",value:function(e,t){var n=t.events,a=e.startTime,r=e.endTime,i=t.cacheKey+"-"+a+"-"+r;this.setCacheKey(t.cacheKey),this.cache[i]=n,this.length++,this.incrLFUCount(i),this.removeOldCache()}},{key:"setCacheKey",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"0";this.cacheKey=e}},{key:"showIndicator",value:function(){this.showIndicatorCallback&&this.showIndicatorCallback()}},{key:"hideIndicator",value:function(){this.hideIndicatorCallback&&this.hideIndicatorCallback()}},{key:"eagerRequest",value:function(e){e=this.getMonthRequestData(e);var t=[];if(e.startTime>this.lastRequestStartTime?t.startTime=e.endTime:t.startTime=e.startTime,t.endTime=t.startTime+o,t.timeZone=e.timeZone,t=this.getMonthRequestData(t),this.lastRequestStartTime=e.startTime,null===this.getCacheData(t)){var n=this;this.requestFn(this.methodName,{data:t,success:function(e){n.saveCache(t,e)},error:function(){n.hideIndicator()}})}}},{key:"saveFirstThreeMonthsData",value:function(e,t,n){var a=e.events,r=t[0],i=t[1],o=t[2],s=[],c=[],l=[];for(var u in a){var h=a[u],f=Date.parse(h.start)/1e3,d=h.end?Date.parse(h.end)/1e3:f;d>=r.startTime&&f=i.startTime&&f=o.startTime&&f1&&void 0!==arguments[1]?arguments[1]:function(){},n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:function(){};this.showIndicator();var a=this.getMonthRequestData(e);this.lastMonthReqeustData=a;var r=[];r.startTime=a.startTime,r.endTime=r.startTime+o,r.timeZone=a.timeZone,r=this.getMonthRequestData(r);var i=[];i.startTime=a.endTime,i.endTime=i.startTime+o,i.timeZone=a.timeZone,i=this.getMonthRequestData(i);var s={startTime:r.startTime,endTime:i.endTime,timeZone:a.timeZone},c=this;this.requestFn(this.methodName,{data:s,success:function(e){c.saveFirstThreeMonthsData(e,[r,a,i],t)},error:function(){c.hideIndicator(),n()}})}},{key:"requestEvents",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:function(){},n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:function(){};if(this.isEmpty())this.loadFirstThreeMonthsData(e,t,n);else{var a=this.getCacheData(e);if(null!==a)return this.lastMonthReqeustData=e,this.eagerRequest(e),void t(a);this.showIndicator();var r=this.getMonthRequestData(e);this.lastMonthReqeustData=r;var i=this;this.requestFn(this.methodName,{data:r,success:function(e){var n=e.events;i.hideIndicator(),i.saveCache(r,e),t(n),i.eagerRequest(r)},error:function(){i.hideIndicator(),n()}})}}},{key:"reloadLastMonth",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:function(){},t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:function(){},n=this.getLastMonthRequestData();this.clearCache(),this.requestEvents(n,e,t)}},{key:"dispose",value:function(){this._hideIndicatorCallback=null,this._showIndicatorCallback=null,this.cache=[],this.lfuCache=[]}}],t&&r(e.prototype,t),n&&r(e,n),Object.defineProperty(e,"prototype",{writable:!1}),e;var e,t,n}()}},__webpack_module_cache__={};function __webpack_require__(e){var t=__webpack_module_cache__[e];if(void 0!==t)return t.exports;var n=__webpack_module_cache__[e]={exports:{}};return __webpack_modules__[e](n,n.exports,__webpack_require__),n.exports}__webpack_require__.d=(e,t)=>{for(var n in t)__webpack_require__.o(t,n)&&!__webpack_require__.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},__webpack_require__.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);var __webpack_exports__={},_CalendarCache__WEBPACK_IMPORTED_MODULE_0__=__webpack_require__(138);function _typeof(e){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},_typeof(e)}function _createForOfIteratorHelper(e,t){var n="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(!n){if(Array.isArray(e)||(n=_unsupportedIterableToArray(e))||t&&e&&"number"==typeof e.length){n&&(e=n);var a=0,r=function(){};return{s:r,n:function(){return a>=e.length?{done:!0}:{done:!1,value:e[a++]}},e:function(e){throw e},f:r}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var i,o=!0,s=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return o=e.done,e},e:function(e){s=!0,i=e},f:function(){try{o||null==n.return||n.return()}finally{if(s)throw i}}}}function _unsupportedIterableToArray(e,t){if(e){if("string"==typeof e)return _arrayLikeToArray(e,t);var n={}.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?_arrayLikeToArray(e,t):void 0}}function _arrayLikeToArray(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,a=Array(t);n3&&void 0!==arguments[3]?arguments[3]:function(){},r=arguments.length>4&&void 0!==arguments[4]?arguments[4]:function(){},i={startTime:e,endTime:t,timeZone:n};this.clearEvents(),this.cache.requestEvents(i,a,r)}},{key:"reloadLastMonth",value:function(){var e=this;this.clearEvents(),this.cache.reloadLastMonth(function(t){e.addEvents(t)})}},{key:"onEventClick",value:function onEventClick(info){info.jsEvent.preventDefault();var url=info.event.url;if(url)if(url.startsWith("http")||!url.startsWith("$"))location.href=url;else{var elements=url.split("."),funcName=elements.pop(),objectName=elements.join("."),index=funcName.indexOf("(");funcName=funcName.substring(0,index);var object=eval(objectName);object[funcName](info,info.event.start,info.event.end,info.event,info.el)}}},{key:"disposeCalendarControl",value:function(){this.calendarControl&&(this.calendarControl.destroy(),this.calendarControl=null)}},{key:"onDateClick",value:function onDateClick(info){var clickDate=this.config.get("clickDate");if(null!=clickDate&&0!==clickDate.length){var elements=clickDate.split("."),funcName=elements.pop(),objectName=elements.join("."),index=funcName.indexOf("(");funcName=funcName.substring(0,index);var object=eval(objectName);object[funcName](info,info.date,info.dateStr,info.allDay,info.dayEl,info.jsEvent,info.view)}}},{key:"addEvent",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:null;this.calendarControl.addEvent(e)}},{key:"addEvents",value:function(e){var t,n=_createForOfIteratorHelper(e);try{for(n.s();!(t=n.n()).done;){var a=t.value;this.addEvent(a)}}catch(e){n.e(e)}finally{n.f()}}},{key:"makeEventHandler",value:function(e){return this.config.get("alias")+"::"+e}},{key:"clearEvents",value:function(){if(null!==this.calendarControl){var e=this.calendarControl.getEvents();e&&e.forEach(function(e){e.remove()})}}},{key:"onFilterUpdate",value:function(){for(var e=arguments.length,t=new Array(e),n=0;n { + const $ = window.jQuery; + + class Calendar extends Snowboard.PluginBase { + construct(element) { + this.element = element; + this.$el = $(element); + this.config = this.snowboard.dataConfig(this, element); + + this.calendarControl = null; + this.$loadContainer = this.$el.find('.loading-indicator-container:first'); + this.firstDay = parseInt(this.config.get('firstDay'), 10) || 0; + + this.cache = new CalendarCache( + this.makeEventHandler('onRefreshEvents'), + (handler, options) => this.snowboard.request(this.element, handler, options), + this.firstDay + ); + this.cache.showIndicatorCallback = () => this.showIndicator(); + this.cache.hideIndicatorCallback = () => this.hideIndicator(); + + // Bridge to the Storm-based search/filter widgets, which still drive the calendar + // through the framework's jQuery AJAX lifecycle events. `oc.beforeRequest` lets us + // inject the current month window into the search/filter request, and `ajaxSuccess` + // (fired by the framework as [context, data, ...]) delivers the onRefresh payload. + this.onBeforeRequest = (ev, context) => this.beforeFilterRequestSend(ev, context); + this.onAjaxSuccess = (...args) => this.onFilterUpdate(...args); + $(document).on('oc.beforeRequest', this.onBeforeRequest); + $(document).on('ajaxSuccess', this.onAjaxSuccess); + + this.initCalendarControl(); + } + + /** + * Config keys read from the element's data-* attributes. + */ + defaults() { + return { + alias: null, + initialView: 'dayGridMonth', + displayModes: 'dayGridMonth', + firstDay: 0, + timezone: 'local', + editable: false, + clickDate: null, + }; + } + + destruct() { + $(document).off('oc.beforeRequest', this.onBeforeRequest); + $(document).off('ajaxSuccess', this.onAjaxSuccess); + + this.disposeCalendarControl(); + + if (this.cache) { + this.cache.dispose(); + } + + this.cache = null; + this.$loadContainer = null; + this.$el = null; + this.element = null; + + super.destruct(); + } + + showIndicator() { + if (this.$loadContainer) { + this.$loadContainer.loadIndicator(); + } + } + + hideIndicator() { + if (this.$loadContainer) { + this.$loadContainer.loadIndicator('hide'); + } + } + + initCalendarControl() { + const $calendar = this.$el.find('.calendar-control'); + const self = this; + const locale = $('meta[name="backend-locale"]').attr('content'); + // Prefer the widget-configured timezone, falling back to the backend meta tag. + // Named timezones (anything other than 'local' / 'UTC') require a FullCalendar + // named-timezone plugin; without one v6 treats them as UTC. + const timezone = this.config.get('timezone') + || $('meta[name="backend-timezone"]').attr('content') + || 'local'; + + this.calendarControl = new FullCalendar.Calendar($calendar[0], { + // Configuration + initialView: this.config.get('initialView'), + firstDay: this.firstDay, + timeZone: timezone, + locale: locale, + + // Toolbar + headerToolbar: { + start: 'prev,next today', + center: 'title', + end: this.config.get('displayModes') + }, + + // Date Nav Links + navLinks: true, // Determines if day names and week names are clickable. + + // Week Numbers + weekNumbers: true, + + // Event Dragging & Resizing + editable: Boolean(this.config.get('editable')), + + // Event Display + eventDisplay: 'block', // render single-day timed events as solid filled rectangle + eventTimeFormat: { + hour: '2-digit', + minute: '2-digit', + }, + + // Event Popover + dayMaxEventRows: true, // allow "more" link when too many events + dayMaxEvents: true, // when too many events in a day, show the popover + + // Events + eventClick: function (info) { + self.onEventClick(info); + }, + // v6 replacement for v4's eventRender. The original popover tooltip relied on a + // popper/tooltip lib that shipped with the (now removed) v4 vendor tree; fall back + // to a native title attribute until a tooltip lib is reintroduced. + eventDidMount: function (info) { + const tooltipContent = info.event.extendedProps.tooltip; + if (tooltipContent) { + info.el.setAttribute('title', tooltipContent); + } + }, + events: function (fetchInfo, successCallback, failureCallback) { + self.onPrevNextButtonClick(fetchInfo, successCallback, failureCallback); + }, + dateClick: function (info) { + self.onDateClick(info); + } + }); + this.calendarControl.render(); + } + + beforeFilterRequestSend(ev, context) { + if (context.handler !== 'calendarFilter::onFilterUpdate' && + context.handler !== 'calendarToolbarSearch::onSubmit') { + return true; + } + + const monthRequestData = this.cache.getLastMonthRequestData(); + if (monthRequestData === null) return; + + if (!context.options.data) { + context.options.data = {}; + } + context.options.data.calendar_time = monthRequestData; + } + + onPrevNextButtonClick(fetchInfo, successCallback, failureCallback) { + this.refreshEvents( + fetchInfo.start.getTime() / 1000, + fetchInfo.end.getTime() / 1000, + fetchInfo.timeZone, + successCallback, + failureCallback + ); + } + + refreshEvents(startTime, endTime, timeZone, onSuccessCallback = () => {}, onErrorCallback = () => {}) { + const data = { + startTime: startTime, + endTime: endTime, + timeZone: timeZone + }; + this.clearEvents(); + this.cache.requestEvents(data, onSuccessCallback, onErrorCallback); + } + + reloadLastMonth() { + this.clearEvents(); + this.cache.reloadLastMonth((events) => { + this.addEvents(events); + }); + } + + onEventClick(info) { + info.jsEvent.preventDefault(); + const url = info.event.url; + if (url) { + if (url.startsWith('http') || (!url.startsWith('$'))) { + location.href = url; + } else { + const elements = url.split('.'); + let funcName = elements.pop(); // remove the last element + const objectName = elements.join('.'); + const index = funcName.indexOf('('); + funcName = funcName.substring(0, index); + const object = eval(objectName); // eslint-disable-line no-eval + object[funcName](info, info.event.start, info.event.end, info.event, info.el); + } + } + } + + disposeCalendarControl() { + if (this.calendarControl) { + this.calendarControl.destroy(); + this.calendarControl = null; + } + } + + onDateClick(info) { + const clickDate = this.config.get('clickDate'); + if (clickDate == null || clickDate.length === 0) return; + const elements = clickDate.split('.'); + let funcName = elements.pop(); // remove the last element + const objectName = elements.join('.'); + + const index = funcName.indexOf('('); + funcName = funcName.substring(0, index); + const object = eval(objectName); // eslint-disable-line no-eval + object[funcName](info, info.date, info.dateStr, info.allDay, info.dayEl, info.jsEvent, info.view); + } + + addEvent(eventObj = null) { + this.calendarControl.addEvent(eventObj); + } + + addEvents(eventList) { + // v6 removed Calendar.batchRendering(); events are simply added one by one. + for (const event of eventList) { + this.addEvent(event); + } + } + + /** + * Make Event Handler, same as PHP $this->getEventHandler('xxx') + */ + makeEventHandler(methodName) { + return this.config.get('alias') + '::' + methodName; + } + + clearEvents() { + if (this.calendarControl === null) return; + const events = this.calendarControl.getEvents(); + if (!events) return; + events.forEach((event) => { + event.remove(); + }); + } + + onFilterUpdate(...args) { + // The framework fires this on the widget element that made the request (search or + // filter) and it bubbles to the document. Scan the arguments for the onRefresh + // payload rather than relying on a fixed position, since the native jQuery global + // ajax events share the same name with a different signature. + const data = args.find((arg) => arg + && typeof arg === 'object' + && arg.id === 'calendar' + && arg.method === 'onRefresh' + && Array.isArray(arg.events)); + + if (!data) { + return; + } + + this.clearEvents(); + const requestData = { + startTime: data.startTime, + endTime: data.endTime + }; + this.cache.saveCache(requestData, data); + // clear the previous request time + this.cache.setLastRequestTime(0); + this.cache.eagerRequest(requestData); + this.addEvents(data.events); + } + } + + Snowboard.addPlugin('backend.widget.calendar', Calendar); + Snowboard['backend.ui.widgethandler']().register('calendar', 'backend.widget.calendar'); +})(window.Snowboard); diff --git a/modules/backend/widgets/calendar/assets/js/src/CalendarCache.js b/modules/backend/widgets/calendar/assets/js/src/CalendarCache.js new file mode 100644 index 0000000000..c555aaf152 --- /dev/null +++ b/modules/backend/widgets/calendar/assets/js/src/CalendarCache.js @@ -0,0 +1,368 @@ +/* + * Month-window client cache for the Calendar widget. + * + * Caches fetched events per month keyed by the server-provided `cacheKey` (an MD5 of the base + * query) and only refetches on cache-miss / filter change. Ported from the original Storm + * implementation; the only behavioural change is that AJAX now goes through an injected + * `requestFn` (wired to Snowboard's request layer) instead of the global jQuery `$.request`. + */ +const daysOfMonth = 42; // 6 weeks per month +const secondsOfDay = 86400; + +export default class CalendarCache { + /** + * @param {string} methodName the AJAX handler used to fetch events (alias::onRefreshEvents) + * @param {function} requestFn (handler, options) => void - issues the AJAX request + * @param {int} firstDay the first day of week, 0 = sunday ... + * @param {int} capcity the default month data stored + */ + constructor(methodName, requestFn, firstDay = 0, capcity = 12) { + this.clearCache(); + + this.requestFn = requestFn; + this.firstDay = firstDay; + this.capcity = capcity; + this._hideIndicatorCallback = null; + this._showIndicatorCallback = null; + this.methodName = methodName; + } + + set hideIndicatorCallback(value) { + this._hideIndicatorCallback = value; + } + get hideIndicatorCallback() { + return this._hideIndicatorCallback; + } + + set showIndicatorCallback(value) { + this._showIndicatorCallback = value; + } + get showIndicatorCallback() { + return this._showIndicatorCallback; + } + + isEmpty() { + return this.length === 0; + } + + count() { + return this.length; + } + + clearCache() { + this.cache = []; + this.lfuCache = []; + /** + * cacheKey is MD5 string created by server, based on the query SQL not including monthData.startTime and endTime + */ + this.cacheKey = '0'; + this.lastMonthReqeustData = null; + this.length = 0; + this.lastRequestStartTime = 0; + } + + incrLFUCount(key) { + let value = this.lfuCache[key]; + this.lfuCache[key] = (value === undefined) ? 1 : ++value; + } + + removeOldCache() { + if (this.count() < this.capcity) return; + let minKey; + let minValue = Number.MAX_SAFE_INTEGER; + for (let key in this.lfuCache) { + let element = this.lfuCache[key]; + if (minValue <= element) { + minValue = element; + minKey = key; + } + } + delete this.lfuCache[minKey]; + delete this.cache[minKey]; + this.length--; + } + + getCacheData(requestData) { + // Maybe the first month + if (this.isEmpty()) { + return null; + } + let startTime = requestData.startTime; + let endTime = requestData.endTime; + let results = null; + + const self = this; + + for (let key in this.cache) { + let element = this.cache[key]; + const timeKeys = key.split('-'); + if (this.cacheKey === timeKeys[0] && + startTime >= parseInt(timeKeys[1]) && endTime <= parseInt(timeKeys[2])) { + self.incrLFUCount(key); + results = element; + break; + } + } + return results; + } + + /** + * + * return 6 weeks such as 2018 - 12 - 30 to 2019 - 01 - 05 + * + * @param Array requestData + */ + getMonthRequestData(requestData) { + // FullCalendar reports its timestamps in the calendar's timezone. Compute day-of-week + // and month boundaries in that same frame - UTC when the calendar runs in UTC, otherwise + // the browser's local zone - so the month window is not shifted by the offset between the + // browser timezone and the calendar timezone. + const utc = requestData.timeZone === 'UTC'; + const startDate = new Date(requestData.startTime * 1000); + const dayOfWeek = utc ? startDate.getUTCDay() : startDate.getDay(); + + if (dayOfWeek === this.firstDay && (requestData.endTime - requestData.startTime) === daysOfMonth * secondsOfDay) { + return requestData; + } + let firstDayOfMonth = utc + ? new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), 1)) + : new Date(startDate.getFullYear(), startDate.getMonth(), 1); + const firstDayOfMonthDow = utc ? firstDayOfMonth.getUTCDay() : firstDayOfMonth.getDay(); + let daysDiff = firstDayOfMonthDow - this.firstDay; + let monthData; + if (daysDiff !== 0) { + // need to get the first day of week , eg: 2018-12-30 is the first day of jan, 2019 + if (daysDiff < 0) daysDiff = firstDayOfMonthDow + this.firstDay; + let firstDayOfMonthTime = firstDayOfMonth.getTime() / 1000 - secondsOfDay * daysDiff; + monthData = { + startTime: firstDayOfMonthTime, + endTime: firstDayOfMonthTime + daysOfMonth * secondsOfDay, + timeZone: requestData.timeZone, + }; + } else { + monthData = { + startTime: requestData.startTime, + endTime: requestData.startTime + daysOfMonth * secondsOfDay, + timeZone: requestData.timeZone, + }; + } + return monthData; + } + + /** + * + * the lastRequestStartTime is been used for loading the next or previous month + * + * @param integer startTime unix Timestamp + */ + setLastRequestTime(startTime) { + this.lastRequestStartTime = startTime; + } + + getLastMonthRequestData() { + return this.lastMonthReqeustData; + } + + saveCache(monthData, data) { + const events = data.events; + const startTime = monthData.startTime; + const endTime = monthData.endTime; + const key = data.cacheKey + '-' + startTime + '-' + endTime; + this.setCacheKey(data.cacheKey); + this.cache[key] = events; + this.length++; + this.incrLFUCount(key); + this.removeOldCache(); + } + + setCacheKey(cacheKey = '0') { + this.cacheKey = cacheKey; + } + + showIndicator() { + if (this.showIndicatorCallback) this.showIndicatorCallback(); + } + + hideIndicator() { + if (this.hideIndicatorCallback) this.hideIndicatorCallback(); + } + + /** + * Click the next button will load one more next month data + * Click the previous button will load one more previous month data + * + * @param array requestData {startTime: unixTimestamp, endTime:, timeZone: string} + */ + eagerRequest(requestData) { + requestData = this.getMonthRequestData(requestData); + let monthData = []; + if (requestData.startTime > this.lastRequestStartTime) { + // go to request next month + monthData.startTime = requestData.endTime; + } else { + // go to request previous month + monthData.startTime = requestData.startTime; + } + monthData.endTime = monthData.startTime + secondsOfDay; + monthData.timeZone = requestData.timeZone; + monthData = this.getMonthRequestData(monthData); + + this.lastRequestStartTime = requestData.startTime; + + let events = this.getCacheData(monthData); + if (events !== null) return; + const self = this; + + this.requestFn(this.methodName, { + data: monthData, + success: function (data) { + self.saveCache(monthData, data); + }, + error: function () { + self.hideIndicator(); + } + }); + } + + saveFirstThreeMonthsData(data, monthDataList, onSuccessCallback) { + const allEvents = data.events; + let prevoiousMonthData = monthDataList[0]; + let currentMonthData = monthDataList[1]; + let nextMonthData = monthDataList[2]; + + let previousEvents = []; + let currentEvents = []; + let nextEvents = []; + + for (let index in allEvents) { + const event = allEvents[index]; + const eventStartTime = Date.parse(event.start) / 1000; + // Point events (no end) are treated as ending at their start so they are not dropped. + const eventEndTime = event.end ? Date.parse(event.end) / 1000 : eventStartTime; + if (eventEndTime >= prevoiousMonthData.startTime && + eventStartTime < prevoiousMonthData.endTime) { + previousEvents.push(event); + } + if (eventEndTime >= currentMonthData.startTime && + eventStartTime < currentMonthData.endTime) { + currentEvents.push(event); + } + if (eventEndTime >= nextMonthData.startTime && + eventStartTime < nextMonthData.endTime) { + nextEvents.push(event); + } + } + + const previousData = { + cacheKey: data.cacheKey, + events: previousEvents, + }; + this.saveCache(prevoiousMonthData, previousData); + + const currentData = { + cacheKey: data.cacheKey, + events: currentEvents + }; + this.saveCache(currentMonthData, currentData); + + onSuccessCallback(currentData.events); + + this.setLastRequestTime(currentMonthData.startTime); + this.hideIndicator(); + + const nextData = { + cacheKey: data.cacheKey, + events: nextEvents + }; + this.saveCache(nextMonthData, nextData); + } + + loadFirstThreeMonthsData(requestData, onSuccessCallback = () => {}, onErrorCallback = () => {}) { + this.showIndicator(); + + const currentMonthData = this.getMonthRequestData(requestData); + this.lastMonthReqeustData = currentMonthData; + // previous month + let previousMonthData = []; + previousMonthData.startTime = currentMonthData.startTime; + previousMonthData.endTime = previousMonthData.startTime + secondsOfDay; + previousMonthData.timeZone = currentMonthData.timeZone; + previousMonthData = this.getMonthRequestData(previousMonthData); + + // next month + let nextMonthData = []; + nextMonthData.startTime = currentMonthData.endTime; + nextMonthData.endTime = nextMonthData.startTime + secondsOfDay; + nextMonthData.timeZone = currentMonthData.timeZone; + nextMonthData = this.getMonthRequestData(nextMonthData); + + const monthData = { + startTime: previousMonthData.startTime, + endTime: nextMonthData.endTime, + timeZone: currentMonthData.timeZone, + }; + + const self = this; + this.requestFn(this.methodName, { + data: monthData, + success: function (data) { + self.saveFirstThreeMonthsData(data, [previousMonthData, currentMonthData, nextMonthData], onSuccessCallback); + }, + error: function () { + self.hideIndicator(); + onErrorCallback(); + } + }); + } + + requestEvents(requestData, onSuccessCallback = () => {}, onErrorCallback = () => {}) { + if (this.isEmpty()) { + this.loadFirstThreeMonthsData(requestData, onSuccessCallback, onErrorCallback); + return; + } + + let events = this.getCacheData(requestData); + if (events !== null) { + this.lastMonthReqeustData = requestData; + this.eagerRequest(requestData); + onSuccessCallback(events); + return; + } + + this.showIndicator(); + + const monthData = this.getMonthRequestData(requestData); + this.lastMonthReqeustData = monthData; + + const self = this; + + this.requestFn(this.methodName, { + data: monthData, + success: function (data) { + const events = data.events; + self.hideIndicator(); + // the events is whole month data + self.saveCache(monthData, data); + onSuccessCallback(events); + self.eagerRequest(monthData); + }, + error: function () { + self.hideIndicator(); + onErrorCallback(); + } + }); + } + + reloadLastMonth(onSuccessCallback = () => {}, onErrorCallback = () => {}) { + const monthData = this.getLastMonthRequestData(); + this.clearCache(); + this.requestEvents(monthData, onSuccessCallback, onErrorCallback); + } + + dispose() { + this._hideIndicatorCallback = null; + this._showIndicatorCallback = null; + this.cache = []; + this.lfuCache = []; + } +} diff --git a/modules/backend/widgets/calendar/assets/less/calendar.less b/modules/backend/widgets/calendar/assets/less/calendar.less new file mode 100644 index 0000000000..beef289cf3 --- /dev/null +++ b/modules/backend/widgets/calendar/assets/less/calendar.less @@ -0,0 +1,83 @@ +@import "../../../../../backend/assets/less/core/boot.less"; + +@calendar_default_color: #0594CD; + +.calendar-container { + + --fc-event-bg-color: @calendar_default_color; + --fc-event-border-color: darken(@calendar_default_color, 15%); + + --fc-list-event-hover-bg-color: lighten(@calendar_default_color, 50%); + + --fc-button-text-color: @btn-default-color; + --fc-button-bg-color: @btn-default-bg; + --fc-button-border-color: @btn-default-border; + --fc-button-hover-bg-color: @btn-primary-bg; + --fc-button-hover-border-color: @btn-primary-bg; + --fc-button-active-bg-color: @btn-primary-bg; + --fc-button-active-border-color: @btn-primary-bg; + + &.primary { + --fc-button-text-color: @btn-primary-color; + --fc-button-bg-color: @btn-primary-bg; + --fc-button-border-color: @btn-primary-border; + --fc-button-hover-bg-color: @btn-primary-bg; + --fc-button-hover-border-color: @btn-primary-bg; + --fc-button-active-bg-color: @btn-primary-bg; + --fc-button-active-border-color: @btn-primary-bg; + } + + &.secondary { + --fc-button-text-color: @btn-secondary-color; + --fc-button-bg-color: @btn-secondary-bg; + --fc-button-border-color: @btn-secondary-border; + --fc-button-hover-bg-color: @btn-secondary-bg; + --fc-button-hover-border-color: @btn-secondary-bg; + --fc-button-active-bg-color: @btn-secondary-bg; + --fc-button-active-border-color: @btn-secondary-bg; + } + + padding: 0 20px 1px; + font-size: 15px; + + .loading-indicator{ + background-color: rgba(0, 0, 0, 0.02); + } + + .calendar-control{ + margin: 0 auto; + } + + .calendar-tooltip { + position: absolute; + z-index: 9999; + color: black; + width: 150px; + border-radius: 3px; + box-shadow: 0 0 2px rgba(0,0,0,0.5); + text-align: center; + &[x-placement^="top"] { + margin-bottom: 5px; + .tooltip-arrow { + border-width: 5px 5px 0 5px; + border-left-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + bottom: -5px; + left: calc(50% - 5px); + margin-top: 0; + margin-bottom: 0; + } + } + .tooltip-arrow { + width: 0; + height: 0; + border-style: solid; + position: absolute; + margin: 5px; + border-color: #34495e; + } + } +} + + diff --git a/modules/backend/widgets/calendar/assets/vendor/fullcalendar/index.global.js b/modules/backend/widgets/calendar/assets/vendor/fullcalendar/index.global.js new file mode 100644 index 0000000000..0f27cf698d --- /dev/null +++ b/modules/backend/widgets/calendar/assets/vendor/fullcalendar/index.global.js @@ -0,0 +1,14702 @@ +/*! +FullCalendar Standard Bundle v6.1.15 +Docs & License: https://fullcalendar.io/docs/initialize-globals +(c) 2024 Adam Shaw +*/ +var FullCalendar = (function (exports) { + 'use strict'; + + var n,l$1,u$1,i$1,t,r$1,o,f$1,e$1,c$1={},s=[],a$1=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i;function h(n,l){for(var u in l)n[u]=l[u];return n}function v$1(n){var l=n.parentNode;l&&l.removeChild(n);}function y(l,u,i){var t,r,o,f={};for(o in u)"key"==o?t=u[o]:"ref"==o?r=u[o]:f[o]=u[o];if(arguments.length>2&&(f.children=arguments.length>3?n.call(arguments,2):i),"function"==typeof l&&null!=l.defaultProps)for(o in l.defaultProps)void 0===f[o]&&(f[o]=l.defaultProps[o]);return p(l,f,t,r,null)}function p(n,i,t,r,o){var f={type:n,props:i,key:t,ref:r,__k:null,__:null,__b:0,__e:null,__d:void 0,__c:null,__h:null,constructor:void 0,__v:null==o?++u$1:o};return null==o&&null!=l$1.vnode&&l$1.vnode(f),f}function d(){return {current:null}}function _(n){return n.children}function k$1(n,l,u,i,t){var r;for(r in u)"children"===r||"key"===r||r in l||g$2(n,r,null,u[r],i);for(r in l)t&&"function"!=typeof l[r]||"children"===r||"key"===r||"value"===r||"checked"===r||u[r]===l[r]||g$2(n,r,l[r],u[r],i);}function b$1(n,l,u){"-"===l[0]?n.setProperty(l,null==u?"":u):n[l]=null==u?"":"number"!=typeof u||a$1.test(l)?u:u+"px";}function g$2(n,l,u,i,t){var r;n:if("style"===l)if("string"==typeof u)n.style.cssText=u;else {if("string"==typeof i&&(n.style.cssText=i=""),i)for(l in i)u&&l in u||b$1(n.style,l,"");if(u)for(l in u)i&&u[l]===i[l]||b$1(n.style,l,u[l]);}else if("o"===l[0]&&"n"===l[1])r=l!==(l=l.replace(/Capture$/,"")),l=l.toLowerCase()in n?l.toLowerCase().slice(2):l.slice(2),n.l||(n.l={}),n.l[l+r]=u,u?i||n.addEventListener(l,r?w$2:m$1,r):n.removeEventListener(l,r?w$2:m$1,r);else if("dangerouslySetInnerHTML"!==l){if(t)l=l.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if("width"!==l&&"height"!==l&&"href"!==l&&"list"!==l&&"form"!==l&&"tabIndex"!==l&&"download"!==l&&l in n)try{n[l]=null==u?"":u;break n}catch(n){}"function"==typeof u||(null==u||!1===u&&-1==l.indexOf("-")?n.removeAttribute(l):n.setAttribute(l,u));}}function m$1(n){t=!0;try{return this.l[n.type+!1](l$1.event?l$1.event(n):n)}finally{t=!1;}}function w$2(n){t=!0;try{return this.l[n.type+!0](l$1.event?l$1.event(n):n)}finally{t=!1;}}function x$1(n,l){this.props=n,this.context=l;}function A(n,l){if(null==l)return n.__?A(n.__,n.__.__k.indexOf(n)+1):null;for(var u;ll&&r$1.sort(function(n,l){return n.__v.__b-l.__v.__b}));$$1.__r=0;}function H$1(n,l,u,i,t,r,o,f,e,a){var h,v,y,d,k,b,g,m=i&&i.__k||s,w=m.length;for(u.__k=[],h=0;h0?p(d.type,d.props,d.key,d.ref?d.ref:null,d.__v):d)){if(d.__=u,d.__b=u.__b+1,null===(y=m[h])||y&&d.key==y.key&&d.type===y.type)m[h]=void 0;else for(v=0;v=0;l--)if((u=n.__k[l])&&(i=L$1(u)))return i;return null}function M(n,u,i,t,r,o,f,e,c){var s,a,v,y,p,d,k,b,g,m,w,A,P,C,T,$=u.type;if(void 0!==u.constructor)return null;null!=i.__h&&(c=i.__h,e=u.__e=i.__e,u.__h=null,o=[e]),(s=l$1.__b)&&s(u);try{n:if("function"==typeof $){if(b=u.props,g=(s=$.contextType)&&t[s.__c],m=s?g?g.props.value:s.__:t,i.__c?k=(a=u.__c=i.__c).__=a.__E:("prototype"in $&&$.prototype.render?u.__c=a=new $(b,m):(u.__c=a=new x$1(b,m),a.constructor=$,a.render=B$1),g&&g.sub(a),a.props=b,a.state||(a.state={}),a.context=m,a.__n=t,v=a.__d=!0,a.__h=[],a._sb=[]),null==a.__s&&(a.__s=a.state),null!=$.getDerivedStateFromProps&&(a.__s==a.state&&(a.__s=h({},a.__s)),h(a.__s,$.getDerivedStateFromProps(b,a.__s))),y=a.props,p=a.state,a.__v=u,v)null==$.getDerivedStateFromProps&&null!=a.componentWillMount&&a.componentWillMount(),null!=a.componentDidMount&&a.__h.push(a.componentDidMount);else {if(null==$.getDerivedStateFromProps&&b!==y&&null!=a.componentWillReceiveProps&&a.componentWillReceiveProps(b,m),!a.__e&&null!=a.shouldComponentUpdate&&!1===a.shouldComponentUpdate(b,a.__s,m)||u.__v===i.__v){for(u.__v!==i.__v&&(a.props=b,a.state=a.__s,a.__d=!1),u.__e=i.__e,u.__k=i.__k,u.__k.forEach(function(n){n&&(n.__=u);}),w=0;w2&&(f.children=arguments.length>3?n.call(arguments,2):i),p(l.type,f,t||l.key,r||l.ref,null)}function G$1(n,l){var u={__c:l="__cC"+e$1++,__:n,Consumer:function(n,l){return n.children(l)},Provider:function(n){var u,i;return this.getChildContext||(u=[],(i={})[l]=this,this.getChildContext=function(){return i},this.shouldComponentUpdate=function(n){this.props.value!==n.value&&u.some(function(n){n.__e=!0,T$1(n);});},this.sub=function(n){u.push(n);var l=n.componentWillUnmount;n.componentWillUnmount=function(){u.splice(u.indexOf(n),1),l&&l.call(n);};}),n.children}};return u.Provider.__=u.Consumer.contextType=u}n=s.slice,l$1={__e:function(n,l,u,i){for(var t,r,o;l=l.__;)if((t=l.__c)&&!t.__)try{if((r=t.constructor)&&null!=r.getDerivedStateFromError&&(t.setState(r.getDerivedStateFromError(n)),o=t.__d),null!=t.componentDidCatch&&(t.componentDidCatch(n,i||{}),o=t.__d),o)return t.__E=t}catch(l){n=l;}throw n}},u$1=0,i$1=function(n){return null!=n&&void 0===n.constructor},t=!1,x$1.prototype.setState=function(n,l){var u;u=null!=this.__s&&this.__s!==this.state?this.__s:this.__s=h({},this.state),"function"==typeof n&&(n=n(h({},u),this.props)),n&&h(u,n),null!=n&&this.__v&&(l&&this._sb.push(l),T$1(this));},x$1.prototype.forceUpdate=function(n){this.__v&&(this.__e=!0,n&&this.__h.push(n),T$1(this));},x$1.prototype.render=_,r$1=[],f$1="function"==typeof Promise?Promise.prototype.then.bind(Promise.resolve()):setTimeout,$$1.__r=0,e$1=0; + + var r,u,i,f=[],c=[],e=l$1.__b,a=l$1.__r,v=l$1.diffed,l=l$1.__c,m=l$1.unmount;function b(){for(var t;t=f.shift();)if(t.__P&&t.__H)try{t.__H.__h.forEach(k),t.__H.__h.forEach(w$1),t.__H.__h=[];}catch(r){t.__H.__h=[],l$1.__e(r,t.__v);}}l$1.__b=function(n){r=null,e&&e(n);},l$1.__r=function(n){a&&a(n);var i=(r=n.__c).__H;i&&(u===r?(i.__h=[],r.__h=[],i.__.forEach(function(n){n.__N&&(n.__=n.__N),n.__V=c,n.__N=n.i=void 0;})):(i.__h.forEach(k),i.__h.forEach(w$1),i.__h=[])),u=r;},l$1.diffed=function(t){v&&v(t);var o=t.__c;o&&o.__H&&(o.__H.__h.length&&(1!==f.push(o)&&i===l$1.requestAnimationFrame||((i=l$1.requestAnimationFrame)||j$1)(b)),o.__H.__.forEach(function(n){n.i&&(n.__H=n.i),n.__V!==c&&(n.__=n.__V),n.i=void 0,n.__V=c;})),u=r=null;},l$1.__c=function(t,r){r.some(function(t){try{t.__h.forEach(k),t.__h=t.__h.filter(function(n){return !n.__||w$1(n)});}catch(u){r.some(function(n){n.__h&&(n.__h=[]);}),r=[],l$1.__e(u,t.__v);}}),l&&l(t,r);},l$1.unmount=function(t){m&&m(t);var r,u=t.__c;u&&u.__H&&(u.__H.__.forEach(function(n){try{k(n);}catch(n){r=n;}}),u.__H=void 0,r&&l$1.__e(r,u.__v));};var g$1="function"==typeof requestAnimationFrame;function j$1(n){var t,r=function(){clearTimeout(u),g$1&&cancelAnimationFrame(t),setTimeout(n);},u=setTimeout(r,100);g$1&&(t=requestAnimationFrame(r));}function k(n){var t=r,u=n.__c;"function"==typeof u&&(n.__c=void 0,u()),r=t;}function w$1(n){var t=r;n.__c=n.__(),r=t;} + + function g(n,t){for(var e in t)n[e]=t[e];return n}function C(n,t){for(var e in n)if("__source"!==e&&!(e in t))return !0;for(var r in t)if("__source"!==r&&n[r]!==t[r])return !0;return !1}function w(n){this.props=n;}(w.prototype=new x$1).isPureReactComponent=!0,w.prototype.shouldComponentUpdate=function(n,t){return C(this.props,n)||C(this.state,t)};var x=l$1.__b;l$1.__b=function(n){n.type&&n.type.__f&&n.ref&&(n.props.ref=n.ref,n.ref=null),x&&x(n);};var T=l$1.__e;l$1.__e=function(n,t,e,r){if(n.then)for(var u,o=t;o=o.__;)if((u=o.__c)&&u.__c)return null==t.__e&&(t.__e=e.__e,t.__k=e.__k),u.__c(n,t);T(n,t,e,r);};var I=l$1.unmount;function L(n,t,e){return n&&(n.__c&&n.__c.__H&&(n.__c.__H.__.forEach(function(n){"function"==typeof n.__c&&n.__c();}),n.__c.__H=null),null!=(n=g({},n)).__c&&(n.__c.__P===e&&(n.__c.__P=t),n.__c=null),n.__k=n.__k&&n.__k.map(function(n){return L(n,t,e)})),n}function U(n,t,e){return n&&(n.__v=null,n.__k=n.__k&&n.__k.map(function(n){return U(n,t,e)}),n.__c&&n.__c.__P===t&&(n.__e&&e.insertBefore(n.__e,n.__d),n.__c.__e=!0,n.__c.__P=e)),n}function D(){this.__u=0,this.t=null,this.__b=null;}function F(n){var t=n.__.__c;return t&&t.__a&&t.__a(n)}function V(){this.u=null,this.o=null;}l$1.unmount=function(n){var t=n.__c;t&&t.__R&&t.__R(),t&&!0===n.__h&&(n.type=null),I&&I(n);},(D.prototype=new x$1).__c=function(n,t){var e=t.__c,r=this;null==r.t&&(r.t=[]),r.t.push(e);var u=F(r.__v),o=!1,i=function(){o||(o=!0,e.__R=null,u?u(l):l());};e.__R=i;var l=function(){if(!--r.__u){if(r.state.__a){var n=r.state.__a;r.__v.__k[0]=U(n,n.__c.__P,n.__c.__O);}var t;for(r.setState({__a:r.__b=null});t=r.t.pop();)t.forceUpdate();}},c=!0===t.__h;r.__u++||c||r.setState({__a:r.__b=r.__v.__k[0]}),n.then(i,i);},D.prototype.componentWillUnmount=function(){this.t=[];},D.prototype.render=function(n,e){if(this.__b){if(this.__v.__k){var r=document.createElement("div"),o=this.__v.__k[0].__c;this.__v.__k[0]=L(this.__b,r,o.__O=o.__P);}this.__b=null;}var i=e.__a&&y(_,null,n.fallback);return i&&(i.__h=null),[y(_,null,e.__a?null:n.children),i]};var W=function(n,t,e){if(++e[1]===e[0]&&n.o.delete(t),n.props.revealOrder&&("t"!==n.props.revealOrder[0]||!n.o.size))for(e=n.u;e;){for(;e.length>3;)e.pop()();if(e[1]>>1,1),e.i.removeChild(n);}}),D$1(y(P,{context:e.context},n.__v),e.l)):e.l&&e.componentWillUnmount();}function j(n,e){var r=y($,{__v:n,i:e});return r.containerInfo=e,r}(V.prototype=new x$1).__a=function(n){var t=this,e=F(t.__v),r=t.o.get(n);return r[0]++,function(u){var o=function(){t.props.revealOrder?(r.push(u),W(t,n,r)):u();};e?e(o):o();}},V.prototype.render=function(n){this.u=null,this.o=new Map;var t=j$2(n.children);n.revealOrder&&"b"===n.revealOrder[0]&&t.reverse();for(var e=t.length;e--;)this.o.set(t[e],this.u=[1,0,this.u]);return n.children},V.prototype.componentDidUpdate=V.prototype.componentDidMount=function(){var n=this;this.o.forEach(function(t,e){W(n,e,t);});};var z="undefined"!=typeof Symbol&&Symbol.for&&Symbol.for("react.element")||60103,B=/^(?:accent|alignment|arabic|baseline|cap|clip(?!PathU)|color|dominant|fill|flood|font|glyph(?!R)|horiz|image|letter|lighting|marker(?!H|W|U)|overline|paint|pointer|shape|stop|strikethrough|stroke|text(?!L)|transform|underline|unicode|units|v|vector|vert|word|writing|x(?!C))[A-Z]/,H="undefined"!=typeof document,Z=function(n){return ("undefined"!=typeof Symbol&&"symbol"==typeof Symbol()?/fil|che|rad/i:/fil|che|ra/i).test(n)};x$1.prototype.isReactComponent={},["componentWillMount","componentWillReceiveProps","componentWillUpdate"].forEach(function(t){Object.defineProperty(x$1.prototype,t,{configurable:!0,get:function(){return this["UNSAFE_"+t]},set:function(n){Object.defineProperty(this,t,{configurable:!0,writable:!0,value:n});}});});var G=l$1.event;function J(){}function K(){return this.cancelBubble}function Q(){return this.defaultPrevented}l$1.event=function(n){return G&&(n=G(n)),n.persist=J,n.isPropagationStopped=K,n.isDefaultPrevented=Q,n.nativeEvent=n};var nn={configurable:!0,get:function(){return this.class}},tn=l$1.vnode;l$1.vnode=function(n){var t=n.type,e=n.props,u=e;if("string"==typeof t){var o=-1===t.indexOf("-");for(var i in u={},e){var l=e[i];H&&"children"===i&&"noscript"===t||"value"===i&&"defaultValue"in e&&null==l||("defaultValue"===i&&"value"in e&&null==e.value?i="value":"download"===i&&!0===l?l="":/ondoubleclick/i.test(i)?i="ondblclick":/^onchange(textarea|input)/i.test(i+t)&&!Z(e.type)?i="oninput":/^onfocus$/i.test(i)?i="onfocusin":/^onblur$/i.test(i)?i="onfocusout":/^on(Ani|Tra|Tou|BeforeInp|Compo)/.test(i)?i=i.toLowerCase():o&&B.test(i)?i=i.replace(/[A-Z0-9]/g,"-$&").toLowerCase():null===l&&(l=void 0),/^oninput$/i.test(i)&&(i=i.toLowerCase(),u[i]&&(i="oninputCapture")),u[i]=l);}"select"==t&&u.multiple&&Array.isArray(u.value)&&(u.value=j$2(e.children).forEach(function(n){n.props.selected=-1!=u.value.indexOf(n.props.value);})),"select"==t&&null!=u.defaultValue&&(u.value=j$2(e.children).forEach(function(n){n.props.selected=u.multiple?-1!=u.defaultValue.indexOf(n.props.value):u.defaultValue==n.props.value;})),n.props=u,e.class!=e.className&&(nn.enumerable="className"in e,null!=e.className&&(u.class=e.className),Object.defineProperty(u,"className",nn));}n.$$typeof=z,tn&&tn(n);};var en=l$1.__r;l$1.__r=function(n){en&&en(n),n.__c;}; + + const styleTexts = []; + const styleEls = new Map(); + function injectStyles(styleText) { + styleTexts.push(styleText); + styleEls.forEach((styleEl) => { + appendStylesTo(styleEl, styleText); + }); + } + function ensureElHasStyles(el) { + if (el.isConnected && // sometimes true if SSR system simulates DOM + el.getRootNode // sometimes undefined if SSR system simulates DOM + ) { + registerStylesRoot(el.getRootNode()); + } + } + function registerStylesRoot(rootNode) { + let styleEl = styleEls.get(rootNode); + if (!styleEl || !styleEl.isConnected) { + styleEl = rootNode.querySelector('style[data-fullcalendar]'); + if (!styleEl) { + styleEl = document.createElement('style'); + styleEl.setAttribute('data-fullcalendar', ''); + const nonce = getNonceValue(); + if (nonce) { + styleEl.nonce = nonce; + } + const parentEl = rootNode === document ? document.head : rootNode; + const insertBefore = rootNode === document + ? parentEl.querySelector('script,link[rel=stylesheet],link[as=style],style') + : parentEl.firstChild; + parentEl.insertBefore(styleEl, insertBefore); + } + styleEls.set(rootNode, styleEl); + hydrateStylesRoot(styleEl); + } + } + function hydrateStylesRoot(styleEl) { + for (const styleText of styleTexts) { + appendStylesTo(styleEl, styleText); + } + } + function appendStylesTo(styleEl, styleText) { + const { sheet } = styleEl; + const ruleCnt = sheet.cssRules.length; + styleText.split('}').forEach((styleStr, i) => { + styleStr = styleStr.trim(); + if (styleStr) { + sheet.insertRule(styleStr + '}', ruleCnt + i); + } + }); + } + // nonce + // ------------------------------------------------------------------------------------------------- + let queriedNonceValue; + function getNonceValue() { + if (queriedNonceValue === undefined) { + queriedNonceValue = queryNonceValue(); + } + return queriedNonceValue; + } + /* + TODO: discourage meta tag and instead put nonce attribute on placeholder