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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -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();
Expand Down
49 changes: 49 additions & 0 deletions inc/destinationfield.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,32 @@

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
{
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]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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
{
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 @@ -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;
Expand Down Expand Up @@ -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), '<option value="0">'),
);
}

private function renderDomContainer(int $containers_id, Computer $computer): string
{
ob_start();
Expand Down
131 changes: 129 additions & 2 deletions tests/Units/FieldDestinationFieldTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
namespace GlpiPlugin\Field\Tests\Units;

use CommonITILObject;
use Exception;
use Glpi\Form\AnswersHandler\AnswersHandler;
use Glpi\Form\Destination\CommonITILField\SimpleValueConfig;
use Glpi\Form\Destination\FormDestinationProblem;
Expand Down Expand Up @@ -235,6 +236,130 @@ public function testDestinationWithAdditionalFieldsButDisabledInConfig(): void
);
}

public function testDestinationIsNotBlockedByMandatoryFieldNotProvidedByForm(): void
{
$this->login();
$GLOBALS['GLPI_IS_COMMAND_LINE'] = false;

$mandatory_field = $this->createField([
'label' => 'Mandatory text',
'type' => 'text',
PluginFieldsContainer::getForeignKeyField() => $this->blocks[Ticket::class]->getID(),
'ranking' => 4,
'is_active' => 1,
'is_readonly' => 0,
'mandatory' => 1,
]);

$builder = (new FormBuilder())->addQuestion("Short text", QuestionTypeShortText::class);
$form = $this->createForm($builder);

try {
foreach ([new SimpleValueConfig(1), new SimpleValueConfig(false)] as $config) {
$created_items = $this->sendFormAndAssertITILObjectAdditionalFields(
form: $form,
config: $config,
answers: [
"Short text" => "Test value",
],
expected_field_values: [Ticket::class => []],
);

// Once the destination is done, the field is mandatory again on the created ticket
$created_ticket = current($created_items);
$this->assertFalse($created_ticket->update([
'id' => $created_ticket->getID(),
'name' => 'Updated after the form submission',
]));
$this->hasSessionMessageThatContains(
__('Some mandatory fields are empty', 'fields'),
ERROR,
);
}

// The field is still mandatory outside of a form destination
$ticket = new Ticket();
$this->assertFalse($ticket->add([
'name' => 'Ticket created from the ticket form',
'content' => 'Test creation',
'entities_id' => $this->getTestRootEntity(true),
]));
$this->hasSessionMessageThatContains(
__('Some mandatory fields are empty', 'fields'),
ERROR,
);
$this->hasSessionMessageThatContains(
__('The form or source creating this item does not provide the mandatory fields above: map them to it, or make them optional.', 'fields'),
ERROR,
);
} finally {
unset($GLOBALS['GLPI_IS_COMMAND_LINE']);
$mandatory_field->delete($mandatory_field->fields, true);
}
}

public function testDestinationIsBlockedByMandatoryFieldLeftEmptyInForm(): void
{
$this->login();
$GLOBALS['GLPI_IS_COMMAND_LINE'] = false;

$mandatory_field = $this->createField([
'label' => 'Mandatory text',
'type' => 'text',
PluginFieldsContainer::getForeignKeyField() => $this->blocks[Ticket::class]->getID(),
'ranking' => 4,
'is_active' => 1,
'is_readonly' => 0,
'mandatory' => 1,
]);

$form = $this->createForm((new FormBuilder())->addQuestion(
"Mandatory text",
PluginFieldsQuestionType::class,
extra_data: json_encode([
'block_id' => $this->blocks[Ticket::class]->getID(),
'field_id' => $mandatory_field->getID(),
]),
));

try {
$this->sendFormAndAssertITILObjectAdditionalFields(
form: $form,
config: new SimpleValueConfig(1),
answers: [
"Mandatory text" => "Test value",
],
expected_field_values: [
Ticket::class => [
$mandatory_field->fields['name'] => "Test value",
],
],
);

// The form provides the field: an empty answer is not exempted
try {
$this->sendFormAndAssertITILObjectAdditionalFields(
form: $form,
config: new SimpleValueConfig(1),
answers: [
"Mandatory text" => "",
],
expected_field_values: [Ticket::class => []],
);
$this->fail('The ticket must not be created with an empty mandatory field provided by the form.');
} catch (Exception $e) {
$this->assertStringContainsString('Failed to create', $e->getMessage());
}

$this->hasSessionMessageThatContains(
__('Some mandatory fields are empty', 'fields'),
ERROR,
);
} finally {
unset($GLOBALS['GLPI_IS_COMMAND_LINE']);
}
}

public function testDestinationWithLocationAdditonalFields(): void
{
$this->login();
Expand Down Expand Up @@ -352,7 +477,7 @@ private function sendFormAndAssertITILObjectAdditionalFields(
SimpleValueConfig $config,
array $answers,
array $expected_field_values,
): void {
): array {
// Insert config
$destinations = $form->getDestinations();
foreach ($destinations as $destination) {
Expand Down Expand Up @@ -403,7 +528,7 @@ private function sendFormAndAssertITILObjectAdditionalFields(

if ($values === false) {
$this->assertEmpty($expected_fields);
return;
return $created_items;
}

foreach ($expected_fields as $field_name => $expected_value) {
Expand All @@ -415,6 +540,8 @@ private function sendFormAndAssertITILObjectAdditionalFields(
);
}
}

return $created_items;
}

private function createAndGetFormWithMultipleFieldQuestions(): Form
Expand Down
Loading