From f6eabfbae064a57ecebb89a21097b0363b33d07c Mon Sep 17 00:00:00 2001 From: RomainLvr Date: Mon, 21 Sep 2026 15:02:36 +0200 Subject: [PATCH 1/5] Feat - Add interactive availability calendar to reservation question --- .../js/modules/ReservationQuestionWidget.js | 103 ++++++--- .../QuestionType/ReservationQuestion.php | 2 + .../ReservationQuestionConfig.php | 13 +- templates/config_form.html.twig | 4 +- .../reservation_config.html.twig | 10 + templates/reservation_question.html.twig | 8 +- .../ReservationQuestionConfigTest.php | 14 +- .../e2e/pages/AdvancedFormsReservationPage.ts | 212 ++++++++++++++++++ tests/e2e/specs/reservation_question.spec.ts | 203 +++++++++++++++++ 9 files changed, 532 insertions(+), 37 deletions(-) create mode 100644 tests/e2e/pages/AdvancedFormsReservationPage.ts create mode 100644 tests/e2e/specs/reservation_question.spec.ts diff --git a/public/js/modules/ReservationQuestionWidget.js b/public/js/modules/ReservationQuestionWidget.js index 8ff5e92..d97f51b 100644 --- a/public/js/modules/ReservationQuestionWidget.js +++ b/public/js/modules/ReservationQuestionWidget.js @@ -31,6 +31,7 @@ export class ReservationQuestionWidget { #root; + #calendar = null; #endpoint_url = `${CFG_GLPI.root_doc}/plugins/advancedforms/ReservationWidget`; /** @param {HTMLElement} root - root element rendered by templates/reservation_question.html.twig */ @@ -116,7 +117,14 @@ export class ReservationQuestionWidget { this.#setReservationItemsId($select.val()); $(this.#root.querySelector('[data-reservation-question-dates]')).removeClass('d-none'); this.#checkAvailability(); - this.#loadReservations(); + + // Absent when the question is configured without the calendar (see #ensureCalendar). + this.#ensureCalendar(); + if (this.#calendar) { + this.#calendar.unselect(); + this.#calendar.gotoDate(new Date()); + this.#calendar.refetchEvents(); + } } #onItemCleared() { @@ -129,7 +137,10 @@ export class ReservationQuestionWidget { } $(this.#root.querySelector('[data-reservation-question-dates]')).addClass('d-none'); this.#showAvailability(null); - this.#renderReservations([]); + if (this.#calendar) { + this.#calendar.unselect(); + this.#calendar.removeAllEvents(); + } } /** begin/end are rendered by the datetimeField macro (self-initializing Flatpickr); just react to changes. */ @@ -197,46 +208,78 @@ export class ReservationQuestionWidget { this.#showStatus(__('The end date must be after the start date', 'advancedforms'), 'text-danger'); } - /** Fetches the equipment's existing reservations and lists them so the user can see busy slots. */ - #loadReservations() { - const reservationitems_id = this.#root.querySelector('[data-reservation-question-field="reservationitems_id"]')?.value ?? ''; - if (!reservationitems_id) { - this.#renderReservations([]); + /** Builds the calendar once; item changes afterwards just refetch its events (see #onItemSelected). */ + #ensureCalendar() { + if (this.#calendar) { return; } - $.post(`${this.#endpoint_url}/Reservations`, { reservationitems_id }) - .done((data) => this.#renderReservations(Array.isArray(data) ? data : [])) - .fail(() => this.#renderReservations([])); - } - - /** @param {Array<{begin: string, end: string}>} reservations */ - #renderReservations(reservations) { - const container = this.#root.querySelector('[data-reservation-question-reservations]'); + const container = this.#root.querySelector('[data-reservation-question-calendar]'); if (!container) { return; } - if (reservations.length === 0) { - container.innerHTML = ''; + this.#calendar = new FullCalendar.Calendar(container, { + plugins: ['timeGrid', 'interaction'], + defaultView: 'timeGridWeek', + header: { left: 'prev,next today', center: 'title', right: 'timeGridWeek,timeGridDay' }, + height: 450, + selectable: true, + selectMirror: true, + // Keep the highlight visible when focus leaves the calendar (e.g. another question); + // it is cleared explicitly on item change/select instead (see #onItemSelected/#onItemCleared). + unselectAuto: false, + select: (info) => this.#onSlotSelected(info), + events: (info, successCallback, failureCallback) => this.#fetchEvents(info, successCallback, failureCallback), + }); + this.#calendar.render(); + } + + /** FullCalendar event source: reuses the existing Reservations endpoint, scoped to the visible range. */ + #fetchEvents(info, successCallback, failureCallback) { + const reservationitems_id = this.#root.querySelector('[data-reservation-question-field="reservationitems_id"]')?.value ?? ''; + if (!reservationitems_id) { + successCallback([]); return; } - const title = document.createElement('div'); - title.className = 'text-muted small mb-1'; - title.textContent = __('Existing reservations for this item', 'advancedforms'); - - const list = document.createElement('ul'); - list.className = 'list-unstyled small mb-0'; - for (const reservation of reservations) { - const line = document.createElement('li'); - line.className = 'text-muted'; - // textContent, never innerHTML: dates come from the server but stay untrusted here. - line.textContent = `${reservation.begin} → ${reservation.end}`; - list.appendChild(line); + $.post(`${this.#endpoint_url}/Reservations`, { + reservationitems_id, + begin: this.#formatForServer(info.start), + end: this.#formatForServer(info.end), + }) + .done((data) => successCallback((Array.isArray(data) ? data : []).map((reservation) => ({ + title: __('Reserved', 'advancedforms'), + start: reservation.begin.replace(' ', 'T'), + end: reservation.end.replace(' ', 'T'), + color: 'var(--tblr-red)', + editable: false, + overlap: false, + })))) + .fail(() => failureCallback()); + } + + /** + * User picked a free slot on the calendar: mirror it into the begin/end pickers used by the + * rest of the widget. The selection highlight is left in place (not unselected) so the user + * can see what they just picked; it clears on the next selection or item change instead. + */ + #onSlotSelected(info) { + const begin_picker = this.#getBeginInput()?.closest('.flatpickr')?._flatpickr; + const end_picker = this.#getEndInput()?.closest('.flatpickr')?._flatpickr; + if (!begin_picker || !end_picker) { + return; } - container.replaceChildren(title, list); + begin_picker.setDate(info.start); + end_picker.setDate(info.end); + this.#checkAvailability(); + } + + /** @returns {string} date formatted as 'Y-m-d H:i:s', as expected by the Reservations endpoint. */ + #formatForServer(date) { + const pad = (n) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } #showAvailability(available) { diff --git a/src/Model/QuestionType/ReservationQuestion.php b/src/Model/QuestionType/ReservationQuestion.php index 13eea3a..07586be 100644 --- a/src/Model/QuestionType/ReservationQuestion.php +++ b/src/Model/QuestionType/ReservationQuestion.php @@ -128,6 +128,7 @@ public function renderAdministrationTemplate(Question|null $question): string 'extra_data' => $config, 'reservation_types' => $reservation_types, 'ALLOWED_ITEMTYPES' => ReservationQuestionConfig::ALLOWED_ITEMTYPES, + 'SHOW_CALENDAR' => ReservationQuestionConfig::SHOW_CALENDAR, 'destination_label' => FormDestination::getTypeName(2), 'timeline_category_label' => Category::TIMELINE->getLabel(), 'pre_reservation_label' => (new PreReservationField())->getLabel(), @@ -158,6 +159,7 @@ public function renderEndUserTemplate(Question $question): string return TemplateRenderer::getInstance()->render('@advancedforms/reservation_question.html.twig', [ 'input_name' => $question->getEndUserInputName(), 'allowed_itemtypes' => $config->getAllowedItemtypes(), + 'show_calendar' => $config->isCalendarEnabled(), ]); } diff --git a/src/Model/QuestionType/ReservationQuestionConfig.php b/src/Model/QuestionType/ReservationQuestionConfig.php index bfc38fe..55d0e57 100644 --- a/src/Model/QuestionType/ReservationQuestionConfig.php +++ b/src/Model/QuestionType/ReservationQuestionConfig.php @@ -40,27 +40,31 @@ { // Unique reference to hardcoded name used for serialization public const ALLOWED_ITEMTYPES = 'allowed_itemtypes'; + public const SHOW_CALENDAR = 'show_calendar'; /** @param array $allowed_itemtypes */ public function __construct( private array $allowed_itemtypes = [], + private bool $show_calendar = true, ) {} - /** @param array{allowed_itemtypes?: array} $data */ + /** @param array{allowed_itemtypes?: array, show_calendar?: bool} $data */ #[Override] public static function jsonDeserialize(array $data): self { return new self( allowed_itemtypes: $data[self::ALLOWED_ITEMTYPES] ?? [], + show_calendar: $data[self::SHOW_CALENDAR] ?? true, ); } - /** @return array{allowed_itemtypes: array} */ + /** @return array{allowed_itemtypes: array, show_calendar: bool} */ #[Override] public function jsonSerialize(): array { return [ self::ALLOWED_ITEMTYPES => $this->allowed_itemtypes, + self::SHOW_CALENDAR => $this->show_calendar, ]; } @@ -70,6 +74,11 @@ public function getAllowedItemtypes(): array return $this->allowed_itemtypes; } + public function isCalendarEnabled(): bool + { + return $this->show_calendar; + } + /** @return array */ public function getEffectiveAllowedItemtypes(): array { diff --git a/templates/config_form.html.twig b/templates/config_form.html.twig index 33c9796..a6ce18d 100644 --- a/templates/config_form.html.twig +++ b/templates/config_form.html.twig @@ -62,8 +62,8 @@ name="update" value="1" > - - {{ __("Save") }} + + {{ __("Save") }} diff --git a/templates/editor/question_types/reservation_config.html.twig b/templates/editor/question_types/reservation_config.html.twig index f4fff01..c1e3bec 100644 --- a/templates/editor/question_types/reservation_config.html.twig +++ b/templates/editor/question_types/reservation_config.html.twig @@ -51,6 +51,16 @@ } ) }} + {{ fields.sliderField( + 'extra_data[' ~ SHOW_CALENDAR ~ ']', + extra_data.isCalendarEnabled() ? 1 : 0, + __('Show availability calendar', 'advancedforms'), + { + 'full_width' : true, + 'is_horizontal' : false, + } + ) }} +
{{ __('Whether a technician must approve the reservation before it is confirmed is configured on the destination that uses this question, under', 'advancedforms') }} diff --git a/templates/reservation_question.html.twig b/templates/reservation_question.html.twig index d322e64..517161c 100644 --- a/templates/reservation_question.html.twig +++ b/templates/reservation_question.html.twig @@ -66,11 +66,17 @@ ) }}
-
+ {% if show_calendar %} +
+ {% endif %}
+{% if show_calendar %} + + +{% endif %}