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 diff --git a/public/css/advancedforms.css b/public/css/advancedforms.css index 866d883..2ba303c 100644 --- a/public/css/advancedforms.css +++ b/public/css/advancedforms.css @@ -33,3 +33,44 @@ [data-glpi-form-editor-active-question] [data-ldap-question-preview] { 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; + 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; +} + +.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 { + 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..a8f360f --- /dev/null +++ b/tests/Model/QuestionType/TableQuestionConfigTest.php @@ -0,0 +1,94 @@ +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()); + } + + public function testJsonDeserializeEnforcesMaxRowNotLessThanMin(): void + { + $config = TableQuestionConfig::jsonDeserialize(['min_rows' => 10, 'max_rows' => 5]); + $this->assertSame(10, $config->getMaxRows()); + } +} diff --git a/tests/Model/QuestionType/TableQuestionIntegrationTest.php b/tests/Model/QuestionType/TableQuestionIntegrationTest.php new file mode 100644 index 0000000..151f88b --- /dev/null +++ b/tests/Model/QuestionType/TableQuestionIntegrationTest.php @@ -0,0 +1,119 @@ + '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 + { + // The admin preview marker lets plugin CSS hide the question-level + // "Mandatory" toggle (required is handled per column instead). + $question = $html->filter('[data-glpi-form-editor-question]')->first(); + $this->assertGreaterThan(0, $question->filter('[data-af-table-admin]')->count()); + + // 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..4a314da --- /dev/null +++ b/tests/Model/QuestionType/TableQuestionTest.php @@ -0,0 +1,212 @@ +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()); + } + + public function testValidateExtraDataInputAcceptsMaxRows50(): void + { + $result = $this->type->validateExtraDataInput([ + 'columns' => [['name' => 'A', 'question_type' => QuestionTypeShortText::class]], + 'min_rows' => 1, + 'max_rows' => 50, + ]); + $this->assertTrue($result); + } + + public function testValidateExtraDataInputRejectsMaxRowsAbove50(): void + { + $result = $this->type->validateExtraDataInput([ + 'columns' => [['name' => 'A', 'question_type' => QuestionTypeShortText::class]], + 'min_rows' => 1, + 'max_rows' => 51, + ]); + $this->assertFalse($result); + } + + public function testValidateExtraDataInputRejectsCraftedLargeMaxRows(): void + { + $result = $this->type->validateExtraDataInput([ + 'columns' => [['name' => 'A', 'question_type' => QuestionTypeShortText::class]], + 'min_rows' => 1, + 'max_rows' => 99999, + ]); + $this->assertFalse($result); + } + + public function testPrepareExtraDataClampsMaxRowsTo50(): void + { + $result = $this->type->prepareExtraData([ + 'columns' => [['name' => 'A', 'question_type' => QuestionTypeShortText::class, 'required' => false]], + 'min_rows' => 1, + 'max_rows' => 99999, + ]); + $this->assertSame(50, $result[TableQuestionConfig::MAX_ROWS]); + } +} 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 => '', + ]; + } +} diff --git a/tests/e2e/fixtures/advancedforms_fixture.ts b/tests/e2e/fixtures/advancedforms_fixture.ts new file mode 100644 index 0000000..04d657d --- /dev/null +++ b/tests/e2e/fixtures/advancedforms_fixture.ts @@ -0,0 +1,34 @@ +/** + * ------------------------------------------------------------------------- + * 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 + * ------------------------------------------------------------------------- + */ + +// Re-export the core GLPI fixture so plugin specs share the same authenticated +// `test`/`expect` and the worker/profile/api helpers. +export * from '../../../../../tests/e2e/fixtures/glpi_fixture'; diff --git a/tests/e2e/pages/AdvancedFormsTablePage.ts b/tests/e2e/pages/AdvancedFormsTablePage.ts new file mode 100644 index 0000000..a8efd39 --- /dev/null +++ b/tests/e2e/pages/AdvancedFormsTablePage.ts @@ -0,0 +1,104 @@ +/** + * ------------------------------------------------------------------------- + * 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 + * ------------------------------------------------------------------------- + */ + +import { Locator, Page, expect } from '@playwright/test'; +import { GlpiPage } from '../../../../../tests/e2e/pages/GlpiPage'; + +/** + * Page helpers for the "Table" question type: enabling it, configuring its + * columns in the editor and interacting with the rendered end-user table. + */ +export class AdvancedFormsTablePage extends GlpiPage { + private static readonly CONFIG_TAB = + 'GlpiPlugin\\Advancedforms\\Model\\Config\\ConfigTab$1'; + + public constructor(page: Page) { + super(page); + } + + /** Enables the Table question type from the plugin configuration page. */ + public async enableTableQuestionType(): Promise { + await this.page.goto( + `/front/config.form.php?forcetab=${AdvancedFormsTablePage.CONFIG_TAB}`, + ); + + const card = this.page + .locator('[data-testid^="feature-"]') + .filter({ hasText: 'Table question type' }); + const toggle = card.getByTestId('feature-toggle'); + + if (!(await toggle.isChecked())) { + await toggle.check(); + await this.getButton('Save').click(); + await expect(card.getByTestId('feature-toggle')).toBeChecked(); + } + } + + /** Opens the column configuration dropdown of a table question in the editor. */ + public async openColumnConfig(question: Locator): Promise { + await question.getByRole('button', { name: 'Configure table columns' }).click(); + await question.locator('[data-af-table-columns-container]').waitFor({ state: 'visible' }); + } + + /** + * Adds a column in the (already open) configuration dropdown. + * `type` is the visible label of a compatible question type (e.g. "Text"). + */ + public async addColumn( + question: Locator, + column: { name: string; type: string; required?: boolean }, + ): Promise { + await question.locator('[data-af-table-column-add]').click(); + + const row = question.locator('[data-af-table-column]').last(); + await row.getByPlaceholder('Column name').fill(column.name); + + // Column type is a select2-enhanced