Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
189 changes: 188 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 @@ -227,6 +227,7 @@ Currently supported Question-Types are:
| `file` | One or multiple files. It is possible to specify which mime types are allowed |
| `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`) |
| `conditional` | A conditional branching question with a trigger question and multiple branches containing subquestions |

## Extra Settings

Expand Down Expand Up @@ -254,3 +255,189 @@ Optional extra settings for some [Question Types](#question-types)
| `optionsHighest` | `linearscale` | Integer | `2, 3, 4, 5, 6, 7, 8, 9, 10` | Set the highest value of the scale, default: `5` |
| `optionsLabelLowest` | `linearscale` | string | - | Set the label of the lowest value, default: `'Strongly disagree'` |
| `optionsLabelHighest` | `linearscale` | string | - | Set the label of the highest value, default: `'Strongly agree'` |
| `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` | Radio buttons - single option selection |
| `dropdown` | `option_selected` | Dropdown - single option selection |
| `multiple` | `options_combination`| Checkboxes - all specified options must be selected |
| `short` | `string_equals`, `string_contains`, `regex` | Short text with string/regex matching |
| `long` | `string_contains`, `regex` | Long text with string/regex matching |
| `linearscale` | `value_equals`, `value_range` | Linear scale with value matching |
| `date` | `date_range` | Date with date range matching (YYYY-MM-DD) |
| `time` | `time_range` | Time with time range matching (HH:mm) |
| `color` | `value_equals` | Color with exact value matching |
| `file` | `file_uploaded` | File with upload status 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 |
62 changes: 61 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 @@ -67,6 +67,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 @@ -81,6 +82,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 @@ -179,6 +181,64 @@ class Constants {
'optionsLabelHighest' => ['string', 'NULL'],
];

/**
* 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_RANGE = 'value_range';
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_RANGE,
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_RANGE],
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],
];

public const FILENAME_INVALID_CHARS = [
"\n",
'/',
Expand Down
Loading
Loading