From 87eb88f188f1f4671c7b3beef7337ddb7a33edea Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sat, 7 Nov 2026 15:49:21 +0100 Subject: [PATCH] feat(conditional): allow branches on checkbox questions A checkbox question opens a branch when all options of one of its option combinations are selected, so several branches can be shown at once. The condition editor picks the combination, the branch label lists its options, copies of a question map the option ids of the combinations to the copied options, and the extra settings only accept integer option ids. The code comes from the original pull request #3123 by Micke Nordin and TimedIn, with the review fixes applied. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Andy Scherzinger --- docs/DataStructure.md | 11 ++- lib/Constants.php | 2 + lib/Service/FormsService.php | 7 ++ lib/Service/SubmissionService.php | 12 +++ .../Conditional/BranchConditionEditor.vue | 55 +++++++++++-- .../Conditional/QuestionBranches.vue | 6 +- src/utils/Branches.ts | 19 +++++ tests/Unit/Service/FormsServiceTest.php | 10 +++ tests/Unit/Service/SubmissionServiceTest.php | 77 +++++++++++++++++++ 9 files changed, 191 insertions(+), 8 deletions(-) diff --git a/docs/DataStructure.md b/docs/DataStructure.md index 304343d71..4f02f7b48 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -270,7 +270,7 @@ Optional extra settings for some [Question Types](#question-types) | `columns` | `grid` | Array | - | Array of column identifiers / labels for grid questions | | `rows` | `grid` | Array | - | Array of row identifiers / labels for grid questions | | `questionType` | `grid` | String | `checkbox`, `number`, `radio` | Type of cell for grid questions (checkbox, numeric input, or radio) | -| `branches` | `dropdown`, `multiple_unique` | Array | Array of [Branch objects](#branch-object) | Branches with follow-up questions, see [Follow-up Questions](#follow-up-questions) | +| `branches` | `dropdown`, `multiple`, `multiple_unique` | Array | Array of [Branch objects](#branch-object) | Branches with follow-up questions, see [Follow-up Questions](#follow-up-questions) | ### Option Types @@ -303,6 +303,7 @@ Follow-up questions (subquestions) are questions of their own with additional pr | ----------------- | ----------------- | ----------------------------------- | | `multiple_unique` | `option_selected` | Radio buttons - the selected option | | `dropdown` | `option_selected` | Dropdown - the selected option | +| `multiple` | `options_combination` | Checkboxes - all options of a combination | A branch is shown when any of its conditions matches. Several branches can be shown at once. @@ -344,6 +345,14 @@ Conditions determine when a branch is activated. The structure depends on the ty { "type": "option_selected", "optionId": 42 } ``` +#### options_combination (for multiple) + +```json +{ "type": "options_combination", "optionIds": [42, 43] } +``` + +All options in `optionIds` must be selected for the condition to match. + ### Example A dropdown question with a branch of follow-up questions: diff --git a/lib/Constants.php b/lib/Constants.php index dfeffa3bc..629ddcc6e 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -243,6 +243,7 @@ class Constants { * Condition types of branches */ public const CONDITION_TYPE_OPTION_SELECTED = 'option_selected'; + public const CONDITION_TYPE_OPTIONS_COMBINATION = 'options_combination'; /** * Question types that can have branches with follow-up questions in `extraSettings.branches`, @@ -253,6 +254,7 @@ class Constants { public const QUESTION_TYPES_WITH_BRANCHES = [ 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], ]; /** diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index eb816b02c..a854bb784 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -156,6 +156,9 @@ public static function mapConditionOptionIds(array $extraSettings, array $option if (isset($condition['optionId'])) { $extraSettings['branches'][$branchIndex]['conditions'][$conditionIndex]['optionId'] = $optionIdMap[$condition['optionId']] ?? $condition['optionId']; } + if (isset($condition['optionIds']) && is_array($condition['optionIds'])) { + $extraSettings['branches'][$branchIndex]['conditions'][$conditionIndex]['optionIds'] = array_map(static fn ($optionId) => $optionIdMap[$optionId] ?? $optionId, $condition['optionIds']); + } } } return $extraSettings; @@ -981,6 +984,10 @@ private function areBranchConditionsValid(string $questionType, array $condition if (isset($condition['optionId']) && !is_int($condition['optionId'])) { return false; } + if (isset($condition['optionIds']) + && (!is_array($condition['optionIds']) || array_filter($condition['optionIds'], static fn ($id) => !is_int($id)) !== [])) { + return false; + } foreach (['value', 'min', 'max'] as $key) { if (isset($condition[$key]) && !is_scalar($condition[$key])) { return false; diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index 8b6398ea7..288c4234c 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -1065,6 +1065,18 @@ private function evaluateBranchConditions(string $triggerType, array $triggerAns } } return false; + case Constants::ANSWER_TYPE_MULTIPLE: + // Multi-select: the branch is active if all option ids of any of its conditions are selected + foreach ($conditions as $condition) { + $optionIds = $condition['optionIds'] ?? []; + if (empty($optionIds) || !is_array($optionIds)) { + continue; + } + if (array_diff(array_map('strval', $optionIds), $selectedOptionIds) === []) { + return true; + } + } + return false; default: return false; } diff --git a/src/components/Conditional/BranchConditionEditor.vue b/src/components/Conditional/BranchConditionEditor.vue index 0f8daf50f..eb74f74b0 100644 --- a/src/components/Conditional/BranchConditionEditor.vue +++ b/src/components/Conditional/BranchConditionEditor.vue @@ -5,10 +5,12 @@