From 1eeb17cc61443b49128d3e392a599fdfe021c4c3 Mon Sep 17 00:00:00 2001 From: RomainLvr Date: Wed, 12 Aug 2026 15:02:53 +0200 Subject: [PATCH 1/9] Fix - Scope custom dropdown option lists to their own definition --- src/Model/QuestionType/TableQuestion.php | 4 +- .../TableQuestionRenderingTest.php | 53 ++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/Model/QuestionType/TableQuestion.php b/src/Model/QuestionType/TableQuestion.php index be46ac6..a1d5e9f 100644 --- a/src/Model/QuestionType/TableQuestion.php +++ b/src/Model/QuestionType/TableQuestion.php @@ -804,6 +804,7 @@ public function getCompatibleQuestionTypes(): array HostnameQuestion::class, HiddenQuestion::class, LdapQuestion::class, + ReservationQuestion::class, self::class, ]; @@ -933,7 +934,8 @@ private function buildGlpiItemtypeOptions(string $itemtype): array return $options; } - $where = []; + /** @var array $where */ + $where = $itemtype::getSystemSQLCriteria(); if ($item->maybeDeleted()) { $where['is_deleted'] = 0; diff --git a/tests/Model/QuestionType/TableQuestionRenderingTest.php b/tests/Model/QuestionType/TableQuestionRenderingTest.php index 5e7098b..f030c8d 100644 --- a/tests/Model/QuestionType/TableQuestionRenderingTest.php +++ b/tests/Model/QuestionType/TableQuestionRenderingTest.php @@ -33,15 +33,18 @@ namespace GlpiPlugin\Advancedforms\Tests\Model\QuestionType; +use Dropdown; use Glpi\Application\ImportMapGenerator; use Glpi\Form\Question; use Glpi\Form\QuestionType\QuestionTypeCheckbox; use Glpi\Form\QuestionType\QuestionTypeEmail; +use Glpi\Form\QuestionType\QuestionTypeItemDropdown; use Glpi\Form\QuestionType\QuestionTypeShortText; use Glpi\Tests\FormBuilder; use GlpiPlugin\Advancedforms\Model\QuestionType\TableQuestion; use GlpiPlugin\Advancedforms\Model\QuestionType\TableQuestionConfig; use GlpiPlugin\Advancedforms\Tests\AdvancedFormsTestCase; +use Session; use Symfony\Component\DomCrawler\Crawler; use function Safe\json_decode; @@ -269,6 +272,53 @@ public function testTheImportMapVersionsTheModuleOnItsContent(): void ); } + /** + * Regression test: custom dropdown definitions all share the same database + * table (distinguished only by a foreign key to their definition), so a + * column's option list must be scoped to its own definition. Without that + * scoping, every "Item (custom dropdown)" column ends up offering entries + * from every custom dropdown definition instead of just its own. + */ + public function testEachColumnOnlyShowsItsOwnCustomDropdownEntries(): void + { + $test1_definition = $this->initDropdownDefinition('Test1'); + $test2_definition = $this->initDropdownDefinition('Test2'); + + $test1_class = $test1_definition->getDropdownClassName(); + $test2_class = $test2_definition->getDropdownClassName(); + + Dropdown::resetItemtypesStaticCache(); + + $entity_id = Session::getActiveEntity(); + + $this->createItem($test1_class, [ + 'name' => 'Item from Test1', + 'entities_id' => $entity_id, + ]); + $this->createItem($test2_class, [ + 'name' => 'Item from Test2', + 'entities_id' => $entity_id, + ]); + + $html = $this->render([ + $this->column('Col1', QuestionTypeItemDropdown::class, itemtype: $test1_class), + $this->column('Col2', QuestionTypeItemDropdown::class, itemtype: $test2_class), + ]); + + $crawler = new Crawler($html); + $selects = $crawler->filter('[data-af-table-body] [data-af-table-row] select'); + $this->assertSame(2, $selects->count()); + + $col1_options = $selects->eq(0)->filter('option')->each(fn(Crawler $n): string => $n->text()); + $col2_options = $selects->eq(1)->filter('option')->each(fn(Crawler $n): string => $n->text()); + + $this->assertContains('Item from Test1', $col1_options); + $this->assertNotContains('Item from Test2', $col1_options); + + $this->assertContains('Item from Test2', $col2_options); + $this->assertNotContains('Item from Test1', $col2_options); + } + /** * @param array $columns * @return array Decoded `data-af-pattern-cols` payload. @@ -324,12 +374,13 @@ private function column( string $fqcn, bool $required = false, string $pattern = '', + string $itemtype = '', ): array { return [ TableQuestionConfig::COL_NAME => $name, TableQuestionConfig::COL_QUESTION_TYPE => $fqcn, TableQuestionConfig::COL_REQUIRED => $required, - TableQuestionConfig::COL_ITEMTYPE => '', + TableQuestionConfig::COL_ITEMTYPE => $itemtype, TableQuestionConfig::COL_PATTERN => $pattern, ]; } From cec9bb6bb0cdbb32c108f9f86c5c5a61f10a430e Mon Sep 17 00:00:00 2001 From: RomainLvr Date: Wed, 12 Aug 2026 16:40:10 +0200 Subject: [PATCH 2/9] Fix - Exclude question types with a sub-type selector --- src/Model/QuestionType/TableQuestion.php | 5 ++ .../Model/QuestionType/TableQuestionTest.php | 58 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/Model/QuestionType/TableQuestion.php b/src/Model/QuestionType/TableQuestion.php index a1d5e9f..5dffad0 100644 --- a/src/Model/QuestionType/TableQuestion.php +++ b/src/Model/QuestionType/TableQuestion.php @@ -817,6 +817,11 @@ public function getCompatibleQuestionTypes(): array } } + // Exclude question types with a sub-type selector (Fields plugin types) + if (!is_a($fqcn, QuestionTypeItem::class, true) && $type->getSubTypes() !== []) { + continue; + } + $types[$fqcn] = $type->getName(); } diff --git a/tests/Model/QuestionType/TableQuestionTest.php b/tests/Model/QuestionType/TableQuestionTest.php index ea22748..c9b6bdf 100644 --- a/tests/Model/QuestionType/TableQuestionTest.php +++ b/tests/Model/QuestionType/TableQuestionTest.php @@ -46,6 +46,13 @@ use GlpiPlugin\Advancedforms\Model\QuestionType\TableQuestion; use GlpiPlugin\Advancedforms\Model\QuestionType\TableQuestionConfig; use GlpiPlugin\Advancedforms\Tests\AdvancedFormsTestCase; +use Glpi\Form\QuestionType\AbstractQuestionType; +use Glpi\Form\QuestionType\QuestionTypeCategoryInterface; +use Glpi\Form\QuestionType\QuestionTypeItem; +use Glpi\Form\QuestionType\QuestionTypeItemDropdown; +use Glpi\Form\QuestionType\QuestionTypesManager; +use GlpiPlugin\Advancedforms\Model\QuestionType\AdvancedCategory; +use Override; final class TableQuestionTest extends AdvancedFormsTestCase { @@ -154,6 +161,57 @@ public function testCompatibleTypesExcludesTreeCascadeDropdown(): void $this->assertArrayNotHasKey(TreeCascadeDropdownQuestion::class, $types); } + /** + * Regression test for types with custom sub-type selectors, which cannot + * be represented as flat table column types and thus must be excluded. + */ + public function testCompatibleTypesExcludesTypesWithSubTypes(): void + { + $fake_type = new class extends AbstractQuestionType { + #[Override] + public function getCategory(): QuestionTypeCategoryInterface + { + return new AdvancedCategory(); + } + + #[Override] + public function getSubTypes(): array + { + return ['fake' => 'Fake sub type']; + } + + #[Override] + public function renderAdministrationTemplate(?\Glpi\Form\Question $question): string + { + return ''; + } + + #[Override] + public function renderEndUserTemplate(?\Glpi\Form\Question $question, mixed $answer = null): string + { + return ''; + } + }; + + QuestionTypesManager::getInstance()->registerPluginQuestionType($fake_type); + + $types = $this->type->getCompatibleQuestionTypes(); + $this->assertArrayNotHasKey($fake_type::class, $types); + } + + /** + * QuestionTypeItem and QuestionTypeItemDropdown both declare a non-empty + * getSubTypes() but must stay selectable: Table + * already renders them through its own dedicated itemtype picker + * (TableQuestionConfig::COL_ITEMTYPE), independent of getSubTypes(). + */ + public function testCompatibleTypesIncludesItemAndItemDropdownDespiteSubTypes(): void + { + $types = $this->type->getCompatibleQuestionTypes(); + $this->assertArrayHasKey(QuestionTypeItem::class, $types); + $this->assertArrayHasKey(QuestionTypeItemDropdown::class, $types); + } + public function testGetConfigKey(): void { $this->assertSame('enable_question_type_table', TableQuestion::getConfigKey()); From 4230f9f7e88c46fa0eb87eff55fbf0bd9a251897 Mon Sep 17 00:00:00 2001 From: RomainLvr Date: Wed, 12 Aug 2026 16:43:37 +0200 Subject: [PATCH 3/9] Rector --- tests/Model/QuestionType/TableQuestionTest.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Model/QuestionType/TableQuestionTest.php b/tests/Model/QuestionType/TableQuestionTest.php index c9b6bdf..17c7f22 100644 --- a/tests/Model/QuestionType/TableQuestionTest.php +++ b/tests/Model/QuestionType/TableQuestionTest.php @@ -33,6 +33,7 @@ namespace GlpiPlugin\Advancedforms\Tests\Model\QuestionType; +use Glpi\Form\Question; use Glpi\Form\Condition\ValueOperator; use Glpi\Form\QuestionType\QuestionTypeCheckbox; use Glpi\Form\QuestionType\QuestionTypeEmail; @@ -181,13 +182,13 @@ public function getSubTypes(): array } #[Override] - public function renderAdministrationTemplate(?\Glpi\Form\Question $question): string + public function renderAdministrationTemplate(?Question $question): string { return ''; } #[Override] - public function renderEndUserTemplate(?\Glpi\Form\Question $question, mixed $answer = null): string + public function renderEndUserTemplate(?Question $question, mixed $answer = null): string { return ''; } From e6fc18541969ef2619ef5e026b47740255cee433 Mon Sep 17 00:00:00 2001 From: RomainLvr Date: Wed, 12 Aug 2026 16:46:59 +0200 Subject: [PATCH 4/9] Update CHANGELOG --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8111ba9..29e2f87 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 Table question column type edge cases + ## [1.3.0] - 2026-08-11 ### Changed From a110bf35597ba5e1d93a1eb81d56aba90a3b13aa Mon Sep 17 00:00:00 2001 From: Romain Lecouvreur <102067890+RomainLvr@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:09:47 +0200 Subject: [PATCH 5/9] Update tests/Model/QuestionType/TableQuestionTest.php Co-authored-by: Romain B. <8530352+Rom1-B@users.noreply.github.com> --- tests/Model/QuestionType/TableQuestionTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Model/QuestionType/TableQuestionTest.php b/tests/Model/QuestionType/TableQuestionTest.php index 17c7f22..fd3940e 100644 --- a/tests/Model/QuestionType/TableQuestionTest.php +++ b/tests/Model/QuestionType/TableQuestionTest.php @@ -162,6 +162,12 @@ public function testCompatibleTypesExcludesTreeCascadeDropdown(): void $this->assertArrayNotHasKey(TreeCascadeDropdownQuestion::class, $types); } + public function testCompatibleTypesExcludesReservation(): void + { + $types = $this->type->getCompatibleQuestionTypes(); + $this->assertArrayNotHasKey(ReservationQuestion::class, $types); + } + /** * Regression test for types with custom sub-type selectors, which cannot * be represented as flat table column types and thus must be excluded. From 28d71d5f23b018e0a78729c11dd5116d18a95337 Mon Sep 17 00:00:00 2001 From: Romain Lecouvreur <102067890+RomainLvr@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:11:05 +0200 Subject: [PATCH 6/9] Update tests/Model/QuestionType/TableQuestionTest.php Co-authored-by: Stanislas --- tests/Model/QuestionType/TableQuestionTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Model/QuestionType/TableQuestionTest.php b/tests/Model/QuestionType/TableQuestionTest.php index fd3940e..9c7d94b 100644 --- a/tests/Model/QuestionType/TableQuestionTest.php +++ b/tests/Model/QuestionType/TableQuestionTest.php @@ -46,6 +46,7 @@ use GlpiPlugin\Advancedforms\Model\QuestionType\TreeCascadeDropdownQuestion; use GlpiPlugin\Advancedforms\Model\QuestionType\TableQuestion; use GlpiPlugin\Advancedforms\Model\QuestionType\TableQuestionConfig; +use GlpiPlugin\Advancedforms\Model\QuestionType\ReservationQuestion; use GlpiPlugin\Advancedforms\Tests\AdvancedFormsTestCase; use Glpi\Form\QuestionType\AbstractQuestionType; use Glpi\Form\QuestionType\QuestionTypeCategoryInterface; From fb5168a58e2131f144a1f2a4140ba3a6c6cfee66 Mon Sep 17 00:00:00 2001 From: RomainLvr Date: Wed, 19 Aug 2026 17:12:33 +0200 Subject: [PATCH 7/9] Replace capped dropdown with ajax mecha --- public/js/modules/AfTableQuestion.js | 65 +++++++--- src/Model/QuestionType/TableQuestion.php | 119 +++++------------- templates/table_end_user.html.twig | 16 +++ .../TableQuestionRenderingTest.php | 104 ++++++++++----- 4 files changed, 171 insertions(+), 133 deletions(-) diff --git a/public/js/modules/AfTableQuestion.js b/public/js/modules/AfTableQuestion.js index 038890c..df5929b 100644 --- a/public/js/modules/AfTableQuestion.js +++ b/public/js/modules/AfTableQuestion.js @@ -51,6 +51,11 @@ export class AfTableQuestion { this.#watchServerErrors(); + // The first row is server-rendered, not cloned from the template, so + // its ajax-backed selects (unlike the static 'adapt' ones, which + // self-init through Dropdown::showFromArray) need the same wiring. + this.#initSelectsInRow(this.#body.querySelector('[data-af-table-row]')); + this.#addBtn.addEventListener('click', () => this.addRow()); this.#body.addEventListener('click', e => { const btn = e.target.closest('[data-af-table-remove-row]'); @@ -335,23 +340,49 @@ export class AfTableQuestion { } #initSelectsInRow(row) { - if (!row || !window.setupAdaptDropdown) { return; } - const limit = parseInt(this.#table.dataset.afS2Limit, 10) || 100; - row.querySelectorAll('[data-af-needs-s2]').forEach(select => { - const id = 'dropdown_af_eu_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7); - select.id = id; - const config = { - type: 'adapt', - field_id: id, - width: '100%', - dropdown_css_class: '', - placeholder: '', - ajax_limit_count: limit, - }; - window.select2_configs = window.select2_configs || {}; - window.select2_configs[id] = config; - window.setupAdaptDropdown(config); - }); + if (!row) { return; } + + if (window.setupAdaptDropdown) { + const limit = parseInt(this.#table.dataset.afS2Limit, 10) || 100; + row.querySelectorAll('[data-af-needs-s2]').forEach(select => { + const id = AfTableQuestion.#newFieldId(select); + const config = { + type: 'adapt', + field_id: id, + width: '100%', + dropdown_css_class: '', + placeholder: '', + ajax_limit_count: limit, + }; + window.select2_configs = window.select2_configs || {}; + window.select2_configs[id] = config; + window.setupAdaptDropdown(config); + }); + } + + if (window.setupAjaxDropdown) { + row.querySelectorAll('[data-af-needs-ajax-s2]').forEach(select => { + let config; + try { + config = JSON.parse(select.dataset.afS2Config ?? ''); + } catch { + config = null; + } + if (!config || typeof config !== 'object') { return; } + + const id = AfTableQuestion.#newFieldId(select); + const full_config = { ...config, field_id: id }; + window.select2_configs = window.select2_configs || {}; + window.select2_configs[id] = full_config; + window.setupAjaxDropdown(full_config); + }); + } + } + + static #newFieldId(select) { + const id = 'dropdown_af_eu_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7); + select.id = id; + return id; } removeRow(rowElement) { diff --git a/src/Model/QuestionType/TableQuestion.php b/src/Model/QuestionType/TableQuestion.php index 5dffad0..d4cc892 100644 --- a/src/Model/QuestionType/TableQuestion.php +++ b/src/Model/QuestionType/TableQuestion.php @@ -714,9 +714,9 @@ public function renderEndUserTemplate(Question $question): string } $cell_map = []; - $user_options = null; + $user_ajax_config = null; $device_options = null; - $glpi_item_options = []; // keyed by itemtype FQCN to avoid duplicate DB queries + $item_ajax_configs = []; // keyed by itemtype FQCN to avoid duplicate IDOR tokens foreach ($config->getColumns() as $index => $col) { $fqcn = $col[TableQuestionConfig::COL_QUESTION_TYPE]; @@ -724,15 +724,15 @@ public function renderEndUserTemplate(Question $question): string $itemtype = $col[TableQuestionConfig::COL_ITEMTYPE] ?? ''; if (is_a($fqcn, AbstractQuestionTypeActors::class, true)) { - $user_options ??= $this->buildUserOptions(); - $cell_map[$index] = ['mode' => 'select', 'options' => $user_options]; + $user_ajax_config ??= $this->buildAjaxDropdownConfig(User::class, ['is_active' => 1, 'is_deleted' => 0]); + $cell_map[$index] = ['mode' => 'select-ajax', 'config' => $user_ajax_config]; } elseif (is_a($fqcn, QuestionTypeUserDevice::class, true)) { $device_options ??= $this->buildUserDeviceOptions(); $cell_map[$index] = ['mode' => 'select', 'options' => $device_options]; } elseif (is_a($fqcn, QuestionTypeItem::class, true)) { if ($itemtype !== '' && class_exists($itemtype)) { - $glpi_item_options[$itemtype] ??= $this->buildGlpiItemtypeOptions($itemtype); - $cell_map[$index] = ['mode' => 'select', 'options' => $glpi_item_options[$itemtype]]; + $item_ajax_configs[$itemtype] ??= $this->buildAjaxDropdownConfig($itemtype); + $cell_map[$index] = ['mode' => 'select-ajax', 'config' => $item_ajax_configs[$itemtype]]; } else { $cell_map[$index] = ['mode' => 'input', 'input_type' => 'text']; } @@ -869,43 +869,6 @@ public function getCellInfo(string $fqcn, ?QuestionTypeInterface $type = null): return ['mode' => 'input', 'input_type' => 'text']; } - /** - * Builds a [value => label] options array for actor-type columns. - * Loads up to 200 active users from the database. - * - * @return array - */ - private function buildUserOptions(): array - { - global $DB; - - $options = ['' => Dropdown::EMPTY_VALUE]; - - $rows = $DB->request([ - 'SELECT' => ['id', 'name', 'realname', 'firstname'], - 'FROM' => User::getTable(), - 'WHERE' => ['is_active' => 1, 'is_deleted' => 0], - 'ORDER' => ['realname', 'firstname', 'name'], - 'LIMIT' => 200, - ]); - - foreach ($rows as $row) { - if (!is_array($row)) { - continue; - } - - $id = is_numeric($row['id']) ? (int) $row['id'] : 0; - $options[(string) $id] = formatUserName( - $id, - is_string($row['name'] ?? null) ? $row['name'] : null, - is_string($row['realname'] ?? null) ? $row['realname'] : null, - is_string($row['firstname'] ?? null) ? $row['firstname'] : null, - ); - } - - return $options; - } - /** * Builds an optgroup-keyed options array for the User Device column type. * Keys at the top level are group labels; inner keys are "Itemtype_id" strings. @@ -923,58 +886,38 @@ private function buildUserDeviceOptions(): array } /** - * Builds a [id => name] options array for a GLPI itemtype (used by Item/ItemDropdown columns). - * Applies entity and soft-delete filters when applicable. + * Builds a select2 "ajax" widget config for a GLPI itemtype-backed column + * (Item/ItemDropdown columns, and the User picker behind Actor columns). * * @param class-string $itemtype - * @return array + * @param array $condition + * @return array */ - private function buildGlpiItemtypeOptions(string $itemtype): array + private function buildAjaxDropdownConfig(string $itemtype, array $condition = []): array { - global $DB; - - $options = ['' => Dropdown::EMPTY_VALUE]; - $item = getItemForItemtype($itemtype); - if ($item === false) { - return $options; - } - - /** @var array $where */ - $where = $itemtype::getSystemSQLCriteria(); - - if ($item->maybeDeleted()) { - $where['is_deleted'] = 0; - } + global $CFG_GLPI; - if ($item->isEntityAssign()) { - $where = array_merge($where, getEntitiesRestrictCriteria( - $item->getTable(), - '', - '', - $item->maybeRecursive(), - )); - } + $condition_key = $condition !== [] ? Dropdown::addNewCondition($condition) : ''; + $root_doc = is_string($CFG_GLPI['root_doc'] ?? null) ? $CFG_GLPI['root_doc'] : ''; + $dropdown_max = $CFG_GLPI['dropdown_max'] ?? 50; - $criteria = [ - 'SELECT' => ['id', 'name'], - 'FROM' => $item->getTable(), - 'ORDER' => 'name', - 'LIMIT' => 200, + return [ + 'url' => $root_doc . '/ajax/getDropdownValue.php', + 'params' => [ + 'itemtype' => $itemtype, + 'condition' => $condition_key, + '_idor_token' => Session::getNewIDORToken($itemtype, ['condition' => $condition_key]), + ], + 'dropdown_max' => is_numeric($dropdown_max) ? (int) $dropdown_max : 50, + 'ajax_limit_count' => $this->ajaxLimitCount(), + 'width' => '100%', + 'container_css_class' => '', + 'multiple' => false, + 'placeholder' => Dropdown::EMPTY_VALUE, + 'allowclear' => false, + 'parent_id_field' => '', + 'on_change' => '', ]; - - if ($where !== []) { - $criteria['WHERE'] = $where; - } - - foreach ($DB->request($criteria) as $row) { - if (!is_array($row)) { - continue; - } - - $options[(string) (is_numeric($row['id']) ? (int) $row['id'] : 0)] = is_string($row['name']) ? $row['name'] : ''; - } - - return $options; } private function loadConfig(Question $question): TableQuestionConfig diff --git a/templates/table_end_user.html.twig b/templates/table_end_user.html.twig index e1fbaaa..621500a 100644 --- a/templates/table_end_user.html.twig +++ b/templates/table_end_user.html.twig @@ -89,6 +89,14 @@ ]) %} {% endset %} {{ sel_html|raw }} + {% elseif cell.mode == 'select-ajax' %} + {% else %} {{ lbl }} {% endfor %} + {% elseif cell.mode == 'select-ajax' %} + {% else %} initDropdownDefinition('Test1'); $test2_definition = $this->initDropdownDefinition('Test2'); @@ -289,34 +286,85 @@ public function testEachColumnOnlyShowsItsOwnCustomDropdownEntries(): void Dropdown::resetItemtypesStaticCache(); - $entity_id = Session::getActiveEntity(); - - $this->createItem($test1_class, [ - 'name' => 'Item from Test1', - 'entities_id' => $entity_id, - ]); - $this->createItem($test2_class, [ - 'name' => 'Item from Test2', - 'entities_id' => $entity_id, - ]); - - $html = $this->render([ + $html = $this->render([ $this->column('Col1', QuestionTypeItemDropdown::class, itemtype: $test1_class), $this->column('Col2', QuestionTypeItemDropdown::class, itemtype: $test2_class), ]); + $configs = $this->renderedAjaxConfigs($html); + + $this->assertCount(2, $configs); + $this->assertSame($test1_class, $configs[0]['params']['itemtype']); + $this->assertSame($test2_class, $configs[1]['params']['itemtype']); + $this->assertNotSame( + $configs[0]['params']['_idor_token'], + $configs[1]['params']['_idor_token'], + "Each column must get its own IDOR token, or one column could query the other's scope.", + ); + } + public function testGlpiObjectColumnIsBackedByTheAjaxDropdownEndpoint(): void + { + $html = $this->render([$this->column('Asset', QuestionTypeItem::class, itemtype: Computer::class)]); + $configs = $this->renderedAjaxConfigs($html); + + $this->assertCount(1, $configs); + $this->assertSame(Computer::class, $configs[0]['params']['itemtype']); + $this->assertNotEmpty($configs[0]['params']['_idor_token']); + $this->assertStringEndsWith('/ajax/getDropdownValue.php', $configs[0]['url']); + } + + public function testGlpiObjectColumnHasNoPreFetchedOptions(): void + { + for ($i = 1; $i <= 3; $i++) { + $this->createItem(Computer::class, ['name' => 'Computer ' . $i, 'entities_id' => Session::getActiveEntity()]); + } + + $html = $this->render([$this->column('Asset', QuestionTypeItem::class, itemtype: Computer::class)]); $crawler = new Crawler($html); - $selects = $crawler->filter('[data-af-table-body] [data-af-table-row] select'); - $this->assertSame(2, $selects->count()); + $select = $crawler->filter('[data-af-table-body] [data-af-table-row] select[data-af-needs-ajax-s2]'); - $col1_options = $selects->eq(0)->filter('option')->each(fn(Crawler $n): string => $n->text()); - $col2_options = $selects->eq(1)->filter('option')->each(fn(Crawler $n): string => $n->text()); + $this->assertSame(1, $select->count()); + $this->assertSame(0, $select->filter('option')->count()); + } + + public function testActorColumnIsBackedByTheAjaxDropdownEndpointRestrictedToActiveUsers(): void + { + $html = $this->render([$this->column('Owner', QuestionTypeRequester::class)]); + $configs = $this->renderedAjaxConfigs($html); + + $this->assertCount(1, $configs); + $this->assertSame(User::class, $configs[0]['params']['itemtype']); + + $condition_key = $configs[0]['params']['condition']; + $this->assertNotSame('', $condition_key); + $this->assertSame( + ['is_active' => 1, 'is_deleted' => 0], + $_SESSION['glpicondition'][$condition_key] ?? null, + ); + } + + public function testAjaxColumnConfigIsAlsoPresentInTheRowCloneTemplate(): void + { + $html = $this->render([$this->column('Asset', QuestionTypeItem::class, itemtype: Computer::class)]); + + $this->assertSame(2, substr_count($html, 'data-af-needs-ajax-s2')); + } + + /** + * @return list> Decoded `data-af-s2-config` payload + * for each ajax-backed select in the visible row, in column order. + */ + private function renderedAjaxConfigs(string $html): array + { + $crawler = new Crawler($html); + $selects = $crawler->filter('[data-af-table-body] [data-af-table-row] select[data-af-needs-ajax-s2]'); - $this->assertContains('Item from Test1', $col1_options); - $this->assertNotContains('Item from Test2', $col1_options); + return $selects->each(function (Crawler $n): array { + $decoded = json_decode((string) $n->attr('data-af-s2-config'), associative: true); + $this->assertIsArray($decoded, 'data-af-s2-config must hold a JSON object.'); - $this->assertContains('Item from Test2', $col2_options); - $this->assertNotContains('Item from Test1', $col2_options); + return $decoded; + }); } /** From e75ceae57fe56ce17b9470d18bbbf3cf3ca0ff71 Mon Sep 17 00:00:00 2001 From: RomainLvr Date: Thu, 20 Aug 2026 15:09:49 +0200 Subject: [PATCH 8/9] Apply suggestions --- templates/table_end_user.html.twig | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/templates/table_end_user.html.twig b/templates/table_end_user.html.twig index 621500a..e000dfd 100644 --- a/templates/table_end_user.html.twig +++ b/templates/table_end_user.html.twig @@ -96,7 +96,9 @@ data-af-needs-ajax-s2 data-af-s2-config="{{ cell.config|json_encode|e('html_attr') }}" {% if col.required %} required {% endif %} - > + > + + {% else %} + > + + {% else %} Date: Thu, 20 Aug 2026 15:18:18 +0200 Subject: [PATCH 9/9] Fix test --- tests/Model/QuestionType/TableQuestionRenderingTest.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/Model/QuestionType/TableQuestionRenderingTest.php b/tests/Model/QuestionType/TableQuestionRenderingTest.php index 8643a40..df7752a 100644 --- a/tests/Model/QuestionType/TableQuestionRenderingTest.php +++ b/tests/Model/QuestionType/TableQuestionRenderingTest.php @@ -324,7 +324,13 @@ public function testGlpiObjectColumnHasNoPreFetchedOptions(): void $select = $crawler->filter('[data-af-table-body] [data-af-table-row] select[data-af-needs-ajax-s2]'); $this->assertSame(1, $select->count()); - $this->assertSame(0, $select->filter('option')->count()); + + $options = $select->filter('option'); + $this->assertLessThanOrEqual(1, $options->count()); + if ($options->count() === 1) { + $this->assertSame('', $options->attr('value')); + $this->assertNotNull($options->attr('disabled')); + } } public function testActorColumnIsBackedByTheAjaxDropdownEndpointRestrictedToActiveUsers(): void