Skip to content

Commit f738b04

Browse files
committed
feat(forms): Validation conditions for question answers
fix(forms): unable to add conditions to an unsaved question Apply suggestions from code review refactor(tests): Refactor form answers validation related test methods to implement dataprovider feat(tests): add test cases to handle validation conditions feat(tests): add E2E tests
1 parent a3425b3 commit f738b04

29 files changed

+1963
-131
lines changed

.phpstan-baseline.php

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4831,12 +4831,6 @@
48314831
'count' => 1,
48324832
'path' => __DIR__ . '/src/Glpi/Form/AnswersSet.php',
48334833
];
4834-
$ignoreErrors[] = [
4835-
'message' => '#^Function json_decode is unsafe to use\\. It can return FALSE instead of throwing an exception\\. Please add \'use function Safe\\\\json_decode;\' at the beginning of the file to use the variant provided by the \'thecodingmachine/safe\' library\\.$#',
4836-
'identifier' => 'theCodingMachineSafe.function',
4837-
'count' => 1,
4838-
'path' => __DIR__ . '/src/Glpi/Form/Comment.php',
4839-
];
48404834
$ignoreErrors[] = [
48414835
'message' => '#^Function json_encode is unsafe to use\\. It can return FALSE instead of throwing an exception\\. Please add \'use function Safe\\\\json_encode;\' at the beginning of the file to use the variant provided by the \'thecodingmachine/safe\' library\\.$#',
48424836
'identifier' => 'theCodingMachineSafe.function',
@@ -4912,7 +4906,7 @@
49124906
$ignoreErrors[] = [
49134907
'message' => '#^Function json_decode is unsafe to use\\. It can return FALSE instead of throwing an exception\\. Please add \'use function Safe\\\\json_decode;\' at the beginning of the file to use the variant provided by the \'thecodingmachine/safe\' library\\.$#',
49144908
'identifier' => 'theCodingMachineSafe.function',
4915-
'count' => 2,
4909+
'count' => 1,
49164910
'path' => __DIR__ . '/src/Glpi/Form/Destination/FormDestination.php',
49174911
];
49184912
$ignoreErrors[] = [
@@ -4948,7 +4942,7 @@
49484942
$ignoreErrors[] = [
49494943
'message' => '#^Function json_decode is unsafe to use\\. It can return FALSE instead of throwing an exception\\. Please add \'use function Safe\\\\json_decode;\' at the beginning of the file to use the variant provided by the \'thecodingmachine/safe\' library\\.$#',
49504944
'identifier' => 'theCodingMachineSafe.function',
4951-
'count' => 3,
4945+
'count' => 2,
49524946
'path' => __DIR__ . '/src/Glpi/Form/Form.php',
49534947
];
49544948
$ignoreErrors[] = [
@@ -4972,13 +4966,13 @@
49724966
$ignoreErrors[] = [
49734967
'message' => '#^Function json_decode is unsafe to use\\. It can return FALSE instead of throwing an exception\\. Please add \'use function Safe\\\\json_decode;\' at the beginning of the file to use the variant provided by the \'thecodingmachine/safe\' library\\.$#',
49744968
'identifier' => 'theCodingMachineSafe.function',
4975-
'count' => 4,
4969+
'count' => 3,
49764970
'path' => __DIR__ . '/src/Glpi/Form/Question.php',
49774971
];
49784972
$ignoreErrors[] = [
49794973
'message' => '#^Function json_encode is unsafe to use\\. It can return FALSE instead of throwing an exception\\. Please add \'use function Safe\\\\json_encode;\' at the beginning of the file to use the variant provided by the \'thecodingmachine/safe\' library\\.$#',
49804974
'identifier' => 'theCodingMachineSafe.function',
4981-
'count' => 3,
4975+
'count' => 5,
49824976
'path' => __DIR__ . '/src/Glpi/Form/Question.php',
49834977
];
49844978
$ignoreErrors[] = [
@@ -5053,12 +5047,6 @@
50535047
'count' => 4,
50545048
'path' => __DIR__ . '/src/Glpi/Form/QuestionType/QuestionTypesManager.php',
50555049
];
5056-
$ignoreErrors[] = [
5057-
'message' => '#^Function json_decode is unsafe to use\\. It can return FALSE instead of throwing an exception\\. Please add \'use function Safe\\\\json_decode;\' at the beginning of the file to use the variant provided by the \'thecodingmachine/safe\' library\\.$#',
5058-
'identifier' => 'theCodingMachineSafe.function',
5059-
'count' => 1,
5060-
'path' => __DIR__ . '/src/Glpi/Form/Section.php',
5061-
];
50625050
$ignoreErrors[] = [
50635051
'message' => '#^Function json_encode is unsafe to use\\. It can return FALSE instead of throwing an exception\\. Please add \'use function Safe\\\\json_encode;\' at the beginning of the file to use the variant provided by the \'thecodingmachine/safe\' library\\.$#',
50645052
'identifier' => 'theCodingMachineSafe.function',

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ input.value-selector {
394394
}
395395
}
396396

397-
.visibility-dropdown-card {
397+
.visibility-dropdown-card, .validation-dropdown-card {
398398
min-width: 700px;
399399
width: fit-content;
400400

install/migrations/update_10.0.x_to_11.0.0/form.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,28 @@
355355
);
356356
}
357357

358+
if (!$DB->fieldExists('glpi_forms_questions', 'validation_strategy')) {
359+
$migration->addField(
360+
'glpi_forms_questions',
361+
'validation_strategy',
362+
"varchar(30) NOT NULL DEFAULT ''",
363+
[
364+
'after' => 'conditions',
365+
]
366+
);
367+
}
368+
369+
if (!$DB->fieldExists('glpi_forms_questions', 'validation_conditions')) {
370+
$migration->addField(
371+
'glpi_forms_questions',
372+
'validation_conditions',
373+
"JSON NOT NULL",
374+
[
375+
'after' => 'validation_strategy',
376+
]
377+
);
378+
}
379+
358380
// Add rights for the forms object
359381
$migration->addRight("form", ALLSTANDARDRIGHT, ['config' => UPDATE]);
360382

install/mysql/glpi-empty.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9555,6 +9555,8 @@ CREATE TABLE `glpi_forms_questions` (
95559555
`extra_data` text COMMENT 'JSON - Extra configuration field(s) depending on the questions type',
95569556
`visibility_strategy` varchar(30) NOT NULL DEFAULT '',
95579557
`conditions` JSON NOT NULL,
9558+
`validation_strategy` varchar(30) NOT NULL DEFAULT '',
9559+
`validation_conditions` JSON NOT NULL,
95589560
PRIMARY KEY (`id`),
95599561
UNIQUE KEY `uuid` (`uuid`),
95609562
KEY `name` (`name`),
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
/**
2+
* ---------------------------------------------------------------------
3+
*
4+
* GLPI - Gestionnaire Libre de Parc Informatique
5+
*
6+
* http://glpi-project.org
7+
*
8+
* @copyright 2015-2025 Teclib' and contributors.
9+
* @licence https://www.gnu.org/licenses/gpl-3.0.html
10+
*
11+
* ---------------------------------------------------------------------
12+
*
13+
* LICENSE
14+
*
15+
* This file is part of GLPI.
16+
*
17+
* This program is free software: you can redistribute it and/or modify
18+
* it under the terms of the GNU General Public License as published by
19+
* the Free Software Foundation, either version 3 of the License, or
20+
* (at your option) any later version.
21+
*
22+
* This program is distributed in the hope that it will be useful,
23+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
24+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
25+
* GNU General Public License for more details.
26+
*
27+
* You should have received a copy of the GNU General Public License
28+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
29+
*
30+
* ---------------------------------------------------------------------
31+
*/
32+
33+
34+
export class GlpiFormConditionValidationEditorController
35+
{
36+
/**
37+
* Target containerthat will display the condition editor
38+
* @type {HTMLElement}
39+
*/
40+
#container;
41+
42+
/**
43+
* Known form sections
44+
* @type {array<{uuid: string, name: string}>}
45+
*/
46+
#form_sections;
47+
48+
/**
49+
* Known form questions
50+
* @type {array<{uuid: string, name: string, type: string, extra_data: object}>}
51+
*/
52+
#form_questions;
53+
54+
/**
55+
* Known form comments
56+
* @type {array<{uuid: string, name: string}>}
57+
*/
58+
#form_comments;
59+
60+
/** @type {?string} */
61+
#item_uuid;
62+
63+
/** @type {?string} */
64+
#item_type;
65+
66+
constructor(container, item_uuid, item_type, forms_sections, form_questions, form_comments)
67+
{
68+
this.#container = container;
69+
if (this.#container.dataset.glpiConditionsEditorContainer === undefined) {
70+
console.error(this.#container); // Help debugging by printing the node.
71+
throw new Error("Invalid container");
72+
}
73+
74+
// Load item on which the condition will be defined
75+
this.#item_uuid = item_uuid;
76+
this.#item_type = item_type;
77+
78+
// Load form sections
79+
this.#form_sections = forms_sections;
80+
81+
// Load linked form questions
82+
this.#form_questions = form_questions;
83+
this.#initEventHandlers();
84+
85+
// Load linked form comments
86+
this.#form_comments = form_comments;
87+
88+
// Enable actions
89+
const disabled_items = this.#container.querySelectorAll(
90+
'[data-glpi-conditions-editor-enable-on-ready]'
91+
);
92+
for (const disabled_item of disabled_items) {
93+
disabled_item.removeAttribute('disabled');
94+
}
95+
}
96+
97+
async renderEditor()
98+
{
99+
const data = this.#computeData();
100+
await this.#doRenderEditor(data);
101+
}
102+
103+
/**
104+
* In a dynamic environement such as the form editor, it might be necessary
105+
* to redefine the known list of available sections.
106+
*/
107+
setFormSections(form_sections)
108+
{
109+
this.#form_sections = form_sections;
110+
}
111+
112+
/**
113+
* In a dynamic environement such as the form editor, it might be necessary
114+
* to redefine the known list of available questions.
115+
*/
116+
setFormQuestions(form_questions)
117+
{
118+
this.#form_questions = form_questions;
119+
}
120+
121+
/**
122+
* In a dynamic environement such as the form editor, it might be necessary
123+
* to redefine the known list of available comments.
124+
*/
125+
setFormComments(form_comments)
126+
{
127+
this.#form_comments = form_comments;
128+
}
129+
130+
async #doRenderEditor(data)
131+
{
132+
const url = `${CFG_GLPI.root_doc}/Form/Condition/Validation/Editor`;
133+
const content = await $.post(url, {
134+
form_data: data,
135+
});
136+
137+
// Note: must use `$().html` to make sure we trigger scripts
138+
$(this.#container.querySelector('[data-glpi-conditions-editor]')).html(content);
139+
}
140+
141+
#initEventHandlers()
142+
{
143+
// Handle add and delete conditions
144+
this.#container.addEventListener('click', (e) => {
145+
const target = e.target;
146+
147+
148+
// Available buttons
149+
const add_condition = '[data-glpi-condition-editor-add-condition]';
150+
const delete_condition = '[data-glpi-condition-editor-delete-condition]';
151+
152+
if (target.closest(add_condition) !== null) {
153+
this.#addNewEmptyCondition();
154+
return;
155+
} else if (target.closest(delete_condition) !== null) {
156+
const index = target
157+
.closest('[data-glpi-conditions-editor-condition]')
158+
.dataset
159+
.glpiConditionsEditorConditionIndex
160+
;
161+
this.#deleteCondition(index);
162+
}
163+
});
164+
165+
// Handle change on selected condition items
166+
// Note: need to be jquery else select2 wont work
167+
$(this.#container).on(
168+
'change',
169+
'[data-glpi-conditions-editor-item], [data-glpi-conditions-editor-value-operator]',
170+
() => this.renderEditor()
171+
);
172+
173+
// Handle strategy changes
174+
const strategy_inputs = this.#container.querySelectorAll(
175+
'[data-glpi-conditions-editor-strategy]'
176+
);
177+
for (const strategy_input of strategy_inputs) {
178+
strategy_input.addEventListener('change', (e) => {
179+
const value = e.target.value;
180+
const should_displayed_editor = (this.#container
181+
.querySelector(`[data-glpi-conditions-editor-display-for-${value}]`)
182+
) !== null;
183+
this.#container
184+
.querySelector(`[data-glpi-conditions-editor]`)
185+
.classList
186+
.toggle('d-none', !should_displayed_editor)
187+
;
188+
const event = new CustomEvent("updated_strategy", {
189+
detail: {
190+
container: this.#container,
191+
strategy: value,
192+
}
193+
});
194+
document.dispatchEvent(event);
195+
});
196+
}
197+
}
198+
199+
async #addNewEmptyCondition()
200+
{
201+
const data = this.#computeData();
202+
data.conditions.push({'item': ''});
203+
await this.#doRenderEditor(data);
204+
}
205+
206+
async #deleteCondition(condition_index)
207+
{
208+
const data = this.#computeData();
209+
data.conditions = data.conditions.filter((_condition, index) => {
210+
return index != condition_index;
211+
});
212+
await this.#doRenderEditor(data);
213+
}
214+
215+
#computeData()
216+
{
217+
return {
218+
sections: this.#form_sections,
219+
questions: this.#form_questions,
220+
comments: this.#form_comments,
221+
conditions: this.#computeDefinedConditions(),
222+
selected_item_uuid: this.#item_uuid,
223+
selected_item_type: this.#item_type,
224+
};
225+
}
226+
227+
#computeDefinedConditions()
228+
{
229+
const conditions_data = [];
230+
const conditions = this.#container.querySelectorAll(
231+
'[data-glpi-conditions-editor-condition]'
232+
);
233+
234+
for (const condition of conditions) {
235+
const condition_data = {};
236+
237+
// Try to find a selected logic operator
238+
const condition_logic_operator = $(condition).find(
239+
'[data-glpi-conditions-editor-logic-operator]'
240+
);
241+
if (condition_logic_operator.length > 0) {
242+
condition_data.logic_operator = condition_logic_operator.val();
243+
}
244+
245+
// Try to find a selected item
246+
const condition_item = $(condition).find(
247+
'[data-glpi-conditions-editor-item]'
248+
);
249+
if (condition_item.length > 0) {
250+
condition_data.item = condition_item.val();
251+
}
252+
253+
// Try to find a selected value operator
254+
const condition_value_operator = $(condition).find(
255+
'[data-glpi-conditions-editor-value-operator]'
256+
);
257+
if (condition_value_operator.length > 0) {
258+
condition_data.value_operator = condition_value_operator.val();
259+
}
260+
261+
// Try to find a selected value
262+
const condition_value = $(condition).find(
263+
'[data-glpi-conditions-editor-value]'
264+
);
265+
if (condition_value.length === 1) {
266+
condition_data.value = condition_value.val();
267+
} else if (condition_value.length > 1) {
268+
condition_data.value = {};
269+
condition_value.each((index, element) => {
270+
const name_parts = element.name.split(/[[\]]+/);
271+
const last_part = name_parts[name_parts.length - 2]; // Get the last non-empty part
272+
condition_data.value[last_part] = element.value;
273+
});
274+
}
275+
276+
conditions_data.push(condition_data);
277+
}
278+
279+
return conditions_data;
280+
}
281+
}

0 commit comments

Comments
 (0)