Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0096a5d
Feat: Conditional Logic
mickenordin Jan 18, 2026
73b34c1
fix: allow empty branches, fixes
timedin-de May 18, 2026
67e61f0
fix: trigger question options
timedin-de May 9, 2026
0e8d294
fix: show conditional answers in detail view
timedin-de May 18, 2026
72cc5a2
fix: save options and extraSettings for subquestions
timedin-de May 10, 2026
1625ce2
fix: move up/down for subquestions
timedin-de May 10, 2026
47a3f95
feat: add ranking-question to conditional
timedin-de May 29, 2026
1ebd67b
fix: multiple active branches
timedin-de May 29, 2026
0e7a392
fix: selectors for linear scale
timedin-de Jun 2, 2026
1b7552a
fix: extract question list to seperate component, add 'insert questio…
timedin-de Jun 2, 2026
b658945
feat: adding transition to conditional branches
timedin-de Jun 2, 2026
44fc218
fix: harden conditional submission handling
mickenordin Jun 3, 2026
cb2f9ba
fix: selectors for linear scale
timedin-de Jun 2, 2026
48b76fd
fix: use constants instead of raw strings
timedin-de Jun 4, 2026
074692f
fix: cloning forms
timedin-de Jun 6, 2026
cf61561
fix: tests
timedin-de Jun 6, 2026
3addbd4
fix: backend support for multiple active branches
timedin-de Jun 6, 2026
7a58700
fix: conditional branch test mock and psalm types
mickenordin Jun 7, 2026
08cac7d
fix: align conditional question view types
mickenordin Sep 15, 2026
7ac81c6
test: cover upload authorization for conditional answers
mickenordin Sep 15, 2026
5954381
fix: use valid template syntax for answer updates
mickenordin Oct 8, 2026
0c8c1b3
fix: avoid persisting conditional subquestions twice
mickenordin Oct 8, 2026
3d6990b
fix: simplify conditional trigger type selection
mickenordin Oct 8, 2026
831a3fd
fix(conditional): keep subquestions in their place on question updates
AndyScherzinger Nov 4, 2026
33479cb
fix(conditional): keep branches, order and copies of subquestions con…
AndyScherzinger Nov 4, 2026
8c09b86
fix(conditional): validate and store trigger answers like regular que…
AndyScherzinger Nov 4, 2026
fc45ead
fix(conditional): number and label subquestions apart from other ques…
AndyScherzinger Nov 4, 2026
181fc68
fix(conditional): match conditions the same way in the browser and on…
AndyScherzinger Nov 4, 2026
a33f5e9
fix(conditional): summarize subquestions and keep the trigger menu to…
AndyScherzinger Nov 4, 2026
b193e0c
test(e2e): cover answering a conditional question
AndyScherzinger Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 187 additions & 1 deletion docs/DataStructure.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!--
- SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
- SPDX-FileCopyrightText: 2021-2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-only
-->

Expand Down Expand Up @@ -240,6 +240,7 @@ Currently supported Question-Types are:
| `linearscale` | A linear or Likert scale question where you choose an option that best fits your opinion |
| `color` | A color answer, hex string representation (e. g. `#123456`) |
| `ranking` | Using pre-defined options, the user ranks them from most to least preferred. Needs at least one option available. Answers are stored in ranked order (one answer row per option). |
| `conditional` | A conditional branching question with a trigger question and multiple branches containing subquestions |

## Extra Settings

Expand Down Expand Up @@ -282,3 +283,188 @@ Supported option types and their intended usage.
| `column` | Represents a column header when rendering options in a grid-style question. |

`row` and `column` option types are primarily used together with `grid` question types to build a two-dimensional selection matrix. `choice` is the default for normal option lists.
| `triggerType` | `conditional` | string | [See trigger types](#conditional-trigger-types) | The type of trigger question (dropdown, multiple_unique, etc.) |
| `branches` | `conditional` | Array | Array of [Branch objects](#branch-object) | The branches with conditions and subquestions |

## Conditional Questions

Conditional questions enable branching logic in forms. A trigger question determines which branch of subquestions appears based on the respondent's answer.

### Question Properties for Subquestions

Subquestions (questions belonging to a conditional question's branch) have additional properties:

| Property | Type | Description |
| ---------------- | ------- | ---------------------------------------------------------------------- |
| parentQuestionId | Integer | The ID of the parent conditional question (null for regular questions) |
| branchId | String | The ID of the branch this subquestion belongs to |

### Conditional Trigger Types

Supported trigger types for conditional questions:

| Trigger Type | Condition Type | Description |
| ----------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------- |
| `multiple_unique` | `option_selected`, `option_not_selected` | Radio buttons - single option selection |
| `dropdown` | `option_selected`, `option_not_selected` | Dropdown - single option selection |
| `multiple` | `option_selected`, `option_not_selected`, `options_combination` | Checkbox - option or combination matching |
| `short` | `string_equals`, `string_not_equals`, `string_contains`, `string_not_contains`, `regex` | Short text - string or regex matching |
| `long` | `string_contains`, `string_not_contains`, `regex` | Long text - string or regex matching |
| `linearscale` | `value_equals`, `value_not_equals`, `value_range`, `value_min`, `value_max` | Linear scale - numeric value matching |
| `date` | `date_equals`, `date_before`, `date_after`, `date_range` | Date - exact or range matching (`YYYY-MM-DD`) |
| `time` | `time_equals`, `time_before`, `time_after`, `time_range` | Time - exact or range matching (`HH:mm`) |
| `color` | `value_equals` | Color - exact value matching |
| `file` | `file_uploaded`, `file_not_uploaded`, `filename_contains`, `filename_equals`, `filename_regex` | File - upload status or filename matching |

### Branch Object

A branch defines conditions and subquestions that appear when those conditions are met.

| Property | Type | Description |
| ------------ | ---------------------------------------- | ---------------------------------------------- |
| id | String | Unique identifier for the branch |
| conditions | Array of [Conditions](#condition-object) | Conditions that must be met to show the branch |
| subQuestions | Array of [Questions](#question) | Questions shown when conditions are met |

```json
{
"id": "branch-1705587600000",
"conditions": [{ "type": "option_selected", "optionId": 42 }],
"subQuestions": [
{
"id": 101,
"formId": 3,
"order": 1,
"type": "short",
"text": "Please provide details",
"parentQuestionId": 100,
"branchId": "branch-1705587600000"
}
]
}
```

### Condition Object

Conditions determine when a branch is activated. The structure depends on the trigger type.

#### option_selected (for dropdown, multiple_unique)

```json
{ "type": "option_selected", "optionId": 42 }
```

#### options_combination (for multiple/checkboxes)

```json
{ "type": "options_combination", "optionIds": [42, 43] }
```

All options in `optionIds` must be selected for the branch to activate (AND logic).

#### string_equals (for short text)

```json
{ "type": "string_equals", "value": "yes" }
```

#### string_contains (for short, long text)

```json
{ "type": "string_contains", "value": "keyword" }
```

#### regex (for short, long text)

```json
{ "type": "regex", "value": "^yes.*" }
```

#### value_equals (for color)

```json
{ "type": "value_equals", "value": "#ff0000" }
```

#### value_range (for linearscale, time)

```json
{ "type": "value_range", "min": 3, "max": 5 }
```

#### date_range (for date)

```json
{ "type": "date_range", "min": "2024-01-01", "max": "2024-12-31" }
```

#### time_range (for time)

```json
{ "type": "time_range", "min": "09:00", "max": "17:00" }
```

#### file_uploaded (for file)

```json
{ "type": "file_uploaded", "fileUploaded": true }
```

### Conditional Question Example

A complete conditional question structure:

```json
{
"id": 100,
"formId": 3,
"order": 1,
"type": "conditional",
"isRequired": true,
"text": "Do you have any dietary restrictions?",
"options": [
{ "id": 42, "questionId": 100, "order": 1, "text": "Yes" },
{ "id": 43, "questionId": 100, "order": 2, "text": "No" }
],
"extraSettings": {
"triggerType": "dropdown",
"branches": [
{
"id": "branch-yes",
"conditions": [{ "type": "option_selected", "optionId": 42 }],
"subQuestions": [
{
"id": 101,
"formId": 3,
"order": 1,
"type": "long",
"text": "Please describe your dietary restrictions",
"parentQuestionId": 100,
"branchId": "branch-yes"
}
]
}
]
}
}
```

### Conditional Answer Structure

When submitting or storing conditional question answers, the structure differs from regular questions:

```json
{
"100": {
"trigger": ["42"],
"subQuestions": {
"101": ["Vegetarian, no nuts"]
}
}
}
```

| Property | Type | Description |
| ------------ | --------------------------- | --------------------------------------------------- |
| trigger | Array of strings | Answer values for the trigger question |
| subQuestions | Object (questionId → Array) | Map of subquestion IDs to their answer value arrays |
53 changes: 52 additions & 1 deletion lib/Constants.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php

/**
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2021-2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

Expand Down Expand Up @@ -101,6 +101,7 @@ class Constants {

// Available AnswerTypes
public const ANSWER_TYPE_COLOR = 'color';
public const ANSWER_TYPE_CONDITIONAL = 'conditional';
public const ANSWER_TYPE_DATE = 'date';
public const ANSWER_TYPE_DATETIME = 'datetime';
public const ANSWER_TYPE_DROPDOWN = 'dropdown';
Expand All @@ -121,6 +122,7 @@ class Constants {
// All AnswerTypes
public const ANSWER_TYPES = [
self::ANSWER_TYPE_COLOR,
self::ANSWER_TYPE_CONDITIONAL,
self::ANSWER_TYPE_DATE,
self::ANSWER_TYPE_DATETIME,
self::ANSWER_TYPE_DROPDOWN,
Expand Down Expand Up @@ -239,6 +241,55 @@ class Constants {
self::ANSWER_GRID_TYPE_RADIO,
];

/**
* Extra settings for conditional questions
* - triggerType: The question type used for the trigger (e.g., 'multiple_unique', 'dropdown', 'short')
* - branches: Array of branch definitions, each containing:
* - id: Unique branch identifier
* - conditions: Array of condition objects defining when this branch is active
* For predefined types: [{ optionId: number }]
* For text types: [{ type: 'string_equals'|'string_contains'|'regex', value: string }]
* For numeric/scale: [{ type: 'value_equals'|'value_range', value: number, min?: number, max?: number }]
*/
public const EXTRA_SETTINGS_CONDITIONAL = [
'triggerType' => ['string'],
'branches' => ['array'],
];

/**
* Condition types for conditional questions
*/
public const CONDITION_TYPE_OPTION_SELECTED = 'option_selected';
public const CONDITION_TYPE_OPTIONS_COMBINATION = 'options_combination';
public const CONDITION_TYPE_STRING_EQUALS = 'string_equals';
public const CONDITION_TYPE_STRING_CONTAINS = 'string_contains';
public const CONDITION_TYPE_REGEX = 'regex';
public const CONDITION_TYPE_VALUE_EQUALS = 'value_equals';
public const CONDITION_TYPE_VALUE_NOT_EQUALS = 'value_not_equals';
public const CONDITION_TYPE_VALUE_RANGE = 'value_range';
public const CONDITION_TYPE_VALUE_MIN = 'value_min';
public const CONDITION_TYPE_VALUE_MAX = 'value_max';
public const CONDITION_TYPE_DATE_RANGE = 'date_range';
public const CONDITION_TYPE_FILE_UPLOADED = 'file_uploaded';

/**
* Trigger types allowed for conditional questions
* Maps each trigger type to its supported condition types
*/
public const CONDITIONAL_TRIGGER_TYPES = [
self::ANSWER_TYPE_MULTIPLEUNIQUE => [self::CONDITION_TYPE_OPTION_SELECTED],
self::ANSWER_TYPE_DROPDOWN => [self::CONDITION_TYPE_OPTION_SELECTED],
self::ANSWER_TYPE_MULTIPLE => [self::CONDITION_TYPE_OPTIONS_COMBINATION],
self::ANSWER_TYPE_SHORT => [self::CONDITION_TYPE_STRING_EQUALS, self::CONDITION_TYPE_STRING_CONTAINS, self::CONDITION_TYPE_REGEX],
self::ANSWER_TYPE_LONG => [self::CONDITION_TYPE_STRING_CONTAINS, self::CONDITION_TYPE_REGEX],
self::ANSWER_TYPE_LINEARSCALE => [self::CONDITION_TYPE_VALUE_EQUALS, self::CONDITION_TYPE_VALUE_NOT_EQUALS, self::CONDITION_TYPE_VALUE_RANGE, self::CONDITION_TYPE_VALUE_MIN, self::CONDITION_TYPE_VALUE_MAX],
self::ANSWER_TYPE_DATE => [self::CONDITION_TYPE_DATE_RANGE],
self::ANSWER_TYPE_DATETIME => [self::CONDITION_TYPE_DATE_RANGE],
self::ANSWER_TYPE_TIME => [self::CONDITION_TYPE_DATE_RANGE],
self::ANSWER_TYPE_COLOR => [self::CONDITION_TYPE_VALUE_EQUALS],
self::ANSWER_TYPE_FILE => [self::CONDITION_TYPE_FILE_UPLOADED],
];

/**
* !! Keep in sync with src/mixins/ShareTypes.js !!
*/
Expand Down
Loading
Loading