From 20bf27ea0d388577eb3496efacafce4f02a66d82 Mon Sep 17 00:00:00 2001 From: RomainLvr Date: Wed, 24 Jun 2026 10:23:45 +0200 Subject: [PATCH 1/7] Feat - Add configurable table question type --- public/css/advancedforms.css | 39 + public/js/modules/AfTableQuestion.js | 245 +++++ public/js/modules/AfTableQuestionConfig.js | 171 ++++ src/Model/QuestionType/TableQuestion.php | 845 ++++++++++++++++++ .../QuestionType/TableQuestionConfig.php | 124 +++ src/Service/ConfigManager.php | 2 + .../question_types/table_admin.html.twig | 65 ++ .../question_types/table_config.html.twig | 271 ++++++ templates/table_answer.html.twig | 49 + templates/table_end_user.html.twig | 186 ++++ .../QuestionType/TableQuestionConfigTest.php | 88 ++ .../TableQuestionIntegrationTest.php | 114 +++ .../Model/QuestionType/TableQuestionTest.php | 172 ++++ .../TableQuestionValidationTest.php | 234 +++++ 14 files changed, 2605 insertions(+) create mode 100644 public/js/modules/AfTableQuestion.js create mode 100644 public/js/modules/AfTableQuestionConfig.js create mode 100644 src/Model/QuestionType/TableQuestion.php create mode 100644 src/Model/QuestionType/TableQuestionConfig.php create mode 100644 templates/editor/question_types/table_admin.html.twig create mode 100644 templates/editor/question_types/table_config.html.twig create mode 100644 templates/table_answer.html.twig create mode 100644 templates/table_end_user.html.twig create mode 100644 tests/Model/QuestionType/TableQuestionConfigTest.php create mode 100644 tests/Model/QuestionType/TableQuestionIntegrationTest.php create mode 100644 tests/Model/QuestionType/TableQuestionTest.php create mode 100644 tests/Model/QuestionType/TableQuestionValidationTest.php diff --git a/public/css/advancedforms.css b/public/css/advancedforms.css index 866d883..d64ee45 100644 --- a/public/css/advancedforms.css +++ b/public/css/advancedforms.css @@ -33,3 +33,42 @@ [data-glpi-form-editor-active-question] [data-ldap-question-preview] { display: none !important; } + +/* + * GLPI's .select2-selection__rendered has line-height: ~42px + a ::before pseudo-element, + * making the total height ~80px while the container is ~40px with overflow:hidden. + * When the selection content is a flex span, it gets pushed below the visible area. + * Fix applied for both the admin column config panel and the end-user table cells. + */ +[data-af-table-column] .select2-selection--single .select2-selection__rendered, +[data-af-table-question] td .select2-selection--single .select2-selection__rendered { + display: flex !important; + align-items: center !important; + line-height: normal !important; + direction: ltr !important; + height: 100% !important; +} +[data-af-table-column] .select2-selection--single .select2-selection__rendered::before, +[data-af-table-question] td .select2-selection--single .select2-selection__rendered::before { + display: none !important; +} + +/* + * Per-cell invalid state for select2 cells. + * + * Core (_form-renderer.scss) reddens EVERY select2 of a question as soon as one of + * its selects is .is-invalid, via a question-level selector: + * [data-glpi-form-renderer-question]:has(select.is-invalid) .select2-selection + * A table holds many selects inside a single question, so one invalid cell would turn + * every dropdown red. We re-scope the highlight to the offending cell: while the table + * has an invalid select, valid cells are reset to the default border, and only the cell + * that actually contains the invalid select keeps the red border. + * + * Plain inputs and checkboxes already get their red border from Bootstrap's .is-invalid. + */ +[data-af-table-question]:has(select.is-invalid) td:not(:has(select.is-invalid)) .select2-container--default .select2-selection { + border-color: var(--tblr-border-color) !important; +} +[data-af-table-question] td:has(select.is-invalid) .select2-container--default .select2-selection { + border-color: var(--tblr-form-invalid-border-color) !important; +} diff --git a/public/js/modules/AfTableQuestion.js b/public/js/modules/AfTableQuestion.js new file mode 100644 index 0000000..8879073 --- /dev/null +++ b/public/js/modules/AfTableQuestion.js @@ -0,0 +1,245 @@ +/** + * ------------------------------------------------------------------------- + * advancedforms plugin for GLPI + * ------------------------------------------------------------------------- + * + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * ------------------------------------------------------------------------- + * @copyright Copyright (C) 2025 by the advancedforms plugin team. + * @license MIT https://opensource.org/licenses/mit-license.php + * @link https://github.com/pluginsGLPI/advancedforms + * ------------------------------------------------------------------------- + */ + +export class AfTableQuestion { + static #submitGuardRegistered = false; + + #table; + #body; + #template; + #addBtn; + #minRows; + #maxRows; + + constructor(tableElement) { + this.#table = tableElement; + this.#body = tableElement.querySelector('[data-af-table-body]'); + this.#template = tableElement.querySelector('[data-af-table-row-template]'); + this.#addBtn = tableElement.querySelector('[data-af-table-add-row]'); + this.#minRows = parseInt(tableElement.dataset.afMinRows, 10) || 1; + this.#maxRows = parseInt(tableElement.dataset.afMaxRows, 10) || 50; + + if (!this.#body || !this.#template || !this.#addBtn) { + return; + } + + this.#addBtn.addEventListener('click', () => this.addRow()); + this.#body.addEventListener('click', e => { + const btn = e.target.closest('[data-af-table-remove-row]'); + if (btn) { + this.removeRow(btn.closest('[data-af-table-row]')); + } + }); + // Clear a cell's error state as soon as the user fills it. + const clear = e => AfTableQuestion.#clearCellError(e.target); + this.#body.addEventListener('input', clear); + if (window.$) { + // select2 fires its "change" through jQuery, which native + // addEventListener('change') handlers never receive. + window.$(this.#body).on('change', clear); + } else { + this.#body.addEventListener('change', clear); + } + this.#updateButtonStates(); + + AfTableQuestion.#registerSubmitGuard(); + } + + static #registerSubmitGuard() { + if (AfTableQuestion.#submitGuardRegistered) { return; } + AfTableQuestion.#submitGuardRegistered = true; + + document.addEventListener('click', e => { + const trigger = e.target.closest('[data-glpi-form-renderer-action=submit]'); + if (!trigger) { return; } + + const scope = trigger.closest('form') ?? document; + let firstInvalid = null; + scope.querySelectorAll('[data-af-table-question]').forEach(table => { + const invalid = AfTableQuestion.#validateTable(table); + if (invalid && !firstInvalid) { firstInvalid = invalid; } + }); + + if (firstInvalid) { + e.preventDefault(); + e.stopImmediatePropagation(); + // Scroll to the cell, as a select2-managed + {% set col_select %} + {% do call('Dropdown::showFromArray', [ + 'extra_data[columns][' ~ index ~ '][' ~ COL_QUESTION_TYPE ~ ']', + compatible_types, + { + 'value' : col[COL_QUESTION_TYPE], + 'class' : 'form-select', + 'width' : '200px', + 'templateResult' : 'window.afTableColumnTypeTemplate', + 'templateSelection' : 'window.afTableColumnTypeTemplate', + } + ]) %} + {% endset %} + {{ col_select|raw }} + {% for type_fqcn, options in itemtype_options %} +
+ +
+ {% endfor %} + + + + {% endfor %} + + + {# Template cloned by JS for new columns #} + + + + +
+ +
+ + +
+ + + + + + + + + diff --git a/templates/table_answer.html.twig b/templates/table_answer.html.twig new file mode 100644 index 0000000..908efd4 --- /dev/null +++ b/templates/table_answer.html.twig @@ -0,0 +1,49 @@ +{# + # ------------------------------------------------------------------------- + # advancedforms plugin for GLPI + # ------------------------------------------------------------------------- + # + # MIT License + # + # Permission is hereby granted, free of charge, to any person obtaining a copy + # of this software and associated documentation files (the "Software"), to deal + # in the Software without restriction, including without limitation the rights + # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + # copies of the Software, and to permit persons to whom the Software is + # furnished to do so, subject to the following conditions: + # + # The above copyright notice and this permission notice shall be included in all + # copies or substantial portions of the Software. + # + # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + # SOFTWARE. + # ------------------------------------------------------------------------- + # @copyright Copyright (C) 2025 by the advancedforms plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/advancedforms + # ------------------------------------------------------------------------- + #} + + + + + {% for header in headers %} + + {% endfor %} + + + + {% for row in rows %} + + {% for cell in row %} + + {% endfor %} + + {% endfor %} + +
{{ header }}
{{ cell }}
diff --git a/templates/table_end_user.html.twig b/templates/table_end_user.html.twig new file mode 100644 index 0000000..220e4dc --- /dev/null +++ b/templates/table_end_user.html.twig @@ -0,0 +1,186 @@ +{# + # ------------------------------------------------------------------------- + # advancedforms plugin for GLPI + # ------------------------------------------------------------------------- + # + # MIT License + # + # Permission is hereby granted, free of charge, to any person obtaining a copy + # of this software and associated documentation files (the "Software"), to deal + # in the Software without restriction, including without limitation the rights + # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + # copies of the Software, and to permit persons to whom the Software is + # furnished to do so, subject to the following conditions: + # + # The above copyright notice and this permission notice shall be included in all + # copies or substantial portions of the Software. + # + # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + # SOFTWARE. + # ------------------------------------------------------------------------- + # @copyright Copyright (C) 2025 by the advancedforms plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/advancedforms + # ------------------------------------------------------------------------- + #} + +{% set rand_eu = random() %} +{% set input_base = question.getEndUserInputName() %} +{% set min_rows = config.getMinRows() %} +{% set max_rows = config.getMaxRows() %} +{% set columns = config.getColumns() %} + +{% set required_cols = [] %} +{% for col_index, col in columns %} + {% if col.required %}{% set required_cols = required_cols|merge([col_index]) %}{% endif %} +{% endfor %} + +
+ + + + {% for col in columns %} + + {% endfor %} + + + + + {% for row_index in 0..(min_rows - 1) %} + + {% for col_index, col in columns %} + {% set cell = column_cell_map[col_index] %} + + {% endfor %} + + + {% endfor %} + +
+ {{ col.name }} + {% if col.required %}{% endif %} +
+ {% if cell.mode == 'checkbox' %} +
+ +
+ {% elseif cell.mode == 'select' %} + {% set sel_html %} + {% do call('Dropdown::showFromArray', [ + input_base ~ '[' ~ row_index ~ '][col_' ~ col_index ~ ']', + cell.options, + { + 'class' : 'form-select form-select-sm', + 'width' : '100%', + 'display_emptychoice': false, + } + ]) %} + {% endset %} + {{ sel_html|raw }} + {% else %} + + {% endif %} +
+ +
+ + {# Template for cloning new rows #} + + + +
+ + diff --git a/tests/Model/QuestionType/TableQuestionConfigTest.php b/tests/Model/QuestionType/TableQuestionConfigTest.php new file mode 100644 index 0000000..d9fbb42 --- /dev/null +++ b/tests/Model/QuestionType/TableQuestionConfigTest.php @@ -0,0 +1,88 @@ +assertSame([], $config->getColumns()); + $this->assertSame(1, $config->getMinRows()); + $this->assertSame(50, $config->getMaxRows()); + } + + public function testJsonRoundtrip(): void + { + $original = new TableQuestionConfig( + columns: [ + ['name' => 'Source IP', 'question_type' => 'Glpi\\Form\\QuestionType\\QuestionTypeShortText', 'required' => true, 'itemtype' => ''], + ['name' => 'Port', 'question_type' => 'Glpi\\Form\\QuestionType\\QuestionTypeNumber', 'required' => false, 'itemtype' => ''], + ], + min_rows: 2, + max_rows: 20, + ); + $serialized = $original->jsonSerialize(); + $deserialized = TableQuestionConfig::jsonDeserialize($serialized); + $this->assertSame($original->getColumns(), $deserialized->getColumns()); + $this->assertSame($original->getMinRows(), $deserialized->getMinRows()); + $this->assertSame($original->getMaxRows(), $deserialized->getMaxRows()); + } + + public function testJsonDeserializeFiltersNonArrayColumns(): void + { + $config = TableQuestionConfig::jsonDeserialize([ + 'columns' => ['not_array', ['name' => 'Valid', 'question_type' => 'SomeFqcn', 'required' => false]], + 'min_rows' => 1, + 'max_rows' => 50, + ]); + $this->assertCount(1, $config->getColumns()); + $this->assertSame('Valid', $config->getColumns()[0]['name']); + } + + public function testJsonDeserializeEnforcesMinRow(): void + { + $config = TableQuestionConfig::jsonDeserialize(['min_rows' => 0, 'max_rows' => 10]); + $this->assertSame(1, $config->getMinRows()); + } + + public function testJsonDeserializeEnforcesMaxRowNotZero(): void + { + $config = TableQuestionConfig::jsonDeserialize(['min_rows' => 1, 'max_rows' => 0]); + $this->assertSame(1, $config->getMaxRows()); + } +} diff --git a/tests/Model/QuestionType/TableQuestionIntegrationTest.php b/tests/Model/QuestionType/TableQuestionIntegrationTest.php new file mode 100644 index 0000000..11cb0c7 --- /dev/null +++ b/tests/Model/QuestionType/TableQuestionIntegrationTest.php @@ -0,0 +1,114 @@ + 'Source IP', + TableQuestionConfig::COL_QUESTION_TYPE => QuestionTypeShortText::class, + TableQuestionConfig::COL_REQUIRED => true, + ], + [ + TableQuestionConfig::COL_NAME => 'Port', + TableQuestionConfig::COL_QUESTION_TYPE => QuestionTypeNumber::class, + TableQuestionConfig::COL_REQUIRED => false, + ], + ], + min_rows: 1, + max_rows: 50, + )); + } + + #[Override] + protected function validateEditorRenderingWhenEnabled(Crawler $html): void + { + // Preview table must show column headers — use combined text to handle required asterisk span + $headerText = implode(' ', $html->filter('th')->each(fn(Crawler $n) => $n->text())); + $this->assertStringContainsString('Source IP', $headerText); + $this->assertStringContainsString('Port', $headerText); + + // Gear settings button must be present + $gear = $html->filter('[data-glpi-form-editor-question-extra-details]'); + $this->assertNotEmpty($gear); + } + + #[Override] + protected function validateHelpdeskRenderingWhenEnabled(Crawler $html): void + { + // Dynamic table container must exist + $container = $html->filter('[data-af-table-question]'); + $this->assertNotEmpty($container); + + // Required columns must be exposed to the client validation layer. + // The default config marks the first column ("Source IP") as required. + $this->assertSame('0', $container->attr('data-af-required-cols')); + + // At least one input row rendered (min_rows = 1) + $rows = $html->filter('[data-af-table-body] [data-af-table-row]'); + $this->assertGreaterThanOrEqual(1, $rows->count()); + + // Add-row button must be present + $addBtn = $html->filter('[data-af-table-add-row]'); + $this->assertNotEmpty($addBtn); + } + + #[Override] + protected function validateHelpdeskRenderingWhenDisabled(Crawler $html): void + { + $container = $html->filter('[data-af-table-question]'); + $this->assertEmpty($container); + } +} diff --git a/tests/Model/QuestionType/TableQuestionTest.php b/tests/Model/QuestionType/TableQuestionTest.php new file mode 100644 index 0000000..f8937e3 --- /dev/null +++ b/tests/Model/QuestionType/TableQuestionTest.php @@ -0,0 +1,172 @@ +type = new TableQuestion(); + } + + public function testPrepareExtraDataReindexesColumns(): void + { + $input = [ + 'columns' => [ + 5 => ['name' => 'A', 'question_type' => QuestionTypeShortText::class, 'required' => false], + 9 => ['name' => 'B', 'question_type' => QuestionTypeNumber::class, 'required' => true], + ], + 'min_rows' => '2', + 'max_rows' => '20', + ]; + $result = $this->type->prepareExtraData($input); + $this->assertArrayHasKey(0, $result[TableQuestionConfig::COLUMNS]); + $this->assertArrayHasKey(1, $result[TableQuestionConfig::COLUMNS]); + $this->assertSame(2, $result[TableQuestionConfig::MIN_ROWS]); + $this->assertSame(20, $result[TableQuestionConfig::MAX_ROWS]); + } + + public function testPrepareExtraDataCoercesRequiredToBool(): void + { + $input = [ + 'columns' => [['name' => 'X', 'question_type' => QuestionTypeShortText::class, 'required' => '1']], + 'min_rows' => 1, + 'max_rows' => 50, + ]; + $result = $this->type->prepareExtraData($input); + $this->assertIsBool($result[TableQuestionConfig::COLUMNS][0][TableQuestionConfig::COL_REQUIRED]); + $this->assertTrue($result[TableQuestionConfig::COLUMNS][0][TableQuestionConfig::COL_REQUIRED]); + } + + public function testCompatibleTypesExcludesFile(): void + { + $types = $this->type->getCompatibleQuestionTypes(); + $this->assertArrayNotHasKey(QuestionTypeFile::class, $types); + } + + public function testCompatibleTypesExcludesSelf(): void + { + $types = $this->type->getCompatibleQuestionTypes(); + $this->assertArrayNotHasKey(TableQuestion::class, $types); + } + + public function testCompatibleTypesIncludesShortText(): void + { + $types = $this->type->getCompatibleQuestionTypes(); + $this->assertArrayHasKey(QuestionTypeShortText::class, $types); + } + + public function testCellInfoForNumber(): void + { + $info = $this->type->getCellInfo(QuestionTypeNumber::class); + $this->assertSame('input', $info['mode']); + $this->assertSame('number', $info['input_type']); + } + + public function testCellInfoForEmail(): void + { + $info = $this->type->getCellInfo(QuestionTypeEmail::class); + $this->assertSame('input', $info['mode']); + $this->assertSame('email', $info['input_type']); + } + + public function testCellInfoDefaultsToText(): void + { + $info = $this->type->getCellInfo(QuestionTypeShortText::class); + $this->assertSame('input', $info['mode']); + $this->assertSame('text', $info['input_type']); + } + + public function testCellInfoForCheckbox(): void + { + $info = $this->type->getCellInfo(QuestionTypeCheckbox::class); + $this->assertSame('checkbox', $info['mode']); + } + + public function testCellInfoForHidden(): void + { + $info = $this->type->getCellInfo(HiddenQuestion::class, new HiddenQuestion()); + $this->assertSame('input', $info['mode']); + $this->assertSame('hidden', $info['input_type']); + } + + public function testCompatibleTypesExcludesRequestType(): void + { + $types = $this->type->getCompatibleQuestionTypes(); + $this->assertArrayNotHasKey(QuestionTypeRequestType::class, $types); + } + + public function testCompatibleTypesExcludesLdap(): void + { + $types = $this->type->getCompatibleQuestionTypes(); + $this->assertArrayNotHasKey(LdapQuestion::class, $types); + } + + public function testCompatibleTypesExcludesTreeCascadeDropdown(): void + { + $types = $this->type->getCompatibleQuestionTypes(); + $this->assertArrayNotHasKey(TreeCascadeDropdownQuestion::class, $types); + } + + public function testGetConfigKey(): void + { + $this->assertSame('enable_question_type_table', TableQuestion::getConfigKey()); + } + + public function testGetIcon(): void + { + $this->assertSame('ti ti-table', $this->type->getIcon()); + } + + public function testGetExtraDataConfigClass(): void + { + $this->assertSame(TableQuestionConfig::class, $this->type->getExtraDataConfigClass()); + } +} diff --git a/tests/Model/QuestionType/TableQuestionValidationTest.php b/tests/Model/QuestionType/TableQuestionValidationTest.php new file mode 100644 index 0000000..36e815f --- /dev/null +++ b/tests/Model/QuestionType/TableQuestionValidationTest.php @@ -0,0 +1,234 @@ +type = new TableQuestion(); + } + + public function testRequiredColumnEmptyInFilledRowProducesError(): void + { + $question = $this->makeTableQuestion([ + $this->column('Name', QuestionTypeShortText::class, required: true), + $this->column('Comment', QuestionTypeShortText::class, required: false), + ]); + + $result = $this->type->validateAnswer($question, [ + ['col_0' => '', 'col_1' => 'a comment'], + ]); + + $this->assertFalse($result->isValid()); + $this->assertCount(1, $result->getErrors()); + } + + public function testAllRequiredColumnsFilledIsValid(): void + { + $question = $this->makeTableQuestion([ + $this->column('Name', QuestionTypeShortText::class, required: true), + $this->column('Comment', QuestionTypeShortText::class, required: false), + ]); + + $result = $this->type->validateAnswer($question, [ + ['col_0' => 'Alice', 'col_1' => ''], + ]); + + $this->assertTrue($result->isValid()); + $this->assertCount(0, $result->getErrors()); + } + + public function testEntirelyEmptyRowIsSkipped(): void + { + $question = $this->makeTableQuestion([ + $this->column('Name', QuestionTypeShortText::class, required: true), + $this->column('Comment', QuestionTypeShortText::class, required: false), + ]); + + $result = $this->type->validateAnswer($question, [ + ['col_0' => '', 'col_1' => ''], + ]); + + $this->assertTrue($result->isValid()); + } + + public function testOptionalColumnEmptyIsValid(): void + { + $question = $this->makeTableQuestion([ + $this->column('Name', QuestionTypeShortText::class, required: false), + ]); + + $result = $this->type->validateAnswer($question, [ + ['col_0' => ''], + ]); + + $this->assertTrue($result->isValid()); + } + + public function testErrorMessageMentionsColumnName(): void + { + $question = $this->makeTableQuestion([ + $this->column('Serial number', QuestionTypeShortText::class, required: true), + ]); + + $result = $this->type->validateAnswer($question, [ + ['col_0' => '', 'col_1' => 'filler'], + ]); + + $errors = $result->getErrors(); + $this->assertCount(1, $errors); + $this->assertStringContainsString('Serial number', $errors[0]['message']); + } + + public function testNonArrayAnswerIsValid(): void + { + $question = $this->makeTableQuestion([ + $this->column('Name', QuestionTypeShortText::class, required: true), + ]); + + $result = $this->type->validateAnswer($question, 'not-an-array'); + + $this->assertTrue($result->isValid()); + } + + public function testMultipleMissingRequiredCellsProduceMultipleErrors(): void + { + $question = $this->makeTableQuestion([ + $this->column('A', QuestionTypeShortText::class, required: true), + $this->column('B', QuestionTypeShortText::class, required: true), + ]); + + // Row 1: A missing. Row 2: B missing. Row 3: entirely empty (ignored). + $result = $this->type->validateAnswer($question, [ + ['col_0' => '', 'col_1' => 'b1'], + ['col_0' => 'a2', 'col_1' => ''], + ['col_0' => '', 'col_1' => ''], + ]); + + $this->assertFalse($result->isValid()); + $this->assertCount(2, $result->getErrors()); + } + + public function testRequiredValidationCoversEveryCompatibleColumnType(): void + { + foreach (array_keys($this->type->getCompatibleQuestionTypes()) as $fqcn) { + $question = $this->makeTableQuestion([ + $this->column('Mandatory', $fqcn, required: true), + $this->column('Filler', QuestionTypeShortText::class, required: false), + ]); + + // Filler is filled so the row counts as non-empty, mandatory cell is empty. + $result = $this->type->validateAnswer($question, [ + ['col_0' => '', 'col_1' => 'filled'], + ]); + + $this->assertFalse( + $result->isValid(), + "Required validation should fail for column type {$fqcn}", + ); + } + } + + public function testAnswersHandlerReportsMissingRequiredColumn(): void + { + // The condition engine only validates questions visible to the current + // user, and plugin question types require authentication to be visible. + $this->login(); + $this->enableConfigurableItem($this->type); + + $builder = new FormBuilder('Validation form'); + $builder->addQuestion( + 'Table', + TableQuestion::class, + extra_data: json_encode(new TableQuestionConfig( + columns: [ + $this->column('Name', QuestionTypeShortText::class, required: true), + $this->column('Comment', QuestionTypeShortText::class, required: false), + ], + )), + ); + $form = $this->createForm($builder); + $question_id = $this->getQuestionId($form, 'Table'); + + // Row has data (the comment) but the mandatory "Name" cell is empty. + $result = AnswersHandler::getInstance()->validateAnswers($form, [ + $question_id => [['col_0' => '', 'col_1' => 'a comment']], + ]); + + $this->assertFalse($result->isValid()); + } + + /** + * @param array $columns + */ + private function makeTableQuestion(array $columns): Question + { + $this->enableConfigurableItem($this->type); + + $builder = new FormBuilder('Validation form'); + $builder->addQuestion( + 'Table', + TableQuestion::class, + extra_data: json_encode(new TableQuestionConfig(columns: $columns)), + ); + $form = $this->createForm($builder); + + return Question::getById($this->getQuestionId($form, 'Table')); + } + + /** + * @return array{name: string, question_type: string, required: bool, itemtype: string} + */ + private function column(string $name, string $fqcn, bool $required): array + { + return [ + TableQuestionConfig::COL_NAME => $name, + TableQuestionConfig::COL_QUESTION_TYPE => $fqcn, + TableQuestionConfig::COL_REQUIRED => $required, + TableQuestionConfig::COL_ITEMTYPE => '', + ]; + } +} From f393c446e780d03ea8782f5a12e665aa0e3c6267 Mon Sep 17 00:00:00 2001 From: RomainLvr Date: Wed, 24 Jun 2026 10:24:53 +0200 Subject: [PATCH 2/7] Remove CSS comments --- public/css/advancedforms.css | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/public/css/advancedforms.css b/public/css/advancedforms.css index d64ee45..bc9ddeb 100644 --- a/public/css/advancedforms.css +++ b/public/css/advancedforms.css @@ -34,12 +34,6 @@ display: none !important; } -/* - * GLPI's .select2-selection__rendered has line-height: ~42px + a ::before pseudo-element, - * making the total height ~80px while the container is ~40px with overflow:hidden. - * When the selection content is a flex span, it gets pushed below the visible area. - * Fix applied for both the admin column config panel and the end-user table cells. - */ [data-af-table-column] .select2-selection--single .select2-selection__rendered, [data-af-table-question] td .select2-selection--single .select2-selection__rendered { display: flex !important; @@ -53,19 +47,6 @@ display: none !important; } -/* - * Per-cell invalid state for select2 cells. - * - * Core (_form-renderer.scss) reddens EVERY select2 of a question as soon as one of - * its selects is .is-invalid, via a question-level selector: - * [data-glpi-form-renderer-question]:has(select.is-invalid) .select2-selection - * A table holds many selects inside a single question, so one invalid cell would turn - * every dropdown red. We re-scope the highlight to the offending cell: while the table - * has an invalid select, valid cells are reset to the default border, and only the cell - * that actually contains the invalid select keeps the red border. - * - * Plain inputs and checkboxes already get their red border from Bootstrap's .is-invalid. - */ [data-af-table-question]:has(select.is-invalid) td:not(:has(select.is-invalid)) .select2-container--default .select2-selection { border-color: var(--tblr-border-color) !important; } From 4b277321520967e083aa34582de43b598d70b608 Mon Sep 17 00:00:00 2001 From: RomainLvr Date: Wed, 24 Jun 2026 10:27:34 +0200 Subject: [PATCH 3/7] Update CHANGELOG --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 724738f..c077380 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] + +### Add + +- Add configurable table question type + ## [1.1.1] - 2026-05-27 ### Fixed From 645e6e4a8ac2f08047d0344db606b6d813843be1 Mon Sep 17 00:00:00 2001 From: RomainLvr Date: Wed, 24 Jun 2026 11:56:19 +0200 Subject: [PATCH 4/7] Update required column button style & remove global mandatory field --- public/css/advancedforms.css | 21 ++++++++++++++++ .../question_types/table_admin.html.twig | 2 +- .../question_types/table_config.html.twig | 24 ++++++++++++++----- .../TableQuestionIntegrationTest.php | 5 ++++ 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/public/css/advancedforms.css b/public/css/advancedforms.css index bc9ddeb..8525ffc 100644 --- a/public/css/advancedforms.css +++ b/public/css/advancedforms.css @@ -34,6 +34,10 @@ display: none !important; } +[data-glpi-form-editor-question]:has([data-af-table-admin]) label.form-check:has([data-glpi-form-editor-original-name="is_mandatory"]) { + display: none !important; +} + [data-af-table-column] .select2-selection--single .select2-selection__rendered, [data-af-table-question] td .select2-selection--single .select2-selection__rendered { display: flex !important; @@ -45,6 +49,23 @@ [data-af-table-column] .select2-selection--single .select2-selection__rendered::before, [data-af-table-question] td .select2-selection--single .select2-selection__rendered::before { display: none !important; +}Ptemp + +.af-required-toggle { + display: inline-flex; + align-items: center; + color: var(--tblr-secondary); +} +.af-required-toggle:hover { + color: var(--tblr-body-color); +} +.af-required-toggle:has(:checked) { + color: var(--tblr-danger); +} +.af-required-toggle:has(:focus-visible) { + outline: 2px solid var(--tblr-primary); + outline-offset: 2px; + border-radius: var(--tblr-border-radius); } [data-af-table-question]:has(select.is-invalid) td:not(:has(select.is-invalid)) .select2-container--default .select2-selection { diff --git a/templates/editor/question_types/table_admin.html.twig b/templates/editor/question_types/table_admin.html.twig index 40bd236..ce2bf7a 100644 --- a/templates/editor/question_types/table_admin.html.twig +++ b/templates/editor/question_types/table_admin.html.twig @@ -31,7 +31,7 @@ {% set has_columns = config.getColumns()|length > 0 %} -
+
{{ advanced_config|raw }}
diff --git a/templates/editor/question_types/table_config.html.twig b/templates/editor/question_types/table_config.html.twig index 40a53ad..a996d26 100644 --- a/templates/editor/question_types/table_config.html.twig +++ b/templates/editor/question_types/table_config.html.twig @@ -118,15 +118,21 @@
{% endfor %} -
{% endfor %} -