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
9 changes: 9 additions & 0 deletions docs/DataStructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ Supported trigger types for conditional questions:
| ----------------- | --------------------------------------------------------------------------- | ------------------------------------------------- |
| `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.

Expand Down Expand Up @@ -348,6 +349,14 @@ Conditions determine when a branch is activated. The structure depends on the tr
{ "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.

### Conditional Question Example

A complete conditional question structure:
Expand Down
2 changes: 2 additions & 0 deletions lib/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ class Constants {
* Condition types for conditional questions
*/
public const CONDITION_TYPE_OPTION_SELECTED = 'option_selected';
public const CONDITION_TYPE_OPTIONS_COMBINATION = 'options_combination';

/**
* Trigger types allowed for conditional questions
Expand All @@ -268,6 +269,7 @@ class Constants {
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],
];

/**
Expand Down
7 changes: 7 additions & 0 deletions lib/Service/FormsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -953,6 +956,10 @@ private function areBranchConditionsValid(string $triggerType, array $conditions
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;
Expand Down
12 changes: 12 additions & 0 deletions lib/Service/SubmissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,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;
}
Expand Down
55 changes: 49 additions & 6 deletions src/components/Questions/BranchConditionEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,30 @@

<template>
<div class="branch-condition-editor">
<!-- Option-based conditions (radio, dropdown) -->
<!-- Option-based conditions (radio, dropdown, checkbox) -->
<template v-if="isOptionBasedTrigger">
<div class="condition-options">
<!-- Single select for radio/dropdown -->
<NcSelect
v-if="isSingleSelect"
:inputLabel="conditionLabel"
:modelValue="selectedOptions"
:options="optionsList"
:placeholder="t('forms', 'Select an option')"
:multiple="false"
label="text"
@update:modelValue="onSingleOptionSelect" />

<!-- Multi select for checkboxes -->
<NcSelect
v-else
:inputLabel="conditionLabel"
:modelValue="selectedOptions"
:options="optionsList"
:placeholder="t('forms', 'Select options combination')"
:multiple="true"
label="text"
@update:modelValue="onMultipleOptionsSelect" />
</div>
</template>
</div>
Expand Down Expand Up @@ -62,17 +75,29 @@ export default {

computed: {
/**
* Check if trigger is option-based (radio, dropdown)
* Check if trigger is option-based (radio, dropdown, checkbox)
*/
isOptionBasedTrigger() {
return ['multiple_unique', 'dropdown', 'multiple'].includes(
this.triggerType,
)
},

/**
* Check if trigger is single-select (radio, dropdown)
*/
isSingleSelect() {
return ['multiple_unique', 'dropdown'].includes(this.triggerType)
},

/**
* Label for option-based conditions
*/
conditionLabel() {
return t('forms', 'Show when selected:')
if (this.isSingleSelect) {
return t('forms', 'Show when selected:')
}
return t('forms', 'Show when all selected:')
},

/**
Expand All @@ -90,15 +115,22 @@ export default {
*/
selectedOptions() {
if (!this.branch.conditions || this.branch.conditions.length === 0) {
return null
return this.isSingleSelect ? null : []
}

const selectedIds = this.branch.conditions.map((c) => c.optionId)
let selectedIds
if (this.isSingleSelect) {
// Single select uses optionId in each condition
selectedIds = this.branch.conditions.map((c) => c.optionId)
} else {
// Multi select uses optionIds array in first condition
selectedIds = this.branch.conditions[0]?.optionIds || []
}
const selected = this.optionsList.filter((opt) =>
selectedIds.includes(opt.id),
)

return selected[0] || null
return this.isSingleSelect ? selected[0] || null : selected
},
},

Expand All @@ -113,6 +145,17 @@ export default {
this.emitUpdate({ conditions })
},

/**
* Handle multiple option selection (checkbox)
*
* @param {Array} options The selected options array
*/
onMultipleOptionsSelect(options) {
const optionIds = options.map((opt) => opt.id)
const conditions = optionIds.length > 0 ? [{ optionIds }] : []
this.emitUpdate({ conditions })
},

/**
* Emit branch update
*
Expand Down
36 changes: 31 additions & 5 deletions src/components/Questions/QuestionConditional.vue
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ export default defineComponent({
const groups = [
{
label: t('forms', 'Choice'),
types: ['multiple_unique', 'dropdown'],
types: ['multiple_unique', 'multiple', 'dropdown'],
},
]

Expand Down Expand Up @@ -445,10 +445,12 @@ export default defineComponent({
},

/**
* Check if trigger type uses predefined options (radio, dropdown)
* Check if trigger type uses predefined options (radio, dropdown, checkbox)
*/
isOptionBasedTrigger() {
return ['multiple_unique', 'dropdown'].includes(this.triggerType)
return ['multiple_unique', 'dropdown', 'multiple'].includes(
this.triggerType,
)
},

triggerComponent() {
Expand Down Expand Up @@ -694,8 +696,15 @@ export default defineComponent({
})
}

if (this.isOptionBasedTrigger) {
const optionIds = branch.conditions.map((c) => c.optionId)
if (
['multiple_unique', 'dropdown', 'multiple'].includes(
this.triggerType,
)
) {
// Checkbox conditions hold a combination of options
const optionIds = branch.conditions.flatMap(
(c) => c.optionIds ?? [c.optionId],
)
const optionTexts = optionIds
.map((id) => this.options.find((o) => o.id === id)?.text)
.filter(Boolean)
Expand Down Expand Up @@ -935,6 +944,23 @@ export default defineComponent({
)
}

if (this.triggerType === 'multiple') {
// Multi-select: all condition option IDs must be selected
for (const c of branch.conditions) {
const optionIds = c.optionIds
if (!Array.isArray(optionIds) || optionIds.length === 0) {
continue
}
const allSelected = optionIds.every((id) =>
triggerValuesAsStrings.includes(String(id)),
)
if (allSelected) {
return true
}
}
return false
}

return false
},

Expand Down
20 changes: 20 additions & 0 deletions tests/Unit/Service/FormsServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1926,6 +1926,16 @@ public static function dataAreExtraSettingsValid() {
'questionType' => Constants::ANSWER_TYPE_CONDITIONAL,
'expected' => true
],
'valid-conditional-multiple-trigger' => [
'extraSettings' => [
'triggerType' => Constants::ANSWER_TYPE_MULTIPLE,
'branches' => [
['id' => 'branch-1', 'conditions' => [['type' => 'options_combination', 'optionIds' => [1, 2]]]]
]
],
'questionType' => Constants::ANSWER_TYPE_CONDITIONAL,
'expected' => true
],
'invalid-conditional-missing-triggerType' => [
'extraSettings' => [
'branches' => [
Expand Down Expand Up @@ -2031,6 +2041,16 @@ public static function dataAreExtraSettingsValid() {
'questionType' => Constants::ANSWER_TYPE_CONDITIONAL,
'expected' => false
],
'invalid-conditional-option-ids-not-numbers' => [
'extraSettings' => [
'triggerType' => Constants::ANSWER_TYPE_MULTIPLE,
'branches' => [
['id' => 'branch-1', 'conditions' => [['type' => 'options_combination', 'optionIds' => [1, 'two']]]]
]
],
'questionType' => Constants::ANSWER_TYPE_CONDITIONAL,
'expected' => false
],
'invalid-conditional-branch-empty-id' => [
'extraSettings' => [
'triggerType' => Constants::ANSWER_TYPE_DROPDOWN,
Expand Down
80 changes: 80 additions & 0 deletions tests/Unit/Service/SubmissionServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1614,6 +1614,55 @@ public static function dataValidateSubmission() {
// Expected Result
'Question "Sub Q1" is required.',
],
'valid-conditional-checkbox-combination-trigger' => [
// Questions - conditional with multiple (checkbox) trigger
[
[
'id' => 100,
'type' => Constants::ANSWER_TYPE_CONDITIONAL,
'text' => 'Checkbox Conditional',
'isRequired' => true,
'extraSettings' => [
'triggerType' => Constants::ANSWER_TYPE_MULTIPLE,
'branches' => [
[
'id' => 'branch-combo1',
'conditions' => [['type' => 'options_combination', 'optionIds' => [10, 11]]],
'subQuestions' => [
[
'id' => 101,
'type' => 'short',
'text' => 'Combo 1 question',
'isRequired' => true,
]
]
],
[
'id' => 'branch-combo2',
'conditions' => [['type' => 'options_combination', 'optionIds' => [10]]],
'subQuestions' => []
]
]
],
'options' => [
['id' => 10],
['id' => 11],
['id' => 12]
]
]
],
// Answers - selecting options 10 and 11 activates branch-combo1
[
'100' => [
'trigger' => ['10', '11'],
'subQuestions' => [
'101' => ['Combo answer']
]
]
],
// Expected Result - valid
null,
],
'valid-conditional-no-branch-matched' => [
// Questions - conditional where trigger value doesn't match any branch
[
Expand Down Expand Up @@ -1720,6 +1769,37 @@ public static function dataValidateSubmission() {
// Expected Result
'Question "Conditional Q" is required.',
],
'invalid-conditional-checkbox-any-condition' => [
// Questions - a branch is active if any of its option combinations is selected
[
[
'id' => 100,
'type' => Constants::ANSWER_TYPE_CONDITIONAL,
'text' => 'Checkbox Conditional',
'isRequired' => false,
'extraSettings' => [
'triggerType' => Constants::ANSWER_TYPE_MULTIPLE,
'branches' => [
[
'id' => 'branch-1',
'conditions' => [
['type' => 'options_combination', 'optionIds' => [10, 11]],
['type' => 'options_combination', 'optionIds' => [12]],
],
'subQuestions' => [
['id' => 101, 'type' => 'short', 'text' => 'Combo question', 'isRequired' => true],
],
],
],
],
'options' => [['id' => 10], ['id' => 11], ['id' => 12]],
],
],
// Answers - only the second combination is selected
['100' => ['trigger' => ['12'], 'subQuestions' => []]],
// Expected Result
'Question "Combo question" is required.',
],
'invalid-conditional-answer-for-inactive-branch' => [
// Questions - two branches with one subquestion each
[
Expand Down
Loading