Skip to content

Commit 3f7b57d

Browse files
committed
feat(forms): Server side validation for mandatory questions
feat(tests): add validation tests for mandatory and optional form answers feat(tests): improve E2E tests and enchance accesbility fix: lint tests
1 parent b6c5bc5 commit 3f7b57d

File tree

8 files changed

+408
-41
lines changed

8 files changed

+408
-41
lines changed

css/includes/components/form/_form-renderer.scss

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,27 @@
111111
}
112112
}
113113
}
114+
115+
// Handle invalid states for specific inputs (select2 and tinymce)
116+
[data-glpi-form-renderer-question] {
117+
&:has(select.is-invalid) {
118+
.select2-container--default .select2-selection {
119+
border-color: var(--tblr-form-invalid-border-color) !important;
120+
}
121+
}
122+
123+
&:has(textarea.is-invalid) {
124+
.tox.tox-tinymce {
125+
border-color: var(--tblr-form-invalid-border-color) !important;
126+
}
127+
}
128+
129+
.invalid-tooltip {
130+
position: relative;
131+
background-color: unset;
132+
color: var(--tblr-form-invalid-color);
133+
padding: unset;
134+
margin-top: .25rem;
135+
}
136+
}
114137
}

js/modules/Forms/RendererController.js

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -134,39 +134,61 @@ export class GlpiFormRendererController
134134
$(this.#target).removeClass('pointer-events-none');
135135
}
136136

137-
#checkCurrentSectionValidity() {
138-
// Find all required inputs that are hidden and not already disabled.
139-
// They must be removed from the check as they are inputs from others
140-
// sections or input hidden by condition (thus they should not be
141-
// evaluated).
142-
// The easiest way to not evaluate these inputs is to disable them.
143-
const inputs = $(this.#target).find('[required]:hidden:not(:disabled)');
144-
for (const input of inputs) {
145-
input.disabled = true;
146-
}
137+
async #checkCurrentSectionValidity() {
138+
// Get the UUID of the current section
139+
const currentSectionElement = $(this.#target).find(`[data-glpi-form-renderer-section="${this.#section_index}"]`);
140+
const currentSectionUuid = currentSectionElement.data('glpi-form-renderer-uuid');
141+
142+
const response = await $.ajax({
143+
url: `${CFG_GLPI.root_doc}/Form/ValidateAnswers`,
144+
type: 'POST',
145+
data: `${$(this.#target).serialize() }&section_uuid=${currentSectionUuid}`,
146+
dataType: 'json',
147+
});
147148

148-
// Check validity and display browser feedback if needed.
149-
const is_valid = this.#target.checkValidity();
150-
if (!is_valid) {
151-
this.#target.reportValidity();
152-
}
149+
if (response.success === false) {
150+
// Remove previous error messages
151+
$(this.#target)
152+
.find(".invalid-tooltip")
153+
.remove();
154+
$(this.#target)
155+
.find(".is-invalid")
156+
.removeClass("is-invalid");
157+
158+
Object.values(response.errors).forEach(error => {
159+
// Highlight the field with error
160+
const question = $(`[data-glpi-form-renderer-id="${error.question_id}"][data-glpi-form-renderer-question]`);
161+
if (question.length) {
162+
// Find the input field within the question
163+
const inputField = question.find('input:not([data-uploader-name]), select, textarea');
164+
if (inputField.length) {
165+
// Generate a unique ID for the error message
166+
const errorId = `error-${error.question_id}`;
167+
168+
// Add validation classes and accessibility attributes
169+
inputField
170+
.addClass('is-invalid')
171+
.attr('aria-invalid', 'true')
172+
.attr('aria-errormessage', errorId);
173+
174+
// Add a tooltip with the error message
175+
inputField.parent().append(
176+
`<span id="${errorId}" class="invalid-tooltip">${error.message}</span>`
177+
);
178+
}
179+
}
180+
});
153181

154-
// Revert disabled inputs
155-
for (const input of inputs) {
156-
input.disabled = false;
182+
return false;
157183
}
158184

159-
return is_valid;
185+
return true;
160186
}
161187

162188
/**
163189
* Submit the target form using an AJAX request.
164190
*/
165191
async #submitForm() {
166-
if (!this.#checkCurrentSectionValidity()) {
167-
return;
168-
}
169-
170192
// Form will be sumitted using an AJAX request instead
171193
try {
172194
// Update tinymce values
@@ -176,6 +198,10 @@ export class GlpiFormRendererController
176198
});
177199
}
178200

201+
if (!await this.#checkCurrentSectionValidity()) {
202+
return;
203+
}
204+
179205
// Submit form using AJAX
180206
const response = await $.post({
181207
url: $(this.#target).prop("action"),
@@ -217,8 +243,8 @@ export class GlpiFormRendererController
217243
/**
218244
* Go to the next section of the form.
219245
*/
220-
#goToNextSection() {
221-
if (!this.#checkCurrentSectionValidity()) {
246+
async #goToNextSection() {
247+
if (!await this.#checkCurrentSectionValidity()) {
222248
return;
223249
}
224250

phpunit/functional/Glpi/Form/AnswersHandler/AnswersHandlerTest.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,65 @@ public function testSaveAnswers(): void
164164
);
165165
}
166166

167+
public function testValidateAnswers(): void
168+
{
169+
self::login();
170+
171+
// Create a form with both mandatory and optional questions
172+
$builder = new FormBuilder("Validation Test Form");
173+
$builder
174+
->addQuestion("Mandatory Name", QuestionTypeShortText::class, is_mandatory: true)
175+
->addQuestion("Mandatory Email", QuestionTypeEmail::class, is_mandatory: true)
176+
->addQuestion("Optional Comment", QuestionTypeLongText::class, is_mandatory: false)
177+
;
178+
$form = self::createForm($builder);
179+
180+
// Get handler instance
181+
$handler = AnswersHandler::getInstance();
182+
183+
// Test 1: All mandatory fields are filled - should be valid
184+
$complete_answers = [
185+
self::getQuestionId($form, "Mandatory Name") => "John Doe",
186+
self::getQuestionId($form, "Mandatory Email") => "john.doe@example.com",
187+
self::getQuestionId($form, "Optional Comment") => "This is an optional comment",
188+
];
189+
$result = $handler->validateAnswers($form, $complete_answers);
190+
$this->assertTrue($result->isValid(), "Validation should pass when all mandatory fields are filled");
191+
$this->assertCount(0, $result->getErrors(), "There should be no errors when all mandatory fields are filled");
192+
193+
// Test 2: Missing one mandatory field - should be invalid
194+
$missing_name_answers = [
195+
self::getQuestionId($form, "Mandatory Email") => "john.doe@example.com",
196+
self::getQuestionId($form, "Optional Comment") => "This is an optional comment",
197+
];
198+
$result = $handler->validateAnswers($form, $missing_name_answers);
199+
$this->assertFalse($result->isValid(), "Validation should fail when a mandatory field is missing");
200+
$this->assertCount(1, $result->getErrors(), "There should be one error when one mandatory field is missing");
201+
202+
// Test 3: Missing all mandatory fields - should be invalid with multiple errors
203+
$only_optional_answers = [
204+
self::getQuestionId($form, "Optional Comment") => "This is an optional comment",
205+
];
206+
$result = $handler->validateAnswers($form, $only_optional_answers);
207+
$this->assertFalse($result->isValid(), "Validation should fail when all mandatory fields are missing");
208+
$this->assertCount(2, $result->getErrors(), "There should be two errors when both mandatory fields are missing");
209+
210+
// Test 4: Empty answers - should be invalid with multiple errors
211+
$empty_answers = [];
212+
$result = $handler->validateAnswers($form, $empty_answers);
213+
$this->assertFalse($result->isValid(), "Validation should fail when answers are empty");
214+
$this->assertCount(2, $result->getErrors(), "There should be two errors when answers are empty");
215+
216+
// Test 5: Empty string in mandatory field - should be invalid
217+
$empty_string_answers = [
218+
self::getQuestionId($form, "Mandatory Name") => "",
219+
self::getQuestionId($form, "Mandatory Email") => "john.doe@example.com",
220+
];
221+
$result = $handler->validateAnswers($form, $empty_string_answers);
222+
$this->assertFalse($result->isValid(), "Validation should fail when a mandatory field contains an empty string");
223+
$this->assertCount(1, $result->getErrors(), "There should be one error when a mandatory field contains an empty string");
224+
}
225+
167226
private function validateAnswers(
168227
Form $form,
169228
array $answers,

src/Glpi/Controller/Form/SubmitAnswerController.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,12 @@ private function saveSubmittedAnswers(
109109
}
110110

111111
$handler = AnswersHandler::getInstance();
112+
113+
// Check if answers are valid
114+
if (!$handler->validateAnswers($form, $answers)->isValid()) {
115+
throw new BadRequestHttpException();
116+
}
117+
112118
$answers = $handler->saveAnswers(
113119
$form,
114120
$answers,
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<?php
2+
3+
/**
4+
* ---------------------------------------------------------------------
5+
*
6+
* GLPI - Gestionnaire Libre de Parc Informatique
7+
*
8+
* http://glpi-project.org
9+
*
10+
* @copyright 2015-2025 Teclib' and contributors.
11+
* @licence https://www.gnu.org/licenses/gpl-3.0.html
12+
*
13+
* ---------------------------------------------------------------------
14+
*
15+
* LICENSE
16+
*
17+
* This file is part of GLPI.
18+
*
19+
* This program is free software: you can redistribute it and/or modify
20+
* it under the terms of the GNU General Public License as published by
21+
* the Free Software Foundation, either version 3 of the License, or
22+
* (at your option) any later version.
23+
*
24+
* This program is distributed in the hope that it will be useful,
25+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
26+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
27+
* GNU General Public License for more details.
28+
*
29+
* You should have received a copy of the GNU General Public License
30+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
31+
*
32+
* ---------------------------------------------------------------------
33+
*/
34+
35+
namespace Glpi\Controller\Form;
36+
37+
use Glpi\Controller\AbstractController;
38+
use Glpi\Controller\Form\Utils\CanCheckAccessPolicies;
39+
use Glpi\Exception\Http\BadRequestHttpException;
40+
use Glpi\Exception\Http\NotFoundHttpException;
41+
use Glpi\Form\AnswersHandler\AnswersHandler;
42+
use Glpi\Form\EndUserInputNameProvider;
43+
use Glpi\Form\Form;
44+
use Glpi\Form\Section;
45+
use Glpi\Form\ValidationResult;
46+
use Glpi\Http\Firewall;
47+
use Glpi\Security\Attribute\SecurityStrategy;
48+
use Symfony\Component\HttpFoundation\JsonResponse;
49+
use Symfony\Component\HttpFoundation\Request;
50+
use Symfony\Component\HttpFoundation\Response;
51+
use Symfony\Component\Routing\Attribute\Route;
52+
53+
final class ValidateAnswerController extends AbstractController
54+
{
55+
use CanCheckAccessPolicies;
56+
57+
#[SecurityStrategy(Firewall::STRATEGY_NO_CHECK)] // Some forms can be accessed anonymously
58+
#[Route(
59+
"/Form/ValidateAnswers",
60+
name: "glpi_form_validate_answers",
61+
methods: "POST"
62+
)]
63+
public function __invoke(Request $request): Response
64+
{
65+
$form = $this->loadSubmittedForm($request);
66+
$section = $this->loadSubmittedSection($request);
67+
$this->checkFormAccessPolicies($form, $request);
68+
69+
$validation_result = $this->checkSubmittedAnswersValidation($section, $request);
70+
return new JsonResponse([
71+
'success' => $validation_result->isValid(),
72+
'errors' => $validation_result->getErrors(),
73+
]);
74+
}
75+
76+
private function loadSubmittedForm(Request $request): Form
77+
{
78+
$forms_id = $request->request->getInt("forms_id");
79+
if (!$forms_id) {
80+
throw new BadRequestHttpException();
81+
}
82+
83+
$form = Form::getById($forms_id);
84+
if (!$form) {
85+
throw new NotFoundHttpException();
86+
}
87+
88+
return $form;
89+
}
90+
91+
private function loadSubmittedSection(Request $request): Section
92+
{
93+
$section_uuid = $request->request->getString("section_uuid");
94+
if (!$section_uuid) {
95+
throw new BadRequestHttpException();
96+
}
97+
98+
$section = Section::getByUuid($section_uuid);
99+
if (!$section) {
100+
throw new NotFoundHttpException();
101+
}
102+
103+
return $section;
104+
}
105+
106+
private function checkSubmittedAnswersValidation(
107+
Form|Section $questions_container,
108+
Request $request
109+
): ValidationResult {
110+
$post = $request->request->all();
111+
$provider = new EndUserInputNameProvider();
112+
113+
$answers = $provider->getAnswers($post);
114+
if (empty($answers)) {
115+
throw new BadRequestHttpException();
116+
}
117+
118+
$handler = AnswersHandler::getInstance();
119+
return $handler->validateAnswers($questions_container, $answers);
120+
}
121+
}

src/Glpi/Form/AnswersHandler/AnswersHandler.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444
use Glpi\Form\Destination\AnswersSet_FormDestinationItem;
4545
use Glpi\Form\Destination\FormDestination;
4646
use Glpi\Form\Form;
47+
use Glpi\Form\Section;
48+
use Glpi\Form\ValidationResult;
4749

4850
/**
4951
* Helper class to handle raw answers data
@@ -75,6 +77,35 @@ public static function getInstance(): AnswersHandler
7577
return self::$instance;
7678
}
7779

80+
/**
81+
* Check if the given answers are valid for the given form
82+
*
83+
* @param Form|Section $questions_container The form or section to check
84+
* @param array $answers The answers to check
85+
* @return ValidationResult The validation result
86+
*/
87+
public function validateAnswers(
88+
Form|Section $questions_container,
89+
array $answers
90+
): ValidationResult {
91+
$result = new ValidationResult();
92+
93+
// Check if mandatory questions are answered
94+
$mandatory_questions = array_filter(
95+
$questions_container->getQuestions(),
96+
static fn($question) => $question->fields['is_mandatory']
97+
);
98+
99+
foreach ($mandatory_questions as $question) {
100+
// Check if the question is not answered (empty or not set)
101+
if (empty($answers[$question->getID()])) {
102+
$result->addError($question, __('This field is mandatory'));
103+
}
104+
}
105+
106+
return $result;
107+
}
108+
78109
/**
79110
* Saves the given answers of a given form into an AnswersSet object and
80111
* create destinations objects.

0 commit comments

Comments
 (0)