Skip to content

Commit 3aea864

Browse files
committed
WIP: Conditional Logic
This is a WIP implementation of Conditional Logic. It is not well tested, and I would appreciate review and testing by others to see if it works as intended. I have created a few forms and clicked around to the best of my abilities, but I have implemented the fix for the benefit of others that need it and have no real use case myself to test with. Fixes: #358 Signed-off-by: Micke Nordin <kano@sunet.se>
1 parent 731f674 commit 3aea864

21 files changed

+3983
-99
lines changed

docs/DataStructure.md

Lines changed: 188 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<!--
2-
- SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
2+
- SPDX-FileCopyrightText: 2021-2026 Nextcloud GmbH and Nextcloud contributors
33
- SPDX-License-Identifier: AGPL-3.0-only
44
-->
55

@@ -227,6 +227,7 @@ Currently supported Question-Types are:
227227
| `file` | One or multiple files. It is possible to specify which mime types are allowed |
228228
| `linearscale` | A linear or Likert scale question where you choose an option that best fits your opinion |
229229
| `color` | A color answer, hex string representation (e. g. `#123456`) |
230+
| `conditional` | A conditional branching question with a trigger question and multiple branches containing subquestions |
230231

231232
## Extra Settings
232233

@@ -254,3 +255,189 @@ Optional extra settings for some [Question Types](#question-types)
254255
| `optionsHighest` | `linearscale` | Integer | `2, 3, 4, 5, 6, 7, 8, 9, 10` | Set the highest value of the scale, default: `5` |
255256
| `optionsLabelLowest` | `linearscale` | string | - | Set the label of the lowest value, default: `'Strongly disagree'` |
256257
| `optionsLabelHighest` | `linearscale` | string | - | Set the label of the highest value, default: `'Strongly agree'` |
258+
| `triggerType` | `conditional` | string | [See trigger types](#conditional-trigger-types) | The type of trigger question (dropdown, multiple_unique, etc.) |
259+
| `branches` | `conditional` | Array | Array of [Branch objects](#branch-object) | The branches with conditions and subquestions |
260+
261+
## Conditional Questions
262+
263+
Conditional questions enable branching logic in forms. A trigger question determines which branch of subquestions appears based on the respondent's answer.
264+
265+
### Question Properties for Subquestions
266+
267+
Subquestions (questions belonging to a conditional question's branch) have additional properties:
268+
269+
| Property | Type | Description |
270+
| ---------------- | ------- | -------------------------------------------------------- |
271+
| parentQuestionId | Integer | The ID of the parent conditional question (null for regular questions) |
272+
| branchId | String | The ID of the branch this subquestion belongs to |
273+
274+
### Conditional Trigger Types
275+
276+
Supported trigger types for conditional questions:
277+
278+
| Trigger Type | Condition Type | Description |
279+
| ----------------- | -------------------- | ------------------------------------------------ |
280+
| `multiple_unique` | `option_selected` | Radio buttons - single option selection |
281+
| `dropdown` | `option_selected` | Dropdown - single option selection |
282+
| `multiple` | `options_combination`| Checkboxes - all specified options must be selected |
283+
| `short` | `string_equals`, `string_contains`, `regex` | Short text with string/regex matching |
284+
| `long` | `string_contains`, `regex` | Long text with string/regex matching |
285+
| `linearscale` | `value_equals`, `value_range` | Linear scale with value matching |
286+
| `date` | `date_range` | Date with date range matching (YYYY-MM-DD) |
287+
| `time` | `time_range` | Time with time range matching (HH:mm) |
288+
| `color` | `value_equals` | Color with exact value matching |
289+
| `file` | `file_uploaded` | File with upload status matching |
290+
291+
### Branch Object
292+
293+
A branch defines conditions and subquestions that appear when those conditions are met.
294+
295+
| Property | Type | Description |
296+
| ------------ | ------------------------------------------- | --------------------------------------------- |
297+
| id | String | Unique identifier for the branch |
298+
| conditions | Array of [Conditions](#condition-object) | Conditions that must be met to show the branch|
299+
| subQuestions | Array of [Questions](#question) | Questions shown when conditions are met |
300+
301+
```json
302+
{
303+
"id": "branch-1705587600000",
304+
"conditions": [
305+
{ "type": "option_selected", "optionId": 42 }
306+
],
307+
"subQuestions": [
308+
{
309+
"id": 101,
310+
"formId": 3,
311+
"order": 1,
312+
"type": "short",
313+
"text": "Please provide details",
314+
"parentQuestionId": 100,
315+
"branchId": "branch-1705587600000"
316+
}
317+
]
318+
}
319+
```
320+
321+
### Condition Object
322+
323+
Conditions determine when a branch is activated. The structure depends on the trigger type.
324+
325+
#### option_selected (for dropdown, multiple_unique)
326+
327+
```json
328+
{ "type": "option_selected", "optionId": 42 }
329+
```
330+
331+
#### options_combination (for multiple/checkboxes)
332+
333+
```json
334+
{ "type": "options_combination", "optionIds": [42, 43] }
335+
```
336+
All options in `optionIds` must be selected for the branch to activate (AND logic).
337+
338+
#### string_equals (for short text)
339+
340+
```json
341+
{ "type": "string_equals", "value": "yes" }
342+
```
343+
344+
#### string_contains (for short, long text)
345+
346+
```json
347+
{ "type": "string_contains", "value": "keyword" }
348+
```
349+
350+
#### regex (for short, long text)
351+
352+
```json
353+
{ "type": "regex", "value": "^yes.*" }
354+
```
355+
356+
#### value_equals (for color)
357+
358+
```json
359+
{ "type": "value_equals", "value": "#ff0000" }
360+
```
361+
362+
#### value_range (for linearscale, time)
363+
364+
```json
365+
{ "type": "value_range", "min": 3, "max": 5 }
366+
```
367+
368+
#### date_range (for date)
369+
370+
```json
371+
{ "type": "date_range", "min": "2024-01-01", "max": "2024-12-31" }
372+
```
373+
374+
#### time_range (for time)
375+
376+
```json
377+
{ "type": "time_range", "min": "09:00", "max": "17:00" }
378+
```
379+
380+
#### file_uploaded (for file)
381+
382+
```json
383+
{ "type": "file_uploaded", "fileUploaded": true }
384+
```
385+
386+
### Conditional Question Example
387+
388+
A complete conditional question structure:
389+
390+
```json
391+
{
392+
"id": 100,
393+
"formId": 3,
394+
"order": 1,
395+
"type": "conditional",
396+
"isRequired": true,
397+
"text": "Do you have any dietary restrictions?",
398+
"options": [
399+
{ "id": 42, "questionId": 100, "order": 1, "text": "Yes" },
400+
{ "id": 43, "questionId": 100, "order": 2, "text": "No" }
401+
],
402+
"extraSettings": {
403+
"triggerType": "dropdown",
404+
"branches": [
405+
{
406+
"id": "branch-yes",
407+
"conditions": [{ "type": "option_selected", "optionId": 42 }],
408+
"subQuestions": [
409+
{
410+
"id": 101,
411+
"formId": 3,
412+
"order": 1,
413+
"type": "long",
414+
"text": "Please describe your dietary restrictions",
415+
"parentQuestionId": 100,
416+
"branchId": "branch-yes"
417+
}
418+
]
419+
}
420+
]
421+
}
422+
}
423+
```
424+
425+
### Conditional Answer Structure
426+
427+
When submitting or storing conditional question answers, the structure differs from regular questions:
428+
429+
```json
430+
{
431+
"100": {
432+
"trigger": ["42"],
433+
"subQuestions": {
434+
"101": ["Vegetarian, no nuts"]
435+
}
436+
}
437+
}
438+
```
439+
440+
| Property | Type | Description |
441+
| ------------ | --------------------------- | ----------------------------------------------------- |
442+
| trigger | Array of strings | Answer values for the trigger question |
443+
| subQuestions | Object (questionId → Array) | Map of subquestion IDs to their answer value arrays |

lib/Constants.php

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<?php
22

33
/**
4-
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
4+
* SPDX-FileCopyrightText: 2021-2026 Nextcloud GmbH and Nextcloud contributors
55
* SPDX-License-Identifier: AGPL-3.0-or-later
66
*/
77

@@ -67,6 +67,7 @@ class Constants {
6767

6868
// Available AnswerTypes
6969
public const ANSWER_TYPE_COLOR = 'color';
70+
public const ANSWER_TYPE_CONDITIONAL = 'conditional';
7071
public const ANSWER_TYPE_DATE = 'date';
7172
public const ANSWER_TYPE_DATETIME = 'datetime';
7273
public const ANSWER_TYPE_DROPDOWN = 'dropdown';
@@ -81,6 +82,7 @@ class Constants {
8182
// All AnswerTypes
8283
public const ANSWER_TYPES = [
8384
self::ANSWER_TYPE_COLOR,
85+
self::ANSWER_TYPE_CONDITIONAL,
8486
self::ANSWER_TYPE_DATE,
8587
self::ANSWER_TYPE_DATETIME,
8688
self::ANSWER_TYPE_DROPDOWN,
@@ -179,6 +181,64 @@ class Constants {
179181
'optionsLabelHighest' => ['string', 'NULL'],
180182
];
181183

184+
/**
185+
* Extra settings for conditional questions
186+
* - triggerType: The question type used for the trigger (e.g., 'multiple_unique', 'dropdown', 'short')
187+
* - branches: Array of branch definitions, each containing:
188+
* - id: Unique branch identifier
189+
* - conditions: Array of condition objects defining when this branch is active
190+
* For predefined types: [{ optionId: number }]
191+
* For text types: [{ type: 'string_equals'|'string_contains'|'regex', value: string }]
192+
* For numeric/scale: [{ type: 'value_equals'|'value_range', value: number, min?: number, max?: number }]
193+
*/
194+
public const EXTRA_SETTINGS_CONDITIONAL = [
195+
'triggerType' => ['string'],
196+
'branches' => ['array'],
197+
];
198+
199+
/**
200+
* Condition types for conditional questions
201+
*/
202+
public const CONDITION_TYPE_OPTION_SELECTED = 'option_selected';
203+
public const CONDITION_TYPE_OPTIONS_COMBINATION = 'options_combination';
204+
public const CONDITION_TYPE_STRING_EQUALS = 'string_equals';
205+
public const CONDITION_TYPE_STRING_CONTAINS = 'string_contains';
206+
public const CONDITION_TYPE_REGEX = 'regex';
207+
public const CONDITION_TYPE_VALUE_EQUALS = 'value_equals';
208+
public const CONDITION_TYPE_VALUE_RANGE = 'value_range';
209+
public const CONDITION_TYPE_DATE_RANGE = 'date_range';
210+
public const CONDITION_TYPE_FILE_UPLOADED = 'file_uploaded';
211+
212+
public const CONDITION_TYPES = [
213+
self::CONDITION_TYPE_OPTION_SELECTED,
214+
self::CONDITION_TYPE_OPTIONS_COMBINATION,
215+
self::CONDITION_TYPE_STRING_EQUALS,
216+
self::CONDITION_TYPE_STRING_CONTAINS,
217+
self::CONDITION_TYPE_REGEX,
218+
self::CONDITION_TYPE_VALUE_EQUALS,
219+
self::CONDITION_TYPE_VALUE_RANGE,
220+
self::CONDITION_TYPE_DATE_RANGE,
221+
self::CONDITION_TYPE_FILE_UPLOADED,
222+
];
223+
224+
/**
225+
* Trigger types allowed for conditional questions
226+
* Maps each trigger type to its supported condition types
227+
*/
228+
public const CONDITIONAL_TRIGGER_TYPES = [
229+
self::ANSWER_TYPE_MULTIPLEUNIQUE => [self::CONDITION_TYPE_OPTION_SELECTED],
230+
self::ANSWER_TYPE_DROPDOWN => [self::CONDITION_TYPE_OPTION_SELECTED],
231+
self::ANSWER_TYPE_MULTIPLE => [self::CONDITION_TYPE_OPTIONS_COMBINATION],
232+
self::ANSWER_TYPE_SHORT => [self::CONDITION_TYPE_STRING_EQUALS, self::CONDITION_TYPE_STRING_CONTAINS, self::CONDITION_TYPE_REGEX],
233+
self::ANSWER_TYPE_LONG => [self::CONDITION_TYPE_STRING_CONTAINS, self::CONDITION_TYPE_REGEX],
234+
self::ANSWER_TYPE_LINEARSCALE => [self::CONDITION_TYPE_VALUE_EQUALS, self::CONDITION_TYPE_VALUE_RANGE],
235+
self::ANSWER_TYPE_DATE => [self::CONDITION_TYPE_DATE_RANGE],
236+
self::ANSWER_TYPE_DATETIME => [self::CONDITION_TYPE_DATE_RANGE],
237+
self::ANSWER_TYPE_TIME => [self::CONDITION_TYPE_VALUE_RANGE],
238+
self::ANSWER_TYPE_COLOR => [self::CONDITION_TYPE_VALUE_EQUALS],
239+
self::ANSWER_TYPE_FILE => [self::CONDITION_TYPE_FILE_UPLOADED],
240+
];
241+
182242
public const FILENAME_INVALID_CHARS = [
183243
"\n",
184244
'/',

0 commit comments

Comments
 (0)