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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Fix fields values updates not applying the expected checks in some cases
- Fix `Yes/No` field values returned as strings instead of integers in search results
- Fix ticket observers being able to edit and save additional fields they are not allowed to modify
- Fix mandatory fields blocking ticket creation from a GLPI form.

## [1.24.6] - 2026-10-06

Expand Down
8 changes: 8 additions & 0 deletions inc/container.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -1747,6 +1747,9 @@ public static function validateValues($data, $itemtype, $massiveaction, $is_crea

$stored_values = self::getStoredValues($container, $itemtype, (int) ($data['items_id'] ?? 0));

// Set while a form destination creates the item: only the fields the form provides are then mandatory
$form_fields = PluginFieldsDestinationField::getFieldsProvidedByForm($itemtype, (int) ($data['items_id'] ?? 0));

foreach ($fields as $field) {
if (!$field['is_active']) {
continue;
Expand Down Expand Up @@ -1791,6 +1794,7 @@ public static function validateValues($data, $itemtype, $massiveaction, $is_crea

if (
!self::isMandatoryCheckBypassed($data)
&& ($form_fields === null || in_array((int) $field['id'], $form_fields, true))
&& $field['mandatory'] == 1
&& (
empty($value)
Expand Down Expand Up @@ -2044,6 +2048,8 @@ public static function findAllContainers($itemtype)
*/
public static function postItemAdd(CommonDBTM $item)
{
PluginFieldsDestinationField::registerCreatedItem($item);

if (array_key_exists('_plugin_fields_data', $item->input)) {
$data = $item->input['_plugin_fields_data'];
$data['itemtype'] = $item::class;
Expand Down Expand Up @@ -2248,6 +2254,8 @@ private static function checkContainerMandatory(CommonDBTM $item, PluginFieldsCo
}

if (!$item->isNewItem()) {
$data['items_id'] = $item->getID();

// merge already persisted values to avoid false positives
$classname = self::getClassname($item::getType(), $loc_c->fields['name']);
$dbu = new DbUtils();
Expand Down
46 changes: 46 additions & 0 deletions inc/destinationfield.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,29 @@

class PluginFieldsDestinationField extends AbstractConfigField
{
/** @var array{itemtype: class-string<CommonDBTM>, fields: list<int>}|null Item a destination is about to create */
private static ?array $pending_item = null;

/** @var array<class-string<CommonDBTM>, array<int, list<int>>> Fields provided by the form, per item being created */
private static array $created_items = [];

/** @return list<int>|null Fields provided by the form, null when no destination is creating the item */
public static function getFieldsProvidedByForm(string $itemtype, int $items_id): ?array
{
// Also covers updates made by post_addItem(), before the "item_add" hook registers the item
return self::$created_items[$itemtype][$items_id] ?? ((self::$pending_item['itemtype'] ?? null) === $itemtype ? self::$pending_item['fields'] : null);
}

public static function registerCreatedItem(CommonDBTM $item): void
{
if ((self::$pending_item['itemtype'] ?? null) !== $item::class) {
return;
}

self::$created_items[$item::class][$item->getID()] = self::$pending_item['fields'];
self::$pending_item = null;
}

public function __construct(private readonly AbstractCommonITILFormDestination $itil_destination) {}

#[Override]
Expand Down Expand Up @@ -86,6 +109,8 @@ public function applyConfiguratedValueToInputUsingAnswers(
throw new InvalidArgumentException("Unexpected config class");
}

$provided_fields = [];

if ((bool) $config->getValue()) {
$answers = $answers_set->getAnswersByTypes([
PluginFieldsQuestionType::class,
Expand Down Expand Up @@ -126,6 +151,7 @@ public function applyConfiguratedValueToInputUsingAnswers(
}

$input['c_id'] = $block_id;
$provided_fields[] = $field->getID();
if ($field->fields['type'] == 'dropdown') {
$field_name = 'plugin_fields_' . $field->fields['name'] . 'dropdowns_id';
} else {
Expand All @@ -151,9 +177,29 @@ public function applyConfiguratedValueToInputUsingAnswers(
}
}

// Mandatory fields the form does not provide must not block the creation
self::$pending_item = [
'itemtype' => $this->itil_destination->getTarget()::class,
'fields' => $provided_fields,
];

return $input;
}

#[Override]
public function applyConfiguratedValueAfterDestinationCreation(
FormDestination $destination,
JsonFieldInterface $config,
AnswersSet $answers_set,
array $created_objects,
): void {
// The destination is done: its items are validated like any other from now on
self::$pending_item = null;
foreach ($created_objects[$destination->getID()] ?? [] as $item) {
unset(self::$created_items[$item::class][$item->getID()]);
}
}

#[Override]
public function getDefaultConfig(Form $form): SimpleValueConfig
{
Expand Down
6 changes: 4 additions & 2 deletions templates/fields.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@
{% set value = item.input[name]|default(field['value']) %}
{% set readonly = field['is_readonly'] %}
{% set rand = random() %}
{# On an item form, a single dropdown without value must still render its empty option #}
{% set dropdown_value = field['multiple'] or massiveaction or in_form_builder ? value : value|default(0) %}

{% set field_options = field_options|merge({
'readonly': readonly or not canedit,
Expand Down Expand Up @@ -161,7 +163,7 @@
{% if input_name == name %}
{% set name_fk = call("getForeignKeyFieldForItemType", [dropdown_itemtype]) %}
{% endif %}
{{ macros.dropdownField(dropdown_itemtype, name_fk ?? input_name, value, label, field_options|merge(dropdown_options|default({}))) }}
{{ macros.dropdownField(dropdown_itemtype, name_fk ?? input_name, dropdown_value, label, field_options|merge(dropdown_options|default({}))) }}

{% elseif type matches '/^dropdown-.+/i' %}
{% set dropdown_options = {'entity': item.getEntityID()} %}
Expand All @@ -176,7 +178,7 @@
{% if field['multiple'] %}
{% set dropdown_options = dropdown_options|merge({'multiple': true}) %}
{% endif %}
{{ macros.dropdownField(field['dropdown_class'], input_name, value, label, field_options|merge(dropdown_options|default({}))) }}
{{ macros.dropdownField(field['dropdown_class'], input_name, dropdown_value, label, field_options|merge(dropdown_options|default({}))) }}

{% elseif type == 'glpi_item' %}
{% if not massiveaction %}
Expand Down
38 changes: 38 additions & 0 deletions tests/Units/ContainerItemRightTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
use Glpi\Tests\DbTestCase;
use Glpi\Tests\GLPITestCase;
use GlpiPlugin\Field\Tests\FieldTestTrait;
use Location;
use PluginFieldsContainer;
use PluginFieldsField;
use PluginFieldsProfile;
Expand Down Expand Up @@ -213,6 +214,43 @@ public function testContainerValuesCanOnlyBeWrittenThroughContainer(): void
$this->assertFalse($values->can(-1, CREATE, $input));
}

public function testShowDomContainerRendersEmptyOptionForMandatoryDropdowns(): void
{
$entity_id = getItemByTypeName(Entity::class, '_test_root_entity', true);
$this->setEntity($entity_id, true);

$container = $this->createFieldContainer([
'label' => 'Dom container ' . $this->getUniqueString(),
'type' => 'dom',
'itemtypes' => [Computer::class],
'is_active' => 1,
'entities_id' => $entity_id,
'is_recursive' => 1,
]);
foreach (['dropdown', 'dropdown-' . Location::class] as $ranking => $type) {
$this->createField([
'label' => 'Mandatory ' . $type,
'type' => $type,
PluginFieldsContainer::getForeignKeyField() => $container->getID(),
'ranking' => $ranking + 1,
'is_active' => 1,
'is_readonly' => 0,
'mandatory' => 1,
]);
}

$computer = $this->createItem(Computer::class, [
'name' => 'Computer ' . $this->getUniqueString(),
'entities_id' => $entity_id,
]);

// Without its empty option, a mandatory dropdown without value makes the save a silent no-op
$this->assertSame(
2,
substr_count($this->renderDomContainer($container->getID(), $computer), '<option value="0">'),
);
}

private function renderDomContainer(int $containers_id, Computer $computer): string
{
ob_start();
Expand Down
Loading
Loading