diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b582f6f..a18fcdae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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] + +### Fixed + +- Fix mandatory fields blocking ticket creation from a GLPI form. + ## [1.24.6] - 2026-10-06 ### Added diff --git a/inc/container.class.php b/inc/container.class.php index 0bed98d9..991cf82d 100644 --- a/inc/container.class.php +++ b/inc/container.class.php @@ -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; @@ -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) @@ -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; @@ -2227,6 +2233,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(); diff --git a/inc/destinationfield.class.php b/inc/destinationfield.class.php index 00214e10..58173bf8 100644 --- a/inc/destinationfield.class.php +++ b/inc/destinationfield.class.php @@ -42,6 +42,32 @@ class PluginFieldsDestinationField extends AbstractConfigField { + /** @var array{itemtype: class-string, fields: list}|null Item a destination is about to create */ + private static ?array $pending_item = null; + + /** @var array, array>> Fields provided by the form, per item being created */ + private static array $created_items = []; + + /** @return list|null Fields provided by the form, null when no destination is creating the item */ + public static function getFieldsProvidedByForm(string $itemtype, int $items_id): ?array + { + if ($items_id > 0) { + return self::$created_items[$itemtype][$items_id] ?? null; + } + + return (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] @@ -86,6 +112,8 @@ public function applyConfiguratedValueToInputUsingAnswers( throw new InvalidArgumentException("Unexpected config class"); } + $provided_fields = []; + if ((bool) $config->getValue()) { $answers = $answers_set->getAnswersByTypes([ PluginFieldsQuestionType::class, @@ -126,6 +154,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 { @@ -151,9 +180,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 { diff --git a/templates/fields.html.twig b/templates/fields.html.twig index c53da422..6ed1fe82 100644 --- a/templates/fields.html.twig +++ b/templates/fields.html.twig @@ -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, @@ -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()} %} @@ -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 %} diff --git a/tests/Units/ContainerItemRightTest.php b/tests/Units/ContainerItemRightTest.php index ae31b33c..3b588d1b 100644 --- a/tests/Units/ContainerItemRightTest.php +++ b/tests/Units/ContainerItemRightTest.php @@ -37,6 +37,7 @@ use Glpi\Tests\DbTestCase; use Glpi\Tests\GLPITestCase; use GlpiPlugin\Field\Tests\FieldTestTrait; +use Location; use PluginFieldsContainer; use PluginFieldsField; use PluginFieldsProfile; @@ -158,6 +159,43 @@ public function testShowDomContainerRendersReadOnlyFieldsWithoutUpdateRight(): v ); } + 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), '