Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/continuous-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
name: "Generate CI matrix"
uses: "glpi-project/plugin-ci-workflows/.github/workflows/generate-ci-matrix.yml@v1"
with:
glpi-version: "11.0.x"
glpi-version: "12.0.x"
ci:
name: "GLPI ${{ matrix.glpi-version }} - php:${{ matrix.php-version }} - ${{ matrix.db-image }}"
needs: "generate-ci-matrix"
Expand Down
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]
## [UNRELEASED]

### Added

- GLPI 12 compatibility

### Added

Expand Down
24 changes: 24 additions & 0 deletions public/js/modules/AfTreeCascadeDropdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,36 @@ export class AfTreeCascadeDropdown {

this.#setupAdapt($select);
this.#bindChangeEvent($select);
this.#observeValidationState($select);

if (this.auto_load_parent_id > 0 && this.next_container_id) {
this.#loadChildren(this.auto_load_parent_id, $(`#${this.next_container_id}`));
}
}

/**
* The generic form renderer marks every visible <select> found inside
* the question wrapper as invalid when the question fails mandatory
* validation, since it has no way to know that this question is made of
* several cascading levels sharing a single answer. As soon as a level
* already has a value selected, strip the wrongly-applied invalid state
* (and its duplicated "required" tooltip) so only the actually empty
* level stays flagged.
*/
#observeValidationState($select) {
const select = $select[0];
const observer = new MutationObserver(() => {
const value = parseInt($select.val(), 10);
if (value > 0 && select.classList.contains('is-invalid')) {
select.classList.remove('is-invalid');
select.removeAttribute('aria-invalid');
select.removeAttribute('aria-errormessage');
$select.closest('.af-tree-level-wrapper').find('.invalid-tooltip').remove();
}
});
observer.observe(select, { attributes: true, attributeFilter: ['class'] });
}

#setupAdapt($select) {
if ($select.hasClass('af-tree-cascade-select')) {
setupAdaptDropdown({
Expand Down
88 changes: 42 additions & 46 deletions public/js/modules/ReservationQuestionWidget.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,27 @@
* -------------------------------------------------------------------------
*/

/* global FullCalendar */

let fullcalendar_loader = null;

/**
* GLPI core bundles FullCalendar inside its Vue planning and no longer exposes it globally:
* load the plugin's own copy (see public/lib/fullcalendar), once per page whatever the number of questions.
* FullCalendar v6 injects its own styles, no stylesheet to load.
*/
function loadFullCalendar() {
fullcalendar_loader ??= new Promise((resolve, reject) => {
const script = document.createElement('script');
// Relative to this module, with the same cache-busting query string.
script.src = new URL(`../../lib/fullcalendar/fullcalendar.js${new URL(import.meta.url).search}`, import.meta.url).href;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
return fullcalendar_loader;
}

export class ReservationQuestionWidget {
#root;
#calendar = null;
Expand Down Expand Up @@ -118,16 +139,23 @@ export class ReservationQuestionWidget {
$(this.#root.querySelector('[data-reservation-question-dates]')).removeClass('d-none');
this.#checkAvailability();

// Absent when the question is configured without the calendar (see #ensureCalendar):
// Absent when the question is configured without the calendar:
// fall back to the plain list of existing reservations.
this.#ensureCalendar();
if (this.#calendar) {
if (!this.#getCalendarContainer()) {
this.#loadReservations();
return;
}

loadFullCalendar().then(() => {
this.#ensureCalendar();
this.#calendar.unselect();
this.#calendar.today();
this.#calendar.refetchEvents();
} else {
this.#loadReservations();
}
});
}

#getCalendarContainer() {
return this.#root.querySelector('[data-reservation-question-calendar]');
}

#onItemCleared() {
Expand Down Expand Up @@ -218,28 +246,20 @@ export class ReservationQuestionWidget {
return;
}

const container = this.#root.querySelector('[data-reservation-question-calendar]');
if (!container) {
return;
}

this.#loadCalendarCss();

// Same locale/hours setup as core js/planning.js; planning hours are absent from CFG_GLPI for anonymous sessions.
const loaded_locales = typeof FullCalendarLocales !== 'undefined' ? Object.keys(FullCalendarLocales) : [];
// Like core planning: UTC mode + server "now", so the grid shows server/session wall-clock
// times (those of the reservations) whatever the browser timezone.
const now = new Date(`${this.#root.dataset.now.replace(' ', 'T')}Z`);
this.#calendar = new FullCalendar.Calendar(container, {
plugins: ['timeGrid', 'interaction'],
defaultView: 'timeGridWeek',
header: { left: 'prev,next today', center: 'title', right: 'timeGridWeek,timeGridDay' },
locale: loaded_locales.length === 1 ? loaded_locales[0] : undefined,
this.#calendar = new FullCalendar.Calendar(this.#getCalendarContainer(), {
initialView: 'timeGridWeek',
headerToolbar: { left: 'prev,next today', center: 'title', right: 'timeGridWeek,timeGridDay' },
// Like core BaseFullCalendar.vue: FullCalendar falls back from e.g. "fr-FR" to "fr", then to English.
locale: document.documentElement.lang,
timeZone: 'UTC',
now,
nowIndicator: true,
minTime: CFG_GLPI.planning_begin ?? '00:00:00',
maxTime: CFG_GLPI.planning_end ?? '24:00:00',
// Planning hours are absent from CFG_GLPI for anonymous sessions.
slotMinTime: CFG_GLPI.planning_begin ?? '00:00:00',
slotMaxTime: CFG_GLPI.planning_end ?? '24:00:00',
height: 450,
selectable: true,
selectMirror: true,
Expand All @@ -256,30 +276,6 @@ export class ReservationQuestionWidget {
this.#calendar.render();
}

/**
* Several calendar-enabled questions may share the page: only add the stylesheet once.
* It loads asynchronously, so resize the calendar once it applies: slot positions measured
* on the unstyled grid would otherwise misplace the events until the next re-render.
*/
#loadCalendarCss() {
const href = this.#root.dataset.calendarCss;
if (!href) {
return;
}

let link = document.querySelector(`link[rel="stylesheet"][href="${CSS.escape(href)}"]`);
if (!link) {
link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
document.head.appendChild(link);
}

if (!link.sheet) {
link.addEventListener('load', () => this.#calendar?.updateSize(), { once: true });
}
}

/** 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 ?? '';
Expand Down
36 changes: 36 additions & 0 deletions public/lib/fullcalendar/fullcalendar.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions setup.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@

// Minimal GLPI version, inclusive
/** @phpstan-ignore theCodingMachineSafe.function (safe to assume this isn't already defined) */
define("PLUGIN_ADVANCEDFORMS_MIN_GLPI_VERSION", "11.0.7");
define("PLUGIN_ADVANCEDFORMS_MIN_GLPI_VERSION", "12.0.0");

// Maximum GLPI version, exclusive
/** @phpstan-ignore theCodingMachineSafe.function (safe to assume this isn't already defined) */
define("PLUGIN_ADVANCEDFORMS_MAX_GLPI_VERSION", "11.0.99");
define("PLUGIN_ADVANCEDFORMS_MAX_GLPI_VERSION", "12.0.99");

/**
* Init hooks of the plugin.
Expand Down Expand Up @@ -88,7 +88,7 @@ function plugin_init_advancedforms(): void
function plugin_version_advancedforms(): array
{
return [
'name' => 'advancedforms',
'name' => 'Advanced forms',
'version' => PLUGIN_ADVANCEDFORMS_VERSION,
'author' => '<a href="http://www.teclib.com">Teclib\'</a>',
'license' => '',
Expand Down
3 changes: 2 additions & 1 deletion src/Controller/AbstractReservationWidgetController.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
use Glpi\Controller\AbstractController;
use Glpi\Exception\Http\AccessDeniedHttpException;
use Glpi\Exception\Http\BadRequestHttpException;
use Reservation;
use ReservationItem;
use Session;

Expand All @@ -45,7 +46,7 @@ abstract class AbstractReservationWidgetController extends AbstractController
/** @throws AccessDeniedHttpException when the current user cannot read reservations. */
protected function checkReservationAccess(): void
{
if (!Session::haveRightsOr('reservation', [READ, ReservationItem::RESERVEANITEM])) {
if (!Session::haveRightsOr(Reservation::$rightname, [READ, ReservationItem::RESERVEANITEM])) {
throw new AccessDeniedHttpException();
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/Controller/LdapDropdownController.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ public function __invoke(Request $request): Response
$page_limit = $request->request->getInt('page_limit', 0);

// Make sure mandatory parameters are set
if ($page == 0 || $page_limit == 0) {
if ($page === 0 || $page_limit === 0) {
throw new BadRequestHttpException();
}

Expand Down
4 changes: 2 additions & 2 deletions src/Controller/TreeDropdownChildrenController.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ public function __invoke(Request $request): Response
$question_type = $question->getQuestionType();

$itemtype = $question_type->getDefaultValueItemtype($question) ?? '';
$field_name = $question->getEndUserInputName() . '[items_id]';
$field_name = $question->getEndUserInputName() . '[items_ids]';
$aria_label = $question_type->items_id_aria_label ?? __('Select a dropdown item');

$dropdown_restriction_params = $question_type->getDropdownRestrictionParams($question);
Expand All @@ -99,7 +99,7 @@ public function __invoke(Request $request): Response
$is_recursive = $item_check->maybeRecursive();

$entity_restrict = getEntitiesRestrictCriteria($table, '', '', $is_recursive);
if (!empty($entity_restrict)) {
if ($entity_restrict !== []) {
$where = array_merge($where, $entity_restrict);
}

Expand Down
2 changes: 2 additions & 0 deletions src/Model/Condition/TableRowCountConditionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@

namespace GlpiPlugin\Advancedforms\Model\Condition;

use Glpi\DBAL\JsonFieldInterface;
use Glpi\Form\Condition\ConditionData;
use Glpi\Form\Condition\ConditionHandler\ConditionHandlerInterface;
use Glpi\Form\Condition\ValueOperator;
Expand Down Expand Up @@ -86,6 +87,7 @@ public function applyValueOperator(
mixed $a,
ValueOperator $operator,
mixed $b,
?JsonFieldInterface $config = null,
): bool {
$rows = $this->countFilledRows($a);
$threshold = (int) (is_scalar($b) ? $b : 0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
namespace GlpiPlugin\Advancedforms\Model\ConditionHandler;

use CommonDBTM;
use Glpi\DBAL\JsonFieldInterface;
use Glpi\Form\Condition\ConditionData;
use Glpi\Form\Condition\ConditionHandler\ConditionHandlerInterface;
use Glpi\Form\Condition\ValueOperator;
Expand Down Expand Up @@ -83,12 +84,14 @@ public function applyValueOperator(
mixed $a,
ValueOperator $operator,
mixed $b,
?JsonFieldInterface $config,
): bool {
if (!is_array($a) || !isset($a['items_id'])) {
if (!is_array($a) || !isset($a['items_ids'])) {
return false;
}

$item = $this->itemtype::getById($a['items_id']);
$items_id = is_array($a['items_ids']) ? reset($a['items_ids']) : $a['items_ids'];
$item = $this->itemtype::getById($items_id);
if (!$item) {
return false;
}
Expand Down
4 changes: 1 addition & 3 deletions src/Model/Destination/PreReservationFieldConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,7 @@ public function __construct(
public static function jsonDeserialize(array $data): self
{
$strategy = PreReservationFieldStrategy::tryFrom($data[self::STRATEGY] ?? "");
if ($strategy === null) {
$strategy = PreReservationFieldStrategy::NO_PRERESERVATION;
}
$strategy ??= PreReservationFieldStrategy::NO_PRERESERVATION;

return new self(
strategy: $strategy,
Expand Down
8 changes: 4 additions & 4 deletions src/Model/QuestionType/AdvancedCategory.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ public function getLabel(): string
if (count($types) === 1) {
$type = array_pop($types);
return $type->getName();
} else {
return __('Advanced', 'advancedforms');
}

return __('Advanced', 'advancedforms');
}

#[Override]
Expand All @@ -58,9 +58,9 @@ public function getIcon(): string
if (count($types) === 1) {
$type = array_pop($types);
return $type->getIcon();
} else {
return 'ti ti-adjustments-plus';
}

return 'ti ti-adjustments-plus';
}

#[Override]
Expand Down
1 change: 1 addition & 0 deletions src/Model/QuestionType/TableColumnRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ public function matchesPattern(string $value): bool
$value,
ValueOperator::MATCH_REGEX,
$this->pattern,
null,
);
}
}
8 changes: 4 additions & 4 deletions src/Model/QuestionType/TreeCascadeDropdownQuestion.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,14 @@ public function renderEndUserTemplate(Question $question): string
return parent::renderEndUserTemplate($question);
}

$default_items_id = $this->getDefaultValueItemId($question);
$default_items_id = $this->getDefaultValuesItemIds($question)[0] ?? 0;
$aria_label = $this->items_id_aria_label;

$tree_table = $itemtype::getTable();
$foreign_key = $itemtype::getForeignKeyField();

$rand_tree = random_int(1000000, 9999999);
$final_items_id_name = $question->getEndUserInputName() . '[items_id]';
$final_items_id_name = $question->getEndUserInputName() . '[items_ids]';
$level2_container = 'level2_container_' . $rand_tree;

$dropdown_restriction_params = $this->getDropdownRestrictionParams($question);
Expand Down Expand Up @@ -262,7 +262,7 @@ private function buildAncestorChain(
/** @var array<string, mixed> $base_where */
$base_where = [];
$entity_restrict = getEntitiesRestrictCriteria($table, '', '', $is_recursive);
if (!empty($entity_restrict)) {
if ($entity_restrict !== []) {
$base_where = array_merge($base_where, $entity_restrict);
}

Expand Down Expand Up @@ -331,7 +331,7 @@ private function getFirstLevelItems(
$is_recursive = $item_check->maybeRecursive();

$entity_restrict = getEntitiesRestrictCriteria($table, '', '', $is_recursive);
if (!empty($entity_restrict)) {
if ($entity_restrict !== []) {
$base_where = array_merge($base_where, $entity_restrict);
}

Expand Down
6 changes: 3 additions & 3 deletions src/Model/TicketReservationRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@
/** A ticket-driven request to reserve an equipment item for a timeframe. */
final class TicketReservationRequest extends CommonDBChild
{
public static $itemtype = 'Ticket';
public static string $itemtype = 'Ticket';

public static $items_id = 'tickets_id';
public static string $items_id = 'tickets_id';

public static $rightname = 'ticket';
public static string $rightname = 'ticket';

public const STATUS_WAITING = 1;

Expand Down
1 change: 0 additions & 1 deletion templates/config_form.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@
</div>

<input type="hidden" name="config_context" value="advancedforms"/>
<input type="hidden" name="_glpi_csrf_token" value="{{ csrf_token() }}"/>

<div class="mx-n2 d-flex">
<button
Expand Down
Loading
Loading