Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]

### Added

- Add interactive availability calendar to reservation question

### Fixed

- Fixed the `Table` question's columns configuration panel forcing the whole page to scroll instead of scrolling on its own
Expand Down
103 changes: 73 additions & 30 deletions public/js/modules/ReservationQuestionWidget.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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() {
Expand All @@ -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. */
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/Model/QuestionType/ReservationQuestion.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
]);
}

Expand Down
14 changes: 12 additions & 2 deletions src/Model/QuestionType/ReservationQuestionConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,26 +41,31 @@
// Unique reference to hardcoded name used for serialization
public const ALLOWED_ITEMTYPES = 'allowed_itemtypes';

public const SHOW_CALENDAR = 'show_calendar';

/** @param array<string> $allowed_itemtypes */
public function __construct(
private array $allowed_itemtypes = [],
private bool $show_calendar = true,
) {}

/** @param array{allowed_itemtypes?: array<string>} $data */
/** @param array{allowed_itemtypes?: array<string>, 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<string>} */
/** @return array{allowed_itemtypes: array<string>, show_calendar: bool} */
#[Override]
public function jsonSerialize(): array
{
return [
self::ALLOWED_ITEMTYPES => $this->allowed_itemtypes,
self::SHOW_CALENDAR => $this->show_calendar,
];
}

Expand All @@ -70,6 +75,11 @@ public function getAllowedItemtypes(): array
return $this->allowed_itemtypes;
}

public function isCalendarEnabled(): bool
{
return $this->show_calendar;
}

/** @return array<string> */
public function getEffectiveAllowedItemtypes(): array
{
Expand Down
4 changes: 2 additions & 2 deletions templates/config_form.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@
name="update"
value="1"
>
<i class="ti ti-device-floppy me-2"></i>
<span> {{ __("Save") }} </span>
<i class="ti ti-device-floppy me-2" aria-hidden="true"></i>
<span>{{ __("Save") }}</span>
</button>
</div>
</form>
Expand Down
10 changes: 10 additions & 0 deletions templates/editor/question_types/reservation_config.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@
}
) }}

{{ fields.sliderField(
'extra_data[' ~ SHOW_CALENDAR ~ ']',
extra_data.isCalendarEnabled() ? 1 : 0,
__('Show availability calendar', 'advancedforms'),
{
'full_width' : true,
'is_horizontal' : true,
}
) }}

<div class="alert alert-info mt-2 mb-0">
<div>
{{ __('Whether a technician must approve the reservation before it is confirmed is configured on the destination that uses this question, under', 'advancedforms') }}
Expand Down
8 changes: 7 additions & 1 deletion templates/reservation_question.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,17 @@
) }}
</div>
<div class="col-12 mt-1 small" data-reservation-question-availability></div>
<div class="col-12 mt-1" data-reservation-question-reservations></div>
{% if show_calendar %}
<div class="col-12 mt-2" data-reservation-question-calendar></div>
{% endif %}
</div>

<input type="hidden" name="{{ input_name }}[reservationitems_id]" data-reservation-question-field="reservationitems_id">
</div>
{% if show_calendar %}
<link rel="stylesheet" href="{{ css_path('lib/fullcalendar.css') }}">
<script src="{{ js_path('lib/fullcalendar.js') }}"></script>
{% endif %}
<script type="module">
import { ReservationQuestionWidget } from '/plugins/advancedforms/js/modules/ReservationQuestionWidget.js?v={{ constant('PLUGIN_ADVANCEDFORMS_VERSION') }}';
$(function() {
Expand Down
14 changes: 12 additions & 2 deletions tests/Model/QuestionType/ReservationQuestionConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,22 @@ class ReservationQuestionConfigTest extends TestCase
{
public function testJsonRoundTrip(): void
{
$config = new ReservationQuestionConfig(['Computer', 'Monitor']);
$config = new ReservationQuestionConfig(['Computer', 'Monitor'], show_calendar: false);
$serialized = $config->jsonSerialize();
$this->assertSame(['allowed_itemtypes' => ['Computer', 'Monitor']], $serialized);
$this->assertSame(
['allowed_itemtypes' => ['Computer', 'Monitor'], 'show_calendar' => false],
$serialized,
);

$rebuilt = ReservationQuestionConfig::jsonDeserialize($serialized);
$this->assertSame(['Computer', 'Monitor'], $rebuilt->getAllowedItemtypes());
$this->assertFalse($rebuilt->isCalendarEnabled());
}

public function testCalendarEnabledByDefault(): void
{
$config = new ReservationQuestionConfig(['Computer']);
$this->assertTrue($config->isCalendarEnabled());
}

public function testEffectiveAllowedItemtypesFallsBackToConfiguredReservationTypes(): void
Expand Down
Loading
Loading