diff --git a/CHANGELOG.md b/CHANGELOG.md index 93da59e3..63ce89a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fix administrators losing access to a block's configuration after setting a profile to "no access" on that block. - Fix dependency conflict with GLPI core by no longer vendoring symfony/deprecation-contracts and symfony/polyfill-ctype. - Fix default field values not being applied when fields are empty on creation +- Fix a field's default value not being applied to existing items and not being shown in search results for items with no dedicated row in the container table ## [1.24.5] - 2026-09-11 diff --git a/inc/abstractcontainerinstance.class.php b/inc/abstractcontainerinstance.class.php index 7b918fd2..772cedd6 100644 --- a/inc/abstractcontainerinstance.class.php +++ b/inc/abstractcontainerinstance.class.php @@ -133,6 +133,10 @@ public static function getSpecificValueToDisplay($field, $values, array $options return ''; // Itemtype not exists (maybe a deactivated plugin) } + if (empty($values[$field]) && !empty($field_specs->fields['default_value'])) { + $values[$field] = $field_specs->fields['default_value']; + } + if (empty($values[$field])) { return ''; // Value not defined } diff --git a/inc/container.class.php b/inc/container.class.php index 3aa78b09..fc3904b8 100644 --- a/inc/container.class.php +++ b/inc/container.class.php @@ -29,6 +29,7 @@ */ use Glpi\DBAL\QueryExpression; +use Glpi\DBAL\QueryFunction; use Glpi\Features\Clonable; class PluginFieldsContainer extends CommonDBTM @@ -2197,6 +2198,7 @@ public static function getAddSearchOptions($itemtype, $containers_id = false) 'glpi_plugin_fields_fields.is_readonly', 'glpi_plugin_fields_fields.allowed_values', 'glpi_plugin_fields_fields.multiple', + 'glpi_plugin_fields_fields.default_value', 'glpi_plugin_fields_containers.id AS container_id', 'glpi_plugin_fields_containers.name AS container_name', 'glpi_plugin_fields_containers.label AS container_label', @@ -2304,6 +2306,21 @@ public static function getAddSearchOptions($itemtype, $containers_id = false) $opt[$i]['datatype'] = 'string'; } + if ( + (string) $data['default_value'] !== '' + && !in_array($data['type'], ['dropdown', 'glpi_item'], true) + && !preg_match('/^dropdown-.+$/i', (string) $data['type']) + ) { + $default_expression = in_array($data['type'], ['date', 'datetime'], true) && $data['default_value'] === 'now' + ? QueryFunction::now() + : new QueryExpression($DB::quoteValue($data['default_value'])); + + $opt[$i]['computation'] = QueryFunction::coalesce([ + 'TABLE.' . $data['field_name'], + $default_expression, + ]); + } + $dropdown_matches = []; if ($data['type'] === 'dropdown') { $field_name = 'plugin_fields_' . $data['field_name'] . 'dropdowns_id'; @@ -2324,6 +2341,8 @@ public static function getAddSearchOptions($itemtype, $containers_id = false) $opt[$i]['joinparams']['jointype'] = ''; $opt[$i]['joinparams']['beforejoin']['table'] = $tablename; $opt[$i]['joinparams']['beforejoin']['joinparams']['jointype'] = 'itemtype_item'; + + self::addDropdownDefaultValueComputation($opt[$i], (string) $data['default_value']); } } elseif ( preg_match('/^dropdown-(?.+)$/i', (string) $data['type'], $dropdown_matches) @@ -2346,6 +2365,8 @@ public static function getAddSearchOptions($itemtype, $containers_id = false) $opt[$i]['joinparams']['jointype'] = ''; $opt[$i]['joinparams']['beforejoin']['table'] = $tablename; $opt[$i]['joinparams']['beforejoin']['joinparams']['jointype'] = 'itemtype_item'; + + self::addDropdownDefaultValueComputation($opt[$i], (string) $data['default_value']); } } elseif ($data['type'] === 'glpi_item') { $itemtype_field = sprintf('itemtype_%s', $data['field_name']); @@ -2377,6 +2398,29 @@ public static function getAddSearchOptions($itemtype, $containers_id = false) return $opt; } + /** + * Add a computation to the search option for a dropdown field to use a default value if the field is null. + */ + private static function addDropdownDefaultValueComputation(array &$searchoption, string $default_value): void + { + /** @var DBmysql $DB */ + global $DB; + + if ($default_value === '') { + return; + } + + $default_name = Dropdown::getDropdownName($searchoption['table'], (int) $default_value); + if ($default_name === '') { + return; + } + + $searchoption['computation'] = QueryFunction::coalesce([ + 'TABLE.' . $searchoption['field'], + new QueryExpression($DB::quoteValue($default_name)), + ]); + } + /** * Get subtypes for specified itemtype. * Was previously retrieved using $item::defineTabs() but diff --git a/inc/field.class.php b/inc/field.class.php index 234408e3..6e6ecdea 100644 --- a/inc/field.class.php +++ b/inc/field.class.php @@ -1500,6 +1500,67 @@ public function post_addItem() if (!isset($this->input['clone']) || !$this->input['clone']) { PluginFieldsLabelTranslation::createForItem($this); } + + $this->applyDefaultValueToExistingItems(); + } + + /** + * Fill existing items with the default value of this field if it is set. + */ + private function applyDefaultValueToExistingItems(): void + { + /** @var DBmysql $DB */ + global $DB; + + if ($this->fields['type'] === 'header') { + return; + } + + if ($this->fields['multiple']) { + $decoded = json_decode((string) $this->fields['default_value'], true); + if (!is_array($decoded) || $decoded === []) { + return; + } + } elseif ((string) $this->fields['default_value'] === '') { + return; + } + + $value = self::getDefaultValue($this->fields); + if ($value === null) { + return; + } + + $sql_fields = PluginFieldsMigration::getSQLFields( + $this->fields['name'], + $this->fields['type'], + ['multiple' => (bool) $this->fields['multiple']], + ); + + if (count($sql_fields) !== 1) { + return; + } + + $column = array_key_first($sql_fields); + + $container = new PluginFieldsContainer(); + if (!$container->getFromDB($this->fields['plugin_fields_containers_id'])) { + return; + } + + foreach (PluginFieldsToolbox::decodeJSONItemtypes($container->fields['itemtypes']) as $itemtype) { + if (!class_exists($itemtype)) { + continue; + } + + $classname = PluginFieldsContainer::getClassname($itemtype, $container->fields['name']); + $table = $classname::getTable(); + + if (!$DB->tableExists($table)) { + continue; + } + + $DB->update($table, [$column => $value], [1]); + } } public function rawSearchOptions() diff --git a/tests/Units/ContainerTest.php b/tests/Units/ContainerTest.php index bd85899d..070d5c66 100644 --- a/tests/Units/ContainerTest.php +++ b/tests/Units/ContainerTest.php @@ -42,6 +42,7 @@ use PluginFieldsContainer; use PluginFieldsDropdown; use PluginFieldsField; +use Search; use Session; use Ticket; use UserEmail; @@ -282,4 +283,264 @@ public function testMailCollectorImportRespectsMandatoryFieldDefaultValue( : $container_ticket_fields_value[$row_key]; $this->assertEquals($expected_value, $stored_value); } + + private function getDefaultValueStored(Ticket $ticket, PluginFieldsContainer $container, string $field_name, bool $multiple = false): mixed + { + $classname = PluginFieldsContainer::getClassname(Ticket::class, $container->fields['name']); + $obj = getItemForItemtype($classname); + $obj->getFromDBByCrit([ + 'plugin_fields_containers_id' => $container->getID(), + 'items_id' => $ticket->getID(), + ]); + + $stored = $obj->fields[$field_name]; + + return $multiple ? json_decode((string) $stored, true) : $stored; + } + + public static function provideFieldTypesForDefaultValueBackfill(): iterable + { + yield 'text' => ['type' => 'text', 'created_default' => 'created default text', 'updated_default' => 'updated default text']; + yield 'textarea' => ['type' => 'textarea', 'created_default' => 'created default textarea', 'updated_default' => 'updated default textarea']; + yield 'richtext' => ['type' => 'richtext', 'created_default' => 'created default richtext', 'updated_default' => 'updated default richtext']; + yield 'url' => ['type' => 'url', 'created_default' => 'https://example.org/created', 'updated_default' => 'https://example.org/updated']; + yield 'number' => ['type' => 'number', 'created_default' => '42', 'updated_default' => '99']; + yield 'yesno' => ['type' => 'yesno', 'created_default' => '1', 'updated_default' => '0']; + yield 'date' => ['type' => 'date', 'created_default' => '2024-01-01', 'updated_default' => '2024-02-02']; + yield 'datetime' => ['type' => 'datetime', 'created_default' => '2024-01-01 10:00:00', 'updated_default' => '2024-02-02 11:00:00']; + yield 'dropdown itemtype computer' => ['type' => 'dropdown-Computer', 'created_default' => null, 'updated_default' => null, 'multiple' => false]; + yield 'dropdown itemtype computer multiple' => ['type' => 'dropdown-Computer', 'created_default' => null, 'updated_default' => null, 'multiple' => true]; + } + + #[DataProvider('provideFieldTypesForDefaultValueBackfill')] + public function testDefaultValueIsAppliedToExistingItemsOnlyAtFieldCreation( + string $type, + mixed $created_default, + mixed $updated_default, + bool $multiple = false, + ): void { + $this->login(); + + $container = $this->createFieldContainer([ + 'label' => 'Backfill Container', + 'type' => 'dom', + 'itemtypes' => [Ticket::class], + 'is_active' => 1, + 'entities_id' => 0, + 'is_recursive' => 1, + ]); + + // A first field so tickets already get a row in the container table. + $existing_field = $this->createField([ + 'label' => 'Existing Field', + 'type' => 'text', + PluginFieldsContainer::getForeignKeyField() => $container->getID(), + 'ranking' => 1, + 'is_active' => 1, + 'is_readonly' => 0, + ]); + $existing_field_name = $existing_field->fields['name']; + + // Tickets already existing before the new field is created. + $ticket1 = $this->createItem(Ticket::class, [ + 'name' => 'Ticket 1 ' . $this->getUniqueString(), + 'content' => 'Test', + 'entities_id' => 0, + $existing_field_name => 'value 1', + ], [$existing_field_name]); + + $ticket2 = $this->createItem(Ticket::class, [ + 'name' => 'Ticket 2 ' . $this->getUniqueString(), + 'content' => 'Test', + 'entities_id' => 0, + $existing_field_name => 'value 2', + ], [$existing_field_name]); + + if ($type === 'dropdown-Computer') { + [$computer1, $computer2] = $this->createItems(Computer::class, [ + ['name' => 'Computer 1', 'entities_id' => 0], + ['name' => 'Computer 2', 'entities_id' => 0], + ]); + + $created_default = $multiple ? [$computer1->getID()] : $computer1->getID(); + $updated_default = $multiple ? [$computer2->getID()] : $computer2->getID(); + } + + // Create a new field with a default value on the same container. + $new_field = $this->createField( + [ + 'label' => 'New Field', + 'type' => $type, + 'multiple' => $multiple ? 1 : 0, + PluginFieldsContainer::getForeignKeyField() => $container->getID(), + 'ranking' => 2, + 'is_active' => 1, + 'is_readonly' => 0, + 'default_value' => $created_default, + ], + $multiple ? ['default_value'] : [], + ); + $new_field_name = $new_field->fields['name']; + + $readValue = function (Ticket $ticket) use ($container, $new_field_name, $multiple): mixed { + $stored = $this->getDefaultValueStored($ticket, $container, $new_field_name); + + return $multiple ? json_decode((string) $stored, true) : $stored; + }; + + // Assert: the default value was applied to all objects that already existed. + $this->assertEquals($created_default, $readValue($ticket1)); + $this->assertEquals($created_default, $readValue($ticket2)); + + // Change the default value afterwards, through an update, not a creation. + $this->updateItem( + PluginFieldsField::class, + $new_field->getID(), + ['default_value' => $updated_default], + $multiple ? ['default_value'] : [], + ); + + // The update must not retroactively change existing objects values. + $this->assertEquals($created_default, $readValue($ticket1)); + $this->assertEquals($created_default, $readValue($ticket2)); + + // Sanity check: a ticket created after the update still gets the new default, + // proving the update did take effect, just not retroactively. + $ticket3 = $this->createItem(Ticket::class, [ + 'name' => 'Ticket 3 ' . $this->getUniqueString(), + 'content' => 'Test', + 'entities_id' => 0, + ]); + $this->assertEquals($updated_default, $readValue($ticket3)); + } + + public static function provideFieldTypesForSearchDefaultValue(): iterable + { + yield 'text' => ['type' => 'text', 'default_value' => 'search default text']; + yield 'textarea' => ['type' => 'textarea', 'default_value' => 'search default textarea']; + yield 'richtext' => ['type' => 'richtext', 'default_value' => 'search default richtext']; + yield 'url' => ['type' => 'url', 'default_value' => 'https://example.org/search-default']; + yield 'number' => ['type' => 'number', 'default_value' => '42']; + yield 'yesno' => ['type' => 'yesno', 'default_value' => '1']; + yield 'date' => ['type' => 'date', 'default_value' => '2024-01-01']; + yield 'date now' => ['type' => 'date', 'default_value' => 'now']; + yield 'datetime' => ['type' => 'datetime', 'default_value' => '2024-01-01 10:00:00']; + yield 'dropdown' => ['type' => 'dropdown', 'multiple' => false]; + yield 'dropdown multiple' => ['type' => 'dropdown', 'multiple' => true]; + yield 'dropdown itemtype computer' => ['type' => 'dropdown-Computer', 'multiple' => false]; + yield 'dropdown itemtype computer multiple' => ['type' => 'dropdown-Computer', 'multiple' => true]; + } + + #[DataProvider('provideFieldTypesForSearchDefaultValue')] + public function testSearchoptionsShowsDefaultValueFieldWithoutAnyRow( + string $type, + ?string $default_value = null, + bool $multiple = false, + ): void { + $this->login(); + $entities_id = $_SESSION['glpiactive_entity']; + + $container = $this->createFieldContainer([ + 'label' => 'Search Default Container', + 'type' => 'dom', + 'itemtypes' => [Ticket::class], + 'is_active' => 1, + 'entities_id' => $entities_id, + 'is_recursive' => 1, + ]); + + // Ticket created before the field exists + $ticket = $this->createItem(Ticket::class, [ + 'name' => 'Search default ticket ' . $this->getUniqueString(), + 'content' => 'Test', + 'entities_id' => $entities_id, + ]); + + $expected_displayname = null; + + if ($type === 'dropdown-Computer') { + [$option1, $option2] = $this->createItems(Computer::class, [ + ['name' => 'Search default option 1 ' . $this->getUniqueString(), 'entities_id' => $entities_id], + ['name' => 'Search default option 2 ' . $this->getUniqueString(), 'entities_id' => $entities_id], + ]); + + $default_value = $multiple ? [$option1->getID(), $option2->getID()] : (string) $option1->getID(); + $expected_displayname = $multiple + ? implode('
', [$option1->fields['name'], $option2->fields['name']]) + : $option1->fields['name']; + } + + $field_input = [ + 'label' => 'Search Default Field', + 'type' => $type, + 'multiple' => $multiple ? 1 : 0, + PluginFieldsContainer::getForeignKeyField() => $container->getID(), + 'ranking' => 1, + 'is_active' => 1, + 'is_readonly' => 0, + ]; + + // The default value for dropdown type fields can only be set after the field is created, + // because it requires the creation of dropdown items first. + if ($type !== 'dropdown') { + $field_input['default_value'] = $default_value; + } elseif ($multiple) { + $field_input['default_value'] = []; + } + + $field = $this->createField($field_input, $multiple ? ['default_value'] : []); + + if ($type === 'dropdown') { + $dropdown_classname = PluginFieldsDropdown::getClassname($field->fields['name']); + [$option1, $option2] = $this->createItems($dropdown_classname, [ + ['name' => 'Search default option 1 ' . $this->getUniqueString()], + ['name' => 'Search default option 2 ' . $this->getUniqueString()], + ]); + + $default_value = $multiple ? [$option1->getID(), $option2->getID()] : (string) $option1->getID(); + $expected_displayname = $multiple + ? implode('
', [$option1->fields['name'], $option2->fields['name']]) + : $option1->fields['name']; + + $this->updateItem( + PluginFieldsField::class, + $field->getID(), + ['default_value' => $default_value], + $multiple ? ['default_value'] : [], + ); + } + + $searchopt = Search::getOptions(Ticket::class); + $so_id = PluginFieldsField::SEARCH_OPTION_STARTING_INDEX + $field->getID(); + $this->assertArrayHasKey($so_id, $searchopt); + + $data = Search::getDatas( + Ticket::class, + [ + 'is_deleted' => 0, + 'start' => 0, + 'criteria' => [ + ['field' => 'view', 'searchtype' => 'contains', 'value' => $ticket->fields['name']], + ], + ], + [$so_id], + ); + + $this->assertSame(1, $data['data']['totalcount']); + $row = current($data['data']['rows']); + + if ($type === 'dropdown-Computer' || $type === 'dropdown') { + $this->assertTrue(isset($row['Ticket_' . $so_id]['displayname'])); + $this->assertSame($expected_displayname, $row['Ticket_' . $so_id]['displayname']); + } elseif ($default_value === 'now') { + // 'now' is resolved to the current server time at query time, + // not stored as the literal string 'now'. + $this->assertMatchesRegularExpression( + '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', + (string) $row['raw']['ITEM_Ticket_' . $so_id], + ); + } else { + $this->assertSame($default_value, $row['raw']['ITEM_Ticket_' . $so_id]); + } + } }