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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Fixed

- Fix mandatory fields of a block hidden by its display conditions blocking the save of an item
- Fix rendering error `unknown named parameters` for duplicated form
- Fix fields values updates not applying the expected checks in some cases

## [1.24.6] - 2026-10-06
Expand Down
1 change: 1 addition & 0 deletions hook.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ function plugin_fields_install()
PluginFieldsField::class,
PluginFieldsLabelTranslation::class,
PluginFieldsProfile::class,
PluginFieldsQuestionType::class,
PluginFieldsStatusOverride::class,
];

Expand Down
2 changes: 1 addition & 1 deletion inc/field.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -1357,7 +1357,7 @@ public static function prepareHtmlFields(
$value = array_values(array_filter(
array_merge(...array_map(
static fn($v) => is_array($v) ? array_values($v) : [$v],
$value,
array_values($value),
)),
is_scalar(...),
));
Expand Down
96 changes: 90 additions & 6 deletions inc/questiontype.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
use Glpi\Form\Question;
use Glpi\Form\QuestionType\AbstractQuestionType;
use Glpi\Form\QuestionType\QuestionTypeCategoryInterface;
use Safe\Exceptions\JsonException;

use function Safe\json_decode;
use function Safe\json_encode;
Expand Down Expand Up @@ -85,6 +86,83 @@ public function formatDefaultValueForDB(mixed $value): string
return json_encode($value);
}

/**
* Decode the stored default value of a cloned question, as it is passed
* again to `formatDefaultValueForDB()` when the clone is added.
*/
public static function preQuestionAdd(Question $question): void
{
if (
!($question->input['clone'] ?? false)
|| ($question->input['type'] ?? null) !== self::class
|| !is_string($question->input['default_value'] ?? null)
) {
return;
}

try {
$question->input['default_value'] = json_decode($question->input['default_value'], true);
} catch (JsonException) {
// Not a JSON value, keep it as is
}
}

/**
* Repair default values that were JSON encoded several times by previous form clones.
*/
public static function installBaseData(Migration $migration, $version): void
{
/** @var DBmysql $DB */
global $DB;

$question_table = Question::getTable();
if (!$DB->tableExists($question_table)) {
return;
}

$migration->displayMessage(__('Repair default values of cloned form questions', 'fields'));

$questions = $DB->request([
'SELECT' => ['id', 'default_value', 'extra_data'],
'FROM' => $question_table,
'WHERE' => ['type' => self::class],
]);
foreach ($questions as $question) {
if (!is_string($question['default_value']) || !is_string($question['extra_data'])) {
continue;
}

$extra_data = \json_decode($question['extra_data'], true);
$field = new PluginFieldsField();
if (!is_array($extra_data) || !$field->getFromDB((int) ($extra_data['field_id'] ?? 0))) {
continue;
}

// Dropdowns and GLPI items store an array, other fields store a scalar value
$is_array_value = $field->fields['type'] === 'glpi_item'
|| str_starts_with((string) $field->fields['type'], 'dropdown');

$default_value = $question['default_value'];
while (is_string($decoded = \json_decode($default_value, true))) {
// Unwrap while the value is still a JSON encoded string (or array for array values)
$inner = \json_decode($decoded, true);
if (!is_string($inner) && (!$is_array_value || !is_array($inner))) {
break;
}

$default_value = $decoded;
}

if ($default_value !== $question['default_value']) {
$DB->update(
$question_table,
['default_value' => $default_value],
['id' => $question['id']],
);
}
}
}

#[Override]
public function validateExtraDataInput(array $input): bool
{
Expand Down Expand Up @@ -130,8 +208,8 @@ public function renderAdministrationTemplate(?Question $question): string

// Compute default value for the field
$default_value = null;
if ($question instanceof Question && !empty($question->fields['default_value'])) {
$default_value = json_decode($question->fields['default_value'], true);
if ($question instanceof Question) {
$default_value = $this->decodeDefaultValue($question);
}

$twig = TemplateRenderer::getInstance();
Expand All @@ -145,6 +223,15 @@ public function renderAdministrationTemplate(?Question $question): string
]);
}

private function decodeDefaultValue(Question $question): mixed
{
if (empty($question->fields['default_value'])) {
return null;
}

return json_decode($question->fields['default_value'], true);
}

#[Override]
public function renderEndUserTemplate(Question $question): string
{
Expand All @@ -165,10 +252,7 @@ public function renderEndUserTemplate(Question $question): string
$current_field = PluginFieldsField::getById($current_field_id);

// Compute default value for the field
$default_value = null;
if (!empty($question->fields['default_value'])) {
$default_value = json_decode($question->fields['default_value'], true);
}
$default_value = $this->decodeDefaultValue($question);

$itemtype = null;
if (str_starts_with((string) $current_field->fields['type'], 'dropdown')) {
Expand Down
2 changes: 2 additions & 0 deletions setup.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
use Glpi\Form\Destination\FormDestinationProblem;
use Glpi\Form\Destination\FormDestinationTicket;
use Glpi\Form\Migration\TypesConversionMapper;
use Glpi\Form\Question;
use Glpi\Form\QuestionType\QuestionTypesManager;
use Glpi\Plugin\Hooks;
use Symfony\Component\Yaml\Yaml;
Expand Down Expand Up @@ -134,6 +135,7 @@ function plugin_init_fields()

// Register fields question type
plugin_fields_register_plugin_types();
$PLUGIN_HOOKS['pre_item_add']['fields'][Question::class] = PluginFieldsQuestionType::preQuestionAdd(...);

if (Session::getLoginUserID() || isCommandLine()) {
// Init hook about itemtype(s) for plugin fields
Expand Down
134 changes: 134 additions & 0 deletions tests/Units/FieldQuestionTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

namespace GlpiPlugin\Field\Tests\Units;

use DBmysql;
use Glpi\Form\Condition\ConditionHandler\ItemAsTextConditionHandler;
use Glpi\Form\Condition\ConditionHandler\ItemConditionHandler;
use Glpi\Form\Condition\Engine;
Expand All @@ -38,20 +39,24 @@
use Glpi\Form\Condition\Type;
use Glpi\Form\Condition\ValueOperator;
use Glpi\Form\Condition\VisibilityStrategy;
use Glpi\Form\Form;
use Glpi\Form\Question;
use Glpi\Form\QuestionType\QuestionTypeShortText;
use Glpi\Form\QuestionType\QuestionTypesManager;
use Glpi\Tests\FormBuilder;
use GlpiPlugin\Field\Tests\QuestionTypeTestCase;
use Location;
use LogicException;
use Migration;
use PHPUnit\Framework\Attributes\DataProvider;
use PluginFieldsContainer;
use PluginFieldsDropdown;
use PluginFieldsField;
use PluginFieldsQuestionType;
use PluginFieldsQuestionTypeCategory;
use PluginFieldsQuestionTypeExtraDataConfig;

use function Safe\json_decode;
use function Safe\json_encode;

final class FieldQuestionTypeTest extends QuestionTypeTestCase
Expand Down Expand Up @@ -242,6 +247,135 @@ public function testFormatRawAnswerForMultipleGlpiItemDropdown(): void
}
}

public static function cloneDefaultValueProvider(): iterable
{
yield 'text' => ['text', 'hello'];
yield 'textarea' => ['textarea', "first line\nsecond line"];
yield 'number' => ['number', '42'];
yield 'url' => ['url', 'https://glpi-project.org'];
yield 'date' => ['date', '2026-10-15'];
yield 'json-like text' => ['text', '[1,2]'];
}

#[DataProvider('cloneDefaultValueProvider')]
public function testCloneKeepsScalarDefaultValue(string $type, string $default_value): void
{
$this->login();

// Arrange: create a form with a question using a scalar field
$this->fields['scalar'] = $this->createField([
'label' => 'Scalar field',
'type' => $type,
PluginFieldsContainer::getForeignKeyField() => $this->block->getID(),
'ranking' => 2,
'is_active' => 1,
]);
$builder = new FormBuilder("My form");
$builder->addQuestion(
"Scalar question",
PluginFieldsQuestionType::class,
default_value: $default_value,
extra_data: json_encode($this->getFieldExtraDataConfig('scalar')),
);
$form = $this->createForm($builder);

// Act: clone the form, then clone the clone
$clone = Form::getById($form->clone());
$clone_of_clone = Form::getById($clone->clone());

// Assert: the default value is encoded only once in each form
foreach ([$form, $clone, $clone_of_clone] as $current_form) {
$question = Question::getById($this->getQuestionId($current_form, "Scalar question"));
$this->assertSame(json_encode($default_value), $question->fields['default_value']);
}
}

public function testCloneKeepsDropdownDefaultValue(): void
{
$this->login();

// Arrange: create a form with a dropdown question having a default value
$itemtype = PluginFieldsDropdown::getClassname($this->fields['dropdown']->fields['name']);
$option = $this->createItem($itemtype, ['name' => 'Default option']);
$default_value = ['itemtype' => $itemtype, 'items_id' => $option->getID()];

$builder = new FormBuilder("My form");
$builder->addQuestion(
"Dropdown question",
PluginFieldsQuestionType::class,
default_value: $default_value,
extra_data: json_encode($this->getFieldExtraDataConfig('dropdown')),
);
$form = $this->createForm($builder);

// Act: clone the form, then clone the clone
$clone = Form::getById($form->clone());
$clone_of_clone = Form::getById($clone->clone());

// Assert: the default value is kept as an array in each form
foreach ([$form, $clone, $clone_of_clone] as $current_form) {
$question = Question::getById($this->getQuestionId($current_form, "Dropdown question"));
$this->assertSame($default_value, json_decode($question->fields['default_value'], true));
}
}

public function testInstallRepairsDefaultValuesEncodedByClones(): void
{
/** @var DBmysql $DB */
global $DB;

$this->login();

// Arrange: create questions whose default values were encoded by successive clones
$this->fields['text'] = $this->createField([
'label' => 'Text field',
'type' => 'text',
PluginFieldsContainer::getForeignKeyField() => $this->block->getID(),
'ranking' => 2,
'is_active' => 1,
]);
$itemtype = PluginFieldsDropdown::getClassname($this->fields['dropdown']->fields['name']);
$dropdown_value = ['itemtype' => $itemtype, 'items_id' => 1];

$cases = [
// question name => [field, stored default value, expected default value]
'Text cloned twice' => ['text', json_encode(json_encode(json_encode('hello'))), json_encode('hello')],
'Text not cloned' => ['text', json_encode('hello'), json_encode('hello')],
'JSON-like text' => ['text', json_encode('[1,2]'), json_encode('[1,2]')],
'Dropdown cloned twice' => ['dropdown', json_encode(json_encode(json_encode($dropdown_value))), json_encode($dropdown_value)],
'Dropdown not cloned' => ['dropdown', json_encode($dropdown_value), json_encode($dropdown_value)],
];

$builder = new FormBuilder("My form");
foreach ($cases as $name => [$field_name]) {
$builder->addQuestion(
$name,
PluginFieldsQuestionType::class,
extra_data: json_encode($this->getFieldExtraDataConfig($field_name)),
);
}

$form = $this->createForm($builder);
foreach ($cases as $name => [, $stored_value]) {
$DB->update(
Question::getTable(),
['default_value' => $stored_value],
['id' => $this->getQuestionId($form, $name)],
);
}

// Act: run the plugin migration
ob_start();
PluginFieldsQuestionType::installBaseData(new Migration(PLUGIN_FIELDS_VERSION), PLUGIN_FIELDS_VERSION);
ob_end_clean();

// Assert: default values are encoded only once
foreach ($cases as $name => [, , $expected_value]) {
$question = Question::getById($this->getQuestionId($form, $name));
$this->assertSame($expected_value, $question->fields['default_value'], $name);
}
}

public function testFieldDeletionWhenUsedInForm(): void
{
$this->login();
Expand Down
Loading