diff --git a/phpunit/functional/Glpi/Form/Export/FormSerializerTest.php b/phpunit/functional/Glpi/Form/Export/FormSerializerTest.php index e4552da8be7..c908b3dba13 100644 --- a/phpunit/functional/Glpi/Form/Export/FormSerializerTest.php +++ b/phpunit/functional/Glpi/Form/Export/FormSerializerTest.php @@ -41,9 +41,20 @@ use Glpi\Form\Export\Result\ImportError; use Glpi\Form\Export\Serializer\FormSerializer; use Glpi\Form\Form; +use Glpi\Form\Question; +use Glpi\Form\QuestionType\QuestionTypeActorsExtraDataConfig; +use Glpi\Form\QuestionType\QuestionTypeActorsDefaultValueConfig; +use Glpi\Form\QuestionType\QuestionTypeDropdown; +use Glpi\Form\QuestionType\QuestionTypeDropdownExtraDataConfig; +use Glpi\Form\QuestionType\QuestionTypeItemDefaultValueConfig; +use Glpi\Form\QuestionType\QuestionTypeItemExtraDataConfig; +use Glpi\Form\QuestionType\QuestionTypeItemDropdown; +use Glpi\Form\QuestionType\QuestionTypeRequester; +use Glpi\Form\QuestionType\QuestionTypeShortText; use Glpi\Form\Section; use Glpi\Tests\FormBuilder; use Glpi\Tests\FormTesterTrait; +use Location; use Session; final class FormSerializerTest extends \DbTestCase @@ -327,6 +338,129 @@ public function testExportAndImportComments(): void ], $comments_data); } + public function testExportAndImportQuestions(): void + { + $this->login(); + + $user = $this->createItem('User', ['name' => 'John Doe']); + $location = $this->createItem( + Location::class, + [ + 'name' => 'My location', + 'entities_id' => $this->getTestRootEntity(only_id: true) + ] + ); + + // Arrange: create a form with multiple sections and questions + $dropdown_config = new QuestionTypeDropdownExtraDataConfig([ + '123456789' => 'Option 1', + '987654321' => 'Option 2', + true, + ]); + $item_default_value_config = new QuestionTypeItemDefaultValueConfig($location->getID()); + $item_extra_data_config = new QuestionTypeItemExtraDataConfig(Location::class); + $actors_default_value_config = new QuestionTypeActorsDefaultValueConfig( + users_ids: [$user->getID()], + ); + $actors_extra_data_config = new QuestionTypeActorsExtraDataConfig( + is_multiple_actors: true, + ); + + $builder = new FormBuilder(); + $builder->addSection("My first section") + ->addQuestion( + "My text question", + QuestionTypeShortText::class, + 'Test default value', + '', + 'My text question description' + ) + ->addQuestion( + "My dropdown question", + QuestionTypeDropdown::class, + '123456789', + json_encode($dropdown_config->jsonSerialize()), + 'My dropdown question description' + ) + ->addSection("My second section") + ->addQuestion( + "My item dropdown question", + QuestionTypeItemDropdown::class, + $location->getID(), + json_encode($item_extra_data_config->jsonSerialize()), + 'My item dropdown question description', + true + ) + ->addQuestion( + "My requester question", + QuestionTypeRequester::class, + ['users_id-' . $user->getID()], + json_encode($actors_extra_data_config->jsonSerialize()), + ); + $form = $this->createForm($builder); + + // Act: export and import the form + $form_copy = $this->exportAndImportForm($form); + + // Assert: validate questions fields + $questions = array_values($form_copy->getQuestions()); + $questions_data = array_map(function (Question $question) { + return [ + 'name' => $question->fields['name'], + 'type' => $question->fields['type'], + 'is_mandatory' => $question->fields['is_mandatory'], + 'rank' => $question->fields['rank'], + 'description' => $question->fields['description'], + 'default_value' => $question->fields['default_value'], + 'extra_data' => $question->fields['extra_data'], + 'forms_sections_id' => $question->fields['forms_sections_id'], + ]; + }, $questions); + + $this->assertEquals([ + [ + 'name' => 'My text question', + 'type' => QuestionTypeShortText::class, + 'is_mandatory' => (int) false, + 'rank' => 0, + 'description' => 'My text question description', + 'default_value' => 'Test default value', + 'extra_data' => "", + 'forms_sections_id' => array_values($form_copy->getSections())[0]->fields['id'], + ], + [ + 'name' => 'My dropdown question', + 'type' => QuestionTypeDropdown::class, + 'is_mandatory' => (int) false, + 'rank' => 1, + 'description' => 'My dropdown question description', + 'default_value' => '123456789', + 'extra_data' => json_encode($dropdown_config->jsonSerialize()), + 'forms_sections_id' => array_values($form_copy->getSections())[0]->fields['id'], + ], + [ + 'name' => 'My item dropdown question', + 'type' => QuestionTypeItemDropdown::class, + 'is_mandatory' => (int) true, + 'rank' => 0, + 'description' => 'My item dropdown question description', + 'default_value' => json_encode($item_default_value_config->jsonSerialize()), + 'extra_data' => json_encode($item_extra_data_config->jsonSerialize()), + 'forms_sections_id' => array_values($form_copy->getSections())[1]->fields['id'], + ], + [ + 'name' => 'My requester question', + 'type' => QuestionTypeRequester::class, + 'is_mandatory' => (int) false, + 'rank' => 1, + 'description' => '', + 'default_value' => json_encode($actors_default_value_config->jsonSerialize()), + 'extra_data' => json_encode($actors_extra_data_config->jsonSerialize()), + 'forms_sections_id' => array_values($form_copy->getSections())[1]->fields['id'], + ] + ], $questions_data); + } + public function testPreviewImportWithValidForm(): void { // Arrange: create a valid form diff --git a/src/Glpi/Form/AccessControl/ControlType/AllowListConfig.php b/src/Glpi/Form/AccessControl/ControlType/AllowListConfig.php index 09395203c70..c42b5fc3302 100644 --- a/src/Glpi/Form/AccessControl/ControlType/AllowListConfig.php +++ b/src/Glpi/Form/AccessControl/ControlType/AllowListConfig.php @@ -39,6 +39,7 @@ use Glpi\DBAL\JsonFieldInterface; use Glpi\Form\Export\Context\ForeignKey\ForeignKeyArrayHandler; use Glpi\Form\Export\Context\ConfigWithForeignKeysInterface; +use Glpi\Form\Export\Specification\ContentSpecificationInterface; use Group; use Override; use Profile; @@ -61,7 +62,7 @@ public function __construct( } #[Override] - public static function listForeignKeysHandlers(): array + public static function listForeignKeysHandlers(ContentSpecificationInterface $content_spec): array { return [ new ForeignKeyArrayHandler( diff --git a/src/Glpi/Form/Export/Context/ConfigWithForeignKeysInterface.php b/src/Glpi/Form/Export/Context/ConfigWithForeignKeysInterface.php index 4fdda2fef77..45379964086 100644 --- a/src/Glpi/Form/Export/Context/ConfigWithForeignKeysInterface.php +++ b/src/Glpi/Form/Export/Context/ConfigWithForeignKeysInterface.php @@ -34,6 +34,8 @@ namespace Glpi\Form\Export\Context; +use Glpi\Form\Export\Specification\ContentSpecificationInterface; + /** * Must be implemented by all JsonFieldInterface objects that contains references * foreign keys. @@ -48,7 +50,8 @@ interface ConfigWithForeignKeysInterface * Must return one JsonConfigForeignKeyHandlerInterface per serialized key that * will contains foreign keys data. * + * @param \Glpi\Form\Export\Specification\ContentSpecificationInterface $content_spec * @return \Glpi\Form\Export\Context\ForeignKey\JsonConfigForeignKeyHandlerInterface[] */ - public static function listForeignKeysHandlers(): array; + public static function listForeignKeysHandlers(ContentSpecificationInterface $content_spec): array; } diff --git a/src/Glpi/Form/Export/Context/ForeignKey/ForeignKeyHandler.php b/src/Glpi/Form/Export/Context/ForeignKey/ForeignKeyHandler.php new file mode 100644 index 00000000000..9fb71b76f58 --- /dev/null +++ b/src/Glpi/Form/Export/Context/ForeignKey/ForeignKeyHandler.php @@ -0,0 +1,112 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Form\Export\Context\ForeignKey; + +use Glpi\Form\Export\Context\DatabaseMapper; +use Glpi\Form\Export\Specification\DataRequirementSpecification; + +/** + * Handle a foreign keys. + */ +final class ForeignKeyHandler implements JsonConfigForeignKeyHandlerInterface +{ + /** @param class-string<\CommonDBTM> $itemtype */ + public function __construct( + private string $key, + private string $itemtype, + ) { + } + + public function getDataRequirements(array $serialized_data): array + { + if (!$this->keyExistInSerializedData($serialized_data)) { + return []; + } + + $requirements = []; + $foreign_key = $serialized_data[$this->key]; + + // Create a data requirement for the foreign key and load item + $item = new $this->itemtype(); + if ($item->getFromDB($foreign_key)) { + $requirements[] = new DataRequirementSpecification( + $this->itemtype, + $item->getName(), + ); + } + + return $requirements; + } + + public function replaceForeignKeysByNames(array $serialized_data): array + { + if (!$this->keyExistInSerializedData($serialized_data)) { + return []; + } + + $foreign_key = $serialized_data[$this->key]; + + // Replace the foreign key by the name of the item it references and load item + $item = new $this->itemtype(); + if ($item->getFromDB($foreign_key)) { + $serialized_data[$this->key] = $item->getName(); + } + + return $serialized_data; + } + + public function replaceNamesByForeignKeys( + array $serialized_data, + DatabaseMapper $mapper, + ): array { + if (!$this->keyExistInSerializedData($serialized_data)) { + return []; + } + + // Replace name by its database id + $serialized_data[$this->key] = $mapper->getItemId( + $this->itemtype, + $serialized_data[$this->key] + ); + + return $serialized_data; + } + + private function keyExistInSerializedData(array $serialized_data): bool + { + return isset($serialized_data[$this->key]); + } +} diff --git a/src/Glpi/Form/Export/Serializer/FormSerializer.php b/src/Glpi/Form/Export/Serializer/FormSerializer.php index 9ef38a62261..ffebfd29ed5 100644 --- a/src/Glpi/Form/Export/Serializer/FormSerializer.php +++ b/src/Glpi/Form/Export/Serializer/FormSerializer.php @@ -36,6 +36,7 @@ namespace Glpi\Form\Export\Serializer; use Entity; +use Glpi\DBAL\JsonFieldInterface; use Glpi\Form\AccessControl\FormAccessControl; use Glpi\Form\Comment; use Glpi\Form\Export\Context\DatabaseMapper; @@ -49,8 +50,10 @@ use Glpi\Form\Export\Specification\CommentContentSpecification; use Glpi\Form\Export\Specification\ExportContentSpecification; use Glpi\Form\Export\Specification\FormContentSpecification; +use Glpi\Form\Export\Specification\QuestionContentSpecification; use Glpi\Form\Export\Specification\SectionContentSpecification; use Glpi\Form\Form; +use Glpi\Form\Question; use Glpi\Form\Section; use InvalidArgumentException; use RuntimeException; @@ -208,6 +211,7 @@ private function exportFormToSpec(Form $form, int $form_export_id): FormContentS $form_spec = $this->exportBasicFormProperties($form, $form_export_id); $form_spec = $this->exportSections($form, $form_spec); $form_spec = $this->exportComments($form, $form_spec); + $form_spec = $this->exportQuestions($form, $form_spec); $form_spec = $this->exportAccesControlPolicies($form, $form_spec); return $form_spec; @@ -241,6 +245,7 @@ private function doImportFormFormSpecs( $form = $this->importBasicFormProperties($form_spec, $mapper); $form = $this->importSections($form, $form_spec); $form = $this->importComments($form, $form_spec); + $form = $this->importQuestions($form, $form_spec, $mapper); $form = $this->importAccessControlPolicices($form, $form_spec, $mapper); return $form; @@ -410,6 +415,109 @@ private function importComments( return $form; } + private function exportQuestions( + Form $form, + FormContentSpecification $form_spec, + ): FormContentSpecification { + foreach ($form->getQuestions() as $question) { + $question_spec = new QuestionContentSpecification(); + $question_spec->name = $question->fields['name']; + $question_spec->type = $question->fields['type']; + $question_spec->is_mandatory = $question->fields['is_mandatory']; + $question_spec->rank = $question->fields['rank']; + $question_spec->description = $question->fields['description']; + $question_spec->default_value = $question->fields['default_value']; + $question_spec->extra_data = $question->fields['extra_data']; + $question_spec->section_rank = $form->getSections()[$question->fields['forms_sections_id']]->fields['rank']; + + $question_type = new $question_spec->type(); + if ($question_type->getDefaultValueConfigClass() !== null) { + $default_value_config = $question_type->getDefaultValueConfig( + json_decode($question_spec->default_value ?? "[]", true) + ); + if ($default_value_config !== null) { + $serialized_default_value = $default_value_config->jsonSerialize(); + if ( + $default_value_config instanceof ConfigWithForeignKeysInterface + ) { + $requirements = $this->extractDataRequirementsFromSerializedJsonConfig( + $default_value_config::listForeignKeysHandlers($question_spec), + $serialized_default_value + ); + array_push($form_spec->data_requirements, ...$requirements); + + $question_spec->default_value = json_encode( + $this->replaceForeignKeysByNameInSerializedJsonConfig( + $default_value_config::listForeignKeysHandlers($question_spec), + $serialized_default_value + ) + ); + } + } + } + + $form_spec->questions[] = $question_spec; + } + + return $form_spec; + } + + private function importQuestions( + Form $form, + FormContentSpecification $form_spec, + DatabaseMapper $mapper, + ): Form { + /** @var QuestionContentSpecification $question_spec */ + foreach ($form_spec->questions as $question_spec) { + // Retrieve section from their rank + $section = current(array_filter( + $form->getSections(), + fn (Section $section) => $section->fields['rank'] === $question_spec->section_rank + )); + + $question_type = new $question_spec->type(); + if ($question_type->getDefaultValueConfigClass() !== null) { + $default_value_config = $question_type->getDefaultValueConfig( + json_decode($question_spec->default_value ?? "[]", true) + ); + if ($default_value_config !== null) { + $serialized_default_value = json_decode($question_spec->default_value, true); + if ( + $default_value_config instanceof ConfigWithForeignKeysInterface + ) { + $serialized_default_value = $this->replaceNamesByForeignKeysInSerializedJsonConfig( + $default_value_config::listForeignKeysHandlers($question_spec), + $serialized_default_value, + $mapper + ); + } + $question_spec->default_value = json_encode($serialized_default_value); + } + } + + $question = new Question(); + $id = $question->add([ + '_from_import' => true, + 'name' => $question_spec->name, + 'type' => $question_spec->type, + 'is_mandatory' => $question_spec->is_mandatory, + 'rank' => $question_spec->rank, + 'description' => $question_spec->description, + 'default_value' => $question_spec->default_value, + 'extra_data' => $question_spec->extra_data, + 'forms_sections_id' => $section->fields['id'], + ]); + + if (!$id) { + throw new RuntimeException("Failed to create question"); + } + } + + // Reload form to clear lazy loaded data + $form->getFromDB($form->getID()); + return $form; + } + private function exportAccesControlPolicies( Form $form, FormContentSpecification $form_spec, @@ -428,13 +536,13 @@ private function exportAccesControlPolicies( $serialized_config = $config->jsonSerialize(); if ($config instanceof ConfigWithForeignKeysInterface) { $requirements = $this->extractDataRequirementsFromSerializedJsonConfig( - $config::listForeignKeysHandlers(), + $config::listForeignKeysHandlers($policy_spec), $serialized_config ); array_push($form_spec->data_requirements, ...$requirements); $serialized_config = $this->replaceForeignKeysByNameInSerializedJsonConfig( - $config::listForeignKeysHandlers(), + $config::listForeignKeysHandlers($policy_spec), $serialized_config, ); } @@ -466,7 +574,7 @@ private function importAccessControlPolicices( $serialized_config = $policy_spec->config_data; if (is_a($config_class, ConfigWithForeignKeysInterface::class, true)) { $serialized_config = $this->replaceNamesByForeignKeysInSerializedJsonConfig( - $config_class::listForeignKeysHandlers(), + $config_class::listForeignKeysHandlers($policy_spec), $serialized_config, $mapper ); diff --git a/src/Glpi/Form/Export/Specification/AccesControlPolicyContentSpecification.php b/src/Glpi/Form/Export/Specification/AccesControlPolicyContentSpecification.php index 3dc3c85ab9b..b299dadd730 100644 --- a/src/Glpi/Form/Export/Specification/AccesControlPolicyContentSpecification.php +++ b/src/Glpi/Form/Export/Specification/AccesControlPolicyContentSpecification.php @@ -35,7 +35,7 @@ namespace Glpi\Form\Export\Specification; -final class AccesControlPolicyContentSpecification +final class AccesControlPolicyContentSpecification implements ContentSpecificationInterface { public string $strategy; public array $config_data; diff --git a/src/Glpi/Form/Export/Specification/ContentSpecificationInterface.php b/src/Glpi/Form/Export/Specification/ContentSpecificationInterface.php new file mode 100644 index 00000000000..a9d59419f4b --- /dev/null +++ b/src/Glpi/Form/Export/Specification/ContentSpecificationInterface.php @@ -0,0 +1,40 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Form\Export\Specification; + +interface ContentSpecificationInterface +{ +} diff --git a/src/Glpi/Form/Export/Specification/FormContentSpecification.php b/src/Glpi/Form/Export/Specification/FormContentSpecification.php index 2f1fe107115..740d30f8083 100644 --- a/src/Glpi/Form/Export/Specification/FormContentSpecification.php +++ b/src/Glpi/Form/Export/Specification/FormContentSpecification.php @@ -49,6 +49,9 @@ final class FormContentSpecification /** @var CommentContentSpecification[] $comments */ public array $comments = []; + /** @var QuestionContentSpecification[] $questions */ + public array $questions = []; + /** @var AccesControlPolicyContentSpecification[] $policies */ public array $policies = []; diff --git a/src/Glpi/Form/Export/Specification/QuestionContentSpecification.php b/src/Glpi/Form/Export/Specification/QuestionContentSpecification.php new file mode 100644 index 00000000000..22d87c2b9d4 --- /dev/null +++ b/src/Glpi/Form/Export/Specification/QuestionContentSpecification.php @@ -0,0 +1,48 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Form\Export\Specification; + +final class QuestionContentSpecification implements ContentSpecificationInterface +{ + public string $name; + public string $type; + public bool $is_mandatory; + public int $rank; + public ?string $description; + public ?string $default_value; + public ?string $extra_data; + public int $section_rank; +} diff --git a/src/Glpi/Form/Question.php b/src/Glpi/Form/Question.php index 6de35d8bc08..866141eda53 100644 --- a/src/Glpi/Form/Question.php +++ b/src/Glpi/Form/Question.php @@ -143,6 +143,12 @@ public function prepareInputForUpdate($input) private function prepareInput(&$input) { + // If the question is being imported, we don't need to format the input + // because it is already formatted. So we skip this step. + if ($input['_from_import'] ?? false) { + return; + } + $question_type = $this->getQuestionType(); // The question type can be null when the question is created diff --git a/src/Glpi/Form/QuestionType/AbstractQuestionType.php b/src/Glpi/Form/QuestionType/AbstractQuestionType.php index b27a5f3730c..61f8d34a7f8 100644 --- a/src/Glpi/Form/QuestionType/AbstractQuestionType.php +++ b/src/Glpi/Form/QuestionType/AbstractQuestionType.php @@ -137,24 +137,36 @@ public function isAllowedForUnauthenticatedAccess(): bool } #[Override] - public function getConfigClass(): ?string + public function getExtraDataConfigClass(): ?string { return null; } #[Override] - public function getConfig(?Question $question): ?JsonFieldInterface + public function getExtraDataConfig(array $serialized_data): ?JsonFieldInterface { - $config_class = $this->getConfigClass(); - if ($config_class === null || $question === null) { + $config_class = $this->getExtraDataConfigClass(); + if ($config_class === null || empty($serialized_data)) { return null; } - $extra_data = $question->fields['extra_data']; - if (empty($extra_data)) { + return $config_class::jsonDeserialize($serialized_data); + } + + #[Override] + public function getDefaultValueConfigClass(): ?string + { + return null; + } + + #[Override] + public function getDefaultValueConfig(array $serialized_data): ?JsonFieldInterface + { + $config_class = $this->getDefaultValueConfigClass(); + if ($config_class === null || empty($serialized_data)) { return null; } - return $config_class::jsonDeserialize(json_decode($extra_data, true)); + return $config_class::jsonDeserialize($serialized_data); } } diff --git a/src/Glpi/Form/QuestionType/AbstractQuestionTypeActors.php b/src/Glpi/Form/QuestionType/AbstractQuestionTypeActors.php index 0030d9881c8..43f4dd91910 100644 --- a/src/Glpi/Form/QuestionType/AbstractQuestionTypeActors.php +++ b/src/Glpi/Form/QuestionType/AbstractQuestionTypeActors.php @@ -37,7 +37,10 @@ use Glpi\Application\View\TemplateRenderer; use Glpi\Form\Question; +use Group; use Override; +use Supplier; +use User; /** * "Actors" questions represent an input field for actors (requesters, ...) @@ -54,11 +57,28 @@ abstract public function getAllowedActorTypes(): array; #[Override] public function formatDefaultValueForDB(mixed $value): ?string { - if (is_array($value)) { - return implode(',', $value); + if (empty($value)) { + return null; } - return $value; + if (!is_array($value)) { + $value = [$value]; + } + + $actors_ids = []; + foreach ($value as $actor) { + $actor_parts = explode('-', $actor); + $actors_ids[getItemtypeForForeignKeyField($actor_parts[0])][] = (int) $actor_parts[1]; + } + + // Wrap the array in a config object to serialize it + $config = new QuestionTypeActorsDefaultValueConfig( + users_ids: $actors_ids['User'] ?? [], + groups_ids: $actors_ids['Group'] ?? [], + suppliers_ids: $actors_ids['Supplier'] ?? [] + ); + + return json_encode($config->jsonSerialize()); } #[Override] @@ -109,8 +129,12 @@ public function prepareEndUserAnswer(Question $question, mixed $answer): mixed */ public function isMultipleActors(?Question $question): bool { - /** @var ?QuestionTypeActorsConfig $config */ - $config = $this->getConfig($question); + if ($question === null) { + return false; + } + + /** @var ?QuestionTypeActorsExtraDataConfig $config */ + $config = $this->getExtraDataConfig(json_decode($question->fields['extra_data'], true) ?? []); if ($config === null) { return false; } @@ -135,12 +159,14 @@ public function getDefaultValue(?Question $question, bool $multiple = false): ar return []; } - $default_values = []; - $raw_default_values = explode(',', $question->fields['default_value']); - foreach ($raw_default_values as $raw_default_value) { - $entry = explode('-', $raw_default_value); - $default_values[$entry[0]][] = $entry[1]; - } + $config = new QuestionTypeActorsDefaultValueConfig(); + $config = $config->jsonDeserialize(json_decode($question->fields['default_value'], true)); + + $default_values = [ + getForeignKeyFieldForItemType(User::class) => $config->getUsersIds(), + getForeignKeyFieldForItemType(Group::class) => $config->getGroupsIds(), + getForeignKeyFieldForItemType(Supplier::class) => $config->getSuppliersIds() + ]; if ($multiple) { return $default_values; @@ -356,8 +382,13 @@ public function isAllowedForUnauthenticatedAccess(): bool } #[Override] - public function getConfigClass(): ?string + public function getExtraDataConfigClass(): ?string + { + return QuestionTypeActorsExtraDataConfig::class; + } + + public function getDefaultValueConfigClass(): ?string { - return QuestionTypeActorsConfig::class; + return QuestionTypeActorsDefaultValueConfig::class; } } diff --git a/src/Glpi/Form/QuestionType/AbstractQuestionTypeSelectable.php b/src/Glpi/Form/QuestionType/AbstractQuestionTypeSelectable.php index bda28f4a472..4e9668481a3 100644 --- a/src/Glpi/Form/QuestionType/AbstractQuestionTypeSelectable.php +++ b/src/Glpi/Form/QuestionType/AbstractQuestionTypeSelectable.php @@ -148,8 +148,12 @@ public function hideOptionsDefaultValueInput(): bool */ public function getOptions(?Question $question): array { - /** @var ?QuestionTypeSelectableConfig $config */ - $config = $this->getConfig($question); + if ($question === null) { + return []; + } + + /** @var ?QuestionTypeSelectableExtraDataConfig $config */ + $config = $this->getExtraDataConfig(json_decode($question->fields['extra_data'], true) ?? []); if ($config === null) { return []; } @@ -351,8 +355,8 @@ public function isAllowedForUnauthenticatedAccess(): bool } #[Override] - public function getConfigClass(): ?string + public function getExtraDataConfigClass(): ?string { - return QuestionTypeSelectableConfig::class; + return QuestionTypeSelectableExtraDataConfig::class; } } diff --git a/src/Glpi/Form/QuestionType/QuestionTypeActorsDefaultValueConfig.php b/src/Glpi/Form/QuestionType/QuestionTypeActorsDefaultValueConfig.php new file mode 100644 index 00000000000..4f214929245 --- /dev/null +++ b/src/Glpi/Form/QuestionType/QuestionTypeActorsDefaultValueConfig.php @@ -0,0 +1,111 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Form\QuestionType; + +use Glpi\DBAL\JsonFieldInterface; +use Glpi\Form\Export\Context\ConfigWithForeignKeysInterface; +use Glpi\Form\Export\Context\ForeignKey\ForeignKeyArrayHandler; +use Glpi\Form\Export\Specification\ContentSpecificationInterface; +use Override; + +final class QuestionTypeActorsDefaultValueConfig implements JsonFieldInterface, ConfigWithForeignKeysInterface +{ + // Unique reference to hardcoded name used for serialization + public const KEY_USERS_IDS = "users_ids"; + public const KEY_GROUPS_IDS = "groups_ids"; + public const KEY_SUPPLIERS_IDS = "suppliers_ids"; + + public function __construct( + private array $users_ids = [], + private array $groups_ids = [], + private array $suppliers_ids = [], + ) { + } + + #[Override] + public static function listForeignKeysHandlers(ContentSpecificationInterface $content_spec): array + { + return [ + new ForeignKeyArrayHandler( + key: self::KEY_USERS_IDS, + itemtype: 'User', + ), + new ForeignKeyArrayHandler( + key: self::KEY_GROUPS_IDS, + itemtype: 'Group', + ), + new ForeignKeyArrayHandler( + key: self::KEY_SUPPLIERS_IDS, + itemtype: 'Supplier', + ), + ]; + } + + #[Override] + public static function jsonDeserialize(array $data): self + { + return new self( + users_ids: $data[self::KEY_USERS_IDS] ?? [], + groups_ids: $data[self::KEY_GROUPS_IDS] ?? [], + suppliers_ids: $data[self::KEY_SUPPLIERS_IDS] ?? [], + ); + } + + #[Override] + public function jsonSerialize(): array + { + return [ + self::KEY_USERS_IDS => $this->users_ids, + self::KEY_GROUPS_IDS => $this->groups_ids, + self::KEY_SUPPLIERS_IDS => $this->suppliers_ids, + ]; + } + + public function getUsersIds(): array + { + return $this->users_ids; + } + + public function getGroupsIds(): array + { + return $this->groups_ids; + } + + public function getSuppliersIds(): array + { + return $this->suppliers_ids; + } +} diff --git a/src/Glpi/Form/QuestionType/QuestionTypeActorsConfig.php b/src/Glpi/Form/QuestionType/QuestionTypeActorsExtraDataConfig.php similarity index 96% rename from src/Glpi/Form/QuestionType/QuestionTypeActorsConfig.php rename to src/Glpi/Form/QuestionType/QuestionTypeActorsExtraDataConfig.php index 01f4e4ff942..eefd435a4d4 100644 --- a/src/Glpi/Form/QuestionType/QuestionTypeActorsConfig.php +++ b/src/Glpi/Form/QuestionType/QuestionTypeActorsExtraDataConfig.php @@ -38,7 +38,7 @@ use Glpi\DBAL\JsonFieldInterface; use Override; -final class QuestionTypeActorsConfig implements JsonFieldInterface +final class QuestionTypeActorsExtraDataConfig implements JsonFieldInterface { // Unique reference to hardcoded name used for serialization public const IS_MULTIPLE_ACTORS = "is_multiple_actors"; diff --git a/src/Glpi/Form/QuestionType/QuestionTypeDateTime.php b/src/Glpi/Form/QuestionType/QuestionTypeDateTime.php index d9a0f8592ff..d6fc741610b 100644 --- a/src/Glpi/Form/QuestionType/QuestionTypeDateTime.php +++ b/src/Glpi/Form/QuestionType/QuestionTypeDateTime.php @@ -126,8 +126,12 @@ public function formatAnswer(string $answer): string public function isDefaultValueCurrentTime(?Question $question): bool { - /** @var ?QuestionTypeDateTimeConfig $config */ - $config = $this->getConfig($question); + if ($question === null) { + return false; + } + + /** @var ?QuestionTypeDateTimeExtraDataConfig $config */ + $config = $this->getExtraDataConfig(json_decode($question->fields['extra_data'], true) ?? []); if ($config === null) { return false; } @@ -137,8 +141,12 @@ public function isDefaultValueCurrentTime(?Question $question): bool public function isDateEnabled(?Question $question): bool { - /** @var ?QuestionTypeDateTimeConfig $config */ - $config = $this->getConfig($question); + if ($question === null) { + return false; + } + + /** @var ?QuestionTypeDateTimeExtraDataConfig $config */ + $config = $this->getExtraDataConfig(json_decode($question->fields['extra_data'], true) ?? []); if ($config === null) { return true; } @@ -148,8 +156,12 @@ public function isDateEnabled(?Question $question): bool public function isTimeEnabled(?Question $question): bool { - /** @var ?QuestionTypeDateTimeConfig $config */ - $config = $this->getConfig($question); + if ($question === null) { + return false; + } + + /** @var ?QuestionTypeDateTimeExtraDataConfig $config */ + $config = $this->getExtraDataConfig(json_decode($question->fields['extra_data'], true) ?? []); if ($config === null) { return false; } @@ -355,8 +367,8 @@ public function isAllowedForUnauthenticatedAccess(): bool } #[Override] - public function getConfigClass(): ?string + public function getExtraDataConfigClass(): ?string { - return QuestionTypeDateTimeConfig::class; + return QuestionTypeDateTimeExtraDataConfig::class; } } diff --git a/src/Glpi/Form/QuestionType/QuestionTypeDateTimeConfig.php b/src/Glpi/Form/QuestionType/QuestionTypeDateTimeExtraDataConfig.php similarity index 97% rename from src/Glpi/Form/QuestionType/QuestionTypeDateTimeConfig.php rename to src/Glpi/Form/QuestionType/QuestionTypeDateTimeExtraDataConfig.php index a9fc2d4134e..8f667a81314 100644 --- a/src/Glpi/Form/QuestionType/QuestionTypeDateTimeConfig.php +++ b/src/Glpi/Form/QuestionType/QuestionTypeDateTimeExtraDataConfig.php @@ -38,7 +38,7 @@ use Glpi\DBAL\JsonFieldInterface; use Override; -final class QuestionTypeDateTimeConfig implements JsonFieldInterface +final class QuestionTypeDateTimeExtraDataConfig implements JsonFieldInterface { // Unique reference to hardcoded name used for serialization public const IS_DEFAULT_VALUE_CURRENT_TIME = "is_default_value_current_time"; diff --git a/src/Glpi/Form/QuestionType/QuestionTypeDropdown.php b/src/Glpi/Form/QuestionType/QuestionTypeDropdown.php index 8ced8be82c1..fddaeb69368 100644 --- a/src/Glpi/Form/QuestionType/QuestionTypeDropdown.php +++ b/src/Glpi/Form/QuestionType/QuestionTypeDropdown.php @@ -61,8 +61,12 @@ public function getCategory(): QuestionTypeCategory */ public function isMultipleDropdown(?Question $question): bool { - /** @var ?QuestionTypeDropdownConfig $config */ - $config = $this->getConfig($question); + if ($question === null) { + return false; + } + + /** @var ?QuestionTypeDropdownExtraDataConfig $config */ + $config = $this->getExtraDataConfig(json_decode($question->fields['extra_data'], true) ?? []); if ($config === null) { return false; } @@ -238,8 +242,8 @@ public function renderEndUserTemplate( } #[Override] - public function getConfigClass(): ?string + public function getExtraDataConfigClass(): ?string { - return QuestionTypeDropdownConfig::class; + return QuestionTypeDropdownExtraDataConfig::class; } } diff --git a/src/Glpi/Form/QuestionType/QuestionTypeDropdownConfig.php b/src/Glpi/Form/QuestionType/QuestionTypeDropdownExtraDataConfig.php similarity index 95% rename from src/Glpi/Form/QuestionType/QuestionTypeDropdownConfig.php rename to src/Glpi/Form/QuestionType/QuestionTypeDropdownExtraDataConfig.php index d5ab91283be..5714ca4bf26 100644 --- a/src/Glpi/Form/QuestionType/QuestionTypeDropdownConfig.php +++ b/src/Glpi/Form/QuestionType/QuestionTypeDropdownExtraDataConfig.php @@ -37,7 +37,7 @@ use Override; -final class QuestionTypeDropdownConfig extends QuestionTypeSelectableConfig +final class QuestionTypeDropdownExtraDataConfig extends QuestionTypeSelectableExtraDataConfig { // Unique reference to hardcoded name used for serialization public const IS_MULTIPLE_DROPDOWN = "is_multiple_dropdown"; diff --git a/src/Glpi/Form/QuestionType/QuestionTypeInterface.php b/src/Glpi/Form/QuestionType/QuestionTypeInterface.php index f0aa79cb95a..4c1356ce9ee 100644 --- a/src/Glpi/Form/QuestionType/QuestionTypeInterface.php +++ b/src/Glpi/Form/QuestionType/QuestionTypeInterface.php @@ -182,18 +182,34 @@ public function getWeight(): int; public function isAllowedForUnauthenticatedAccess(): bool; /** - * Get the configuration class for this question type. + * Get the extra-data configuration class for this question type. * * @return ?string */ - public function getConfigClass(): ?string; + public function getExtraDataConfigClass(): ?string; /** - * Get the configuration for the given question. + * Get the extra-data configuration for the given question. * - * @param Question|null $question The question to get the configuration for. + * @param array $serialized_data The serialized data to get the configuration for. * * @return ?JsonFieldInterface */ - public function getConfig(?Question $question): ?JsonFieldInterface; + public function getExtraDataConfig(array $serialized_data): ?JsonFieldInterface; + + /** + * Get the default value configuration class for this question type. + * + * @return ?string + */ + public function getDefaultValueConfigClass(): ?string; + + /** + * Get the default value configuration for the given question. + * + * @param array $serialized_data The serialized data to get the configuration for. + * + * @return ?JsonFieldInterface + */ + public function getDefaultValueConfig(array $serialized_data): ?JsonFieldInterface; } diff --git a/src/Glpi/Form/QuestionType/QuestionTypeItem.php b/src/Glpi/Form/QuestionType/QuestionTypeItem.php index a6958357969..ee4b22c8832 100644 --- a/src/Glpi/Form/QuestionType/QuestionTypeItem.php +++ b/src/Glpi/Form/QuestionType/QuestionTypeItem.php @@ -62,6 +62,16 @@ public function __construct() $this->items_id_aria_label = __('Select an item'); } + #[Override] + public function formatDefaultValueForDB(mixed $value): ?string + { + if (!is_numeric($value)) { + return null; + } + + return json_encode((new QuestionTypeItemDefaultValueConfig($value))->jsonSerialize()); + } + /** * Retrieve the allowed item types * @@ -104,8 +114,12 @@ public function getAllowedItemtypes(): array */ public function getDefaultValueItemtype(?Question $question): ?string { - /** @var ?QuestionTypeItemConfig $config */ - $config = $this->getConfig($question); + if ($question === null) { + return null; + } + + /** @var ?QuestionTypeItemExtraDataConfig $config */ + $config = $this->getExtraDataConfig(json_decode($question->fields['extra_data'], true) ?? []); if ($config === null) { return null; } @@ -125,7 +139,13 @@ public function getDefaultValueItemId(?Question $question): int return 0; } - return (int) ($question->fields['default_value'] ?? 0); + /** @var ?QuestionTypeItemDefaultValueConfig $config */ + $config = $this->getDefaultValueConfig(json_decode($question->fields['default_value'] ?? '[]', true)); + if ($config === null) { + return 0; + } + + return (int) $config->getItemsId(); } #[Override] @@ -285,8 +305,14 @@ public function getWeight(): int } #[Override] - public function getConfigClass(): ?string + public function getExtraDataConfigClass(): ?string + { + return QuestionTypeItemExtraDataConfig::class; + } + + #[Override] + public function getDefaultValueConfigClass(): ?string { - return QuestionTypeItemConfig::class; + return QuestionTypeItemDefaultValueConfig::class; } } diff --git a/src/Glpi/Form/QuestionType/QuestionTypeItemDefaultValueConfig.php b/src/Glpi/Form/QuestionType/QuestionTypeItemDefaultValueConfig.php new file mode 100644 index 00000000000..0ac27cee77b --- /dev/null +++ b/src/Glpi/Form/QuestionType/QuestionTypeItemDefaultValueConfig.php @@ -0,0 +1,111 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Form\QuestionType; + +use Glpi\DBAL\JsonFieldInterface; +use Glpi\Form\Export\Context\ConfigWithForeignKeysInterface; +use Glpi\Form\Export\Context\ForeignKey\ForeignKeyHandler; +use Glpi\Form\Export\Specification\ContentSpecificationInterface; +use Glpi\Form\Export\Specification\QuestionContentSpecification; +use Override; + +final class QuestionTypeItemDefaultValueConfig implements JsonFieldInterface, ConfigWithForeignKeysInterface +{ + // Unique reference to hardcoded name used for serialization + public const KEY_ITEMS_ID = "items_id"; + + /** + * @param null|int|string $items_id Must accept a string because the foreign key handler + * replaces the ID with the item name during serialization. + */ + public function __construct( + private null|int|string $items_id = null + ) { + } + + #[Override] + public static function listForeignKeysHandlers(ContentSpecificationInterface $content_spec): array + { + if (!($content_spec instanceof QuestionContentSpecification)) { + throw new \InvalidArgumentException( + "Content specification must be an instance of " . QuestionContentSpecification::class + ); + } + + $extra_data_config = (new QuestionTypeItemExtraDataConfig())->jsonDeserialize( + json_decode($content_spec->extra_data, true) + ); + + $default_value_config = (new self())->jsonDeserialize( + json_decode($content_spec->default_value, true) + ); + + if ( + $extra_data_config->getItemtype() !== null + && !empty($default_value_config->items_id) + ) { + return [ + new ForeignKeyHandler( + key: self::KEY_ITEMS_ID, + itemtype: $extra_data_config->getItemtype(), + ), + ]; + } + + return []; + } + + #[Override] + public static function jsonDeserialize(array $data): self + { + return new self( + items_id: $data[self::KEY_ITEMS_ID] ?? null, + ); + } + + #[Override] + public function jsonSerialize(): array + { + return [ + self::KEY_ITEMS_ID => $this->items_id, + ]; + } + + public function getItemsId(): ?int + { + return $this->items_id; + } +} diff --git a/src/Glpi/Form/QuestionType/QuestionTypeItemConfig.php b/src/Glpi/Form/QuestionType/QuestionTypeItemExtraDataConfig.php similarity index 94% rename from src/Glpi/Form/QuestionType/QuestionTypeItemConfig.php rename to src/Glpi/Form/QuestionType/QuestionTypeItemExtraDataConfig.php index 579ff2358c5..6f46d0197a6 100644 --- a/src/Glpi/Form/QuestionType/QuestionTypeItemConfig.php +++ b/src/Glpi/Form/QuestionType/QuestionTypeItemExtraDataConfig.php @@ -38,13 +38,13 @@ use Glpi\DBAL\JsonFieldInterface; use Override; -final class QuestionTypeItemConfig implements JsonFieldInterface +final class QuestionTypeItemExtraDataConfig implements JsonFieldInterface { // Unique reference to hardcoded name used for serialization public const ITEMTYPE = "itemtype"; public function __construct( - private ?string $itemtype, + private ?string $itemtype = null, ) { } diff --git a/src/Glpi/Form/QuestionType/QuestionTypeSelectableConfig.php b/src/Glpi/Form/QuestionType/QuestionTypeSelectableExtraDataConfig.php similarity index 96% rename from src/Glpi/Form/QuestionType/QuestionTypeSelectableConfig.php rename to src/Glpi/Form/QuestionType/QuestionTypeSelectableExtraDataConfig.php index 0a965b8a9c8..95660c6dffe 100644 --- a/src/Glpi/Form/QuestionType/QuestionTypeSelectableConfig.php +++ b/src/Glpi/Form/QuestionType/QuestionTypeSelectableExtraDataConfig.php @@ -38,7 +38,7 @@ use Glpi\DBAL\JsonFieldInterface; use Override; -class QuestionTypeSelectableConfig implements JsonFieldInterface +class QuestionTypeSelectableExtraDataConfig implements JsonFieldInterface { // Unique reference to hardcoded name used for serialization public const OPTIONS = "options"; diff --git a/src/Glpi/Form/QuestionType/QuestionTypeUserDevice.php b/src/Glpi/Form/QuestionType/QuestionTypeUserDevice.php index 23a9ce412e5..a0a411fdde4 100644 --- a/src/Glpi/Form/QuestionType/QuestionTypeUserDevice.php +++ b/src/Glpi/Form/QuestionType/QuestionTypeUserDevice.php @@ -63,8 +63,12 @@ public function validateExtraDataInput(array $input): bool */ public function isMultipleDevices(?Question $question): bool { + if ($question === null) { + return false; + } + /** @var ?QuestionTypeUserDevicesConfig $config */ - $config = $this->getConfig($question); + $config = $this->getExtraDataConfig(json_decode($question->fields['extra_data'], true) ?? []); if ($config === null) { return false; } @@ -268,7 +272,7 @@ public function isAllowedForUnauthenticatedAccess(): bool } #[Override] - public function getConfigClass(): ?string + public function getExtraDataConfigClass(): ?string { return QuestionTypeUserDevicesConfig::class; } diff --git a/tests/cypress.config.js b/tests/cypress.config.js index 634e125d555..458fee5d7ff 100644 --- a/tests/cypress.config.js +++ b/tests/cypress.config.js @@ -40,6 +40,9 @@ module.exports = defineConfig({ e2e: { baseUrl: "http://localhost:80", experimentalMemoryManagement: true, + retries: { + runMode: 3, + }, setupNodeEvents(on) { // implement node event listeners here // Remove --start-maximized flag from Chrome diff --git a/tests/src/FormTesterTrait.php b/tests/src/FormTesterTrait.php index 02a842b0a63..ab135066941 100644 --- a/tests/src/FormTesterTrait.php +++ b/tests/src/FormTesterTrait.php @@ -89,14 +89,17 @@ protected function createForm(FormBuilder $builder): Form ]); // Create questions + $question_rank = 0; foreach ($section_data['questions'] as $question_data) { $this->createItem(Question::class, [ 'forms_sections_id' => $section->getID(), 'name' => $question_data['name'], 'type' => $question_data['type'], 'is_mandatory' => $question_data['is_mandatory'], + 'description' => $question_data['description'], 'default_value' => $question_data['default_value'], 'extra_data' => $question_data['extra_data'], + 'rank' => $question_rank++, ], [ 'default_value', // The default value can be formatted by the question type ]);