Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f58eb88
Feat: Conditional Logic
mickenordin Jan 18, 2026
cc8fa8c
fix: allow empty branches, fixes
timedin-de May 18, 2026
034fbcf
fix: trigger question options
timedin-de May 9, 2026
8fdb319
fix: show conditional answers in detail view
timedin-de May 18, 2026
717122c
fix: save options and extraSettings for subquestions
timedin-de May 10, 2026
26c4fbc
fix: move up/down for subquestions
timedin-de May 10, 2026
61ab5ee
feat: add ranking-question to conditional
timedin-de May 29, 2026
e4c5778
fix: multiple active branches
timedin-de May 29, 2026
50f0bcf
fix: selectors for linear scale
timedin-de Jun 2, 2026
2f868c3
fix: extract question list to seperate component, add 'insert questio…
timedin-de Jun 2, 2026
ce0f101
feat: adding transition to conditional branches
timedin-de Jun 2, 2026
258abe2
fix: harden conditional submission handling
mickenordin Jun 3, 2026
d55754a
fix: selectors for linear scale
timedin-de Jun 2, 2026
fc24d38
fix: use constants instead of raw strings
timedin-de Jun 4, 2026
1968d9d
fix: cloning forms
timedin-de Jun 6, 2026
d38a8dd
fix: tests
timedin-de Jun 6, 2026
aeb3f84
fix: backend support for multiple active branches
timedin-de Jun 6, 2026
bff716b
fix: conditional branch test mock and psalm types
mickenordin Jun 7, 2026
86bf42e
fix: align conditional question view types
mickenordin Sep 15, 2026
bbfee48
test: cover upload authorization for conditional answers
mickenordin Sep 15, 2026
a73ff4f
fix: use valid template syntax for answer updates
mickenordin Oct 8, 2026
3230a14
fix: avoid persisting conditional subquestions twice
mickenordin Oct 8, 2026
1b4726e
fix: simplify conditional trigger type selection
mickenordin Oct 8, 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 @@ -238,6 +238,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 @@ -280,3 +281,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 |
68 changes: 67 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 @@ -92,6 +92,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 @@ -112,6 +113,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 @@ -230,6 +232,70 @@ 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';

public const CONDITION_TYPES = [
self::CONDITION_TYPE_OPTION_SELECTED,
self::CONDITION_TYPE_OPTIONS_COMBINATION,
self::CONDITION_TYPE_STRING_EQUALS,
self::CONDITION_TYPE_STRING_CONTAINS,
self::CONDITION_TYPE_REGEX,
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::CONDITION_TYPE_DATE_RANGE,
self::CONDITION_TYPE_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_VALUE_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