Skip to content
Draft
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
11 changes: 10 additions & 1 deletion docs/DataStructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,10 +306,11 @@ Follow-up questions (subquestions) are questions of their own with additional pr
| `multiple` | `options_combination`, `other_selected` | Checkboxes - all options of a combination checked, or "Other" |
| `date` | `date_range` | Date - the date within a range |
| `time` | `date_range` | Time - the time within a range |
| `linearscale` | `value_equals` | Linear scale - the chosen value |

A branch is shown when any of its conditions matches. An answer leads to at most one branch: an option, a combination of options, or the answer "Other", is part of the conditions of one branch only. On checkboxes several branches can be shown at once, e.g. one for "Red" and one for "Red" and "Blue", and so on date and time questions with overlapping ranges. A date or time question that asks for a range (`dateRange`, `timeRange`) has no branches. A branch without conditions is never shown; it keeps its follow-up questions.

When an option is deleted, its conditions are removed from the branches, combinations with it included. When `allowOtherAnswer` is turned off, the `other_selected` conditions are removed.
When an option is deleted, its conditions are removed from the branches, combinations with it included. When the bounds of a linear scale change, the conditions on values outside the scale are removed. When `allowOtherAnswer` is turned off, the `other_selected` conditions are removed.

### Branch Object

Expand Down Expand Up @@ -359,6 +360,14 @@ Matches when all options of the combination are checked, other checked options d
{ "type": "options_combination", "optionIds": [42, 43] }
```

#### value_equals (for linearscale)

Matches when the value is chosen. The value must lie within `optionsLowest` and `optionsHighest`. Several values leading to one branch express a range, e.g. 4 and 5.

```json
{ "type": "value_equals", "value": 4 }
```

#### date_range (for date, time)

Matches when the answer lies within the range, both limits included. Either limit can be left out; `min` must not be after `max`. Dates use the format `Y-m-d`, times `H:i`. A time range across midnight is written as two conditions, e.g. 22:00–23:59 and 00:00–06:00.
Expand Down
2 changes: 2 additions & 0 deletions lib/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ class Constants {
public const CONDITION_TYPE_OTHER_SELECTED = 'other_selected';
public const CONDITION_TYPE_OPTIONS_COMBINATION = 'options_combination';
public const CONDITION_TYPE_DATE_RANGE = 'date_range';
public const CONDITION_TYPE_VALUE_EQUALS = 'value_equals';

/**
* Question types that can have branches with follow-up questions in `extraSettings.branches`,
Expand All @@ -260,6 +261,7 @@ class Constants {
self::ANSWER_TYPE_MULTIPLE => [self::CONDITION_TYPE_OPTIONS_COMBINATION, self::CONDITION_TYPE_OTHER_SELECTED],
self::ANSWER_TYPE_DATE => [self::CONDITION_TYPE_DATE_RANGE],
self::ANSWER_TYPE_TIME => [self::CONDITION_TYPE_DATE_RANGE],
self::ANSWER_TYPE_LINEARSCALE => [self::CONDITION_TYPE_VALUE_EQUALS],
];

/**
Expand Down
3 changes: 3 additions & 0 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,9 @@ public function updateQuestion(int $formId, int $questionId, array $keyValuePair
if (empty($keyValuePairs['extraSettings']['allowOtherAnswer'])) {
$keyValuePairs['extraSettings'] = FormsService::removeOtherConditions($keyValuePairs['extraSettings']);
}
if ($question->getType() === Constants::ANSWER_TYPE_LINEARSCALE) {
$keyValuePairs['extraSettings'] = FormsService::removeValuesOutsideScale($keyValuePairs['extraSettings']);
}
$optionIds = array_map(static fn (Option $option) => $option->getId(), $this->optionMapper->findByQuestion($questionId));
}

Expand Down
32 changes: 32 additions & 0 deletions lib/Service/FormsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,31 @@ public static function removeOtherConditions(array $extraSettings): array {
);
}

/**
* Remove the conditions on scale values outside the bounds of the scale
*
* A branch keeps its follow-up questions when it loses its last condition.
*
* @param array<string, mixed> $extraSettings the extra settings of a linear scale question
* @return array<string, mixed>
*/
public static function removeValuesOutsideScale(array $extraSettings): array {
[$lowest, $highest] = self::getScaleBounds($extraSettings);
return self::mapConditions(
$extraSettings,
static fn (array $condition) => ($condition['type'] ?? null) === Constants::CONDITION_TYPE_VALUE_EQUALS
&& (!is_int($condition['value'] ?? null) || $condition['value'] < $lowest || $condition['value'] > $highest) ? null : $condition,
);
}

/**
* @param array<string, mixed> $extraSettings the extra settings of a linear scale question
* @return array{int, int} the lowest and the highest value of the scale
*/
private static function getScaleBounds(array $extraSettings): array {
return [(int)($extraSettings['optionsLowest'] ?? 1), (int)($extraSettings['optionsHighest'] ?? 5)];
}

/**
* Change or remove each branch condition
*
Expand Down Expand Up @@ -1048,6 +1073,13 @@ private function getClaimedAnswer(array $condition, string $questionType, array
}
sort($combination);
return 'option-' . implode('-', $combination);
case Constants::CONDITION_TYPE_VALUE_EQUALS:
[$lowest, $highest] = self::getScaleBounds($extraSettings);
if ($keys !== ['type', 'value'] || !is_int($condition['value'])
|| $condition['value'] < $lowest || $condition['value'] > $highest) {
return false;
}
return 'value-' . $condition['value'];
case Constants::CONDITION_TYPE_DATE_RANGE:
$limits = array_values(array_diff($keys, ['type']));
if ($limits === [] || array_diff($limits, ['max', 'min']) !== []) {
Expand Down
2 changes: 2 additions & 0 deletions lib/Service/SubmissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,8 @@ private function matchesCondition(array $condition, array $triggerAnswer): bool
switch ($condition['type'] ?? null) {
case Constants::CONDITION_TYPE_OPTION_SELECTED:
return is_scalar($condition['optionId'] ?? null) && in_array((string)$condition['optionId'], $values, true);
case Constants::CONDITION_TYPE_VALUE_EQUALS:
return is_numeric($condition['value'] ?? null) && is_numeric($values[0] ?? null) && (int)$values[0] === (int)$condition['value'];
case Constants::CONDITION_TYPE_DATE_RANGE:
$value = $values[0] ?? '';
return $value !== ''
Expand Down
3 changes: 3 additions & 0 deletions src/components/Conditional/QuestionBranches.vue
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,9 @@ export default defineComponent({
if (condition.type === 'options_combination') {
return condition.optionIds.map(optionText).join(' + ')
}
if (condition.type === 'value_equals') {
return String(condition.value)
}
if (condition.type === 'date_range') {
return t('forms', '{min} to {max}', {
min: condition.min ?? '…',
Expand Down
100 changes: 94 additions & 6 deletions src/components/Questions/QuestionLinearScale.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@
<NcIconSvgWrapper :svg="IconPencil" />
</template>
</NcActionInput>
<NcActionCheckbox
v-if="canBranch"
:modelValue="branchesEnabled"
@update:modelValue="toggleBranches">
{{ t('forms', 'Follow-up questions') }}
</NcActionCheckbox>
</template>

<div
Expand Down Expand Up @@ -128,7 +134,23 @@
{{ optionsLabelHighest }}
</div>
</div>
<ul v-if="showTargets" class="question-linear-scale__targets">
<li
v-for="option in scaleOptions"
:key="option"
class="question-linear-scale__target">
<span>{{ getValueText(option) }}</span>
<BranchTargetSelect
:modelValue="getTarget({ type: 'value_equals', value: option })"
:options="targetOptions"
:ariaLabel="getTargetLabel(getValueText(option))"
@update:modelValue="
setTarget({ type: 'value_equals', value: option }, $event)
" />
</li>
</ul>
<template #insert>
<slot name="branches" />
<slot name="insert" />
</template>
</Question>
Expand All @@ -138,17 +160,21 @@
import IconPencil from '@material-symbols/svg-400/outlined/edit.svg?raw'
import { t } from '@nextcloud/l10n'
import { computed, defineComponent, nextTick, onMounted, ref } from 'vue'
import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox'
import NcActionInput from '@nextcloud/vue/components/NcActionInput'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
import BranchTargetSelect from '../Conditional/BranchTargetSelect.vue'
import Question from './Question.vue'
import { useBranches } from '../../composables/useBranches.ts'
import {
QUESTION_EMITS,
QUESTION_PROPS,
questionIdPrefix,
useQuestion,
} from '../../composables/useQuestion.ts'
import { removeValuesOutsideScale } from '../../utils/Branches.ts'

type LinearScaleExtraSettings = {
optionsLowest?: number | null
Expand All @@ -161,7 +187,9 @@ export default defineComponent({
name: 'QuestionLinearScale',

components: {
BranchTargetSelect,
NcIconSvgWrapper,
NcActionCheckbox,
NcActionInput,
NcCheckboxRadioSwitch,
NcTextArea,
Expand All @@ -188,14 +216,41 @@ export default defineComponent({
const defaultLowestLabel = t('forms', 'Strongly disagree')
const defaultHighestLabel = t('forms', 'Strongly agree')

const branches = useBranches(props, emit)
const canBranch = computed(() => props.parentQuestionId === null)
const showTargets = computed(
() =>
canBranch.value && !props.readOnly && branches.branchesEnabled.value,
)

/**
* Change the bounds of the scale, the conditions on values outside them are removed right away
*
* @param lowest the lowest value
* @param highest the highest value
*/
const changeBounds = (lowest: number, highest: number): void => {
if (branches.branchesEnabled.value) {
branches.updateBranches(
removeValuesOutsideScale(
branches.branches.value,
lowest,
highest,
),
)
}
question.onExtraSettingsChange({
optionsLowest: lowest === 1 ? null : lowest,
optionsHighest: highest === 5 ? null : highest,
})
}

const optionsLowest = computed<number>({
get: () => {
return extraSettings.value.optionsLowest ?? 1
},
set: (value: number) => {
question.onExtraSettingsChange({
optionsLowest: value === 1 ? null : value,
})
changeBounds(value, extraSettings.value.optionsHighest ?? 5)
},
})

Expand All @@ -204,9 +259,7 @@ export default defineComponent({
return extraSettings.value.optionsHighest ?? 5
},
set: (value: number) => {
question.onExtraSettingsChange({
optionsHighest: value === 5 ? null : value,
})
changeBounds(optionsLowest.value, value)
},
})

Expand Down Expand Up @@ -242,6 +295,21 @@ export default defineComponent({

const questionValues = computed(() => values.value)

/**
* A scale value with the label of its end of the scale, e.g. "1 - Strongly disagree"
*
* @param option the value
*/
const getValueText = (option: number): string => {
if (option === optionsLowest.value && optionsLabelLowest.value) {
return `${option} - ${optionsLabelLowest.value}`
}
if (option === optionsHighest.value && optionsLabelHighest.value) {
return `${option} - ${optionsLabelHighest.value}`
}
return String(option)
}

/**
* ID for the label for the lowest option
*/
Expand Down Expand Up @@ -332,6 +400,10 @@ export default defineComponent({

return {
...question,
...branches,
canBranch,
getValueText,
showTargets,
rootElement,
IconPencil,
t,
Expand Down Expand Up @@ -360,6 +432,22 @@ export default defineComponent({
</script>

<style lang="scss" scoped>
.question-linear-scale__targets {
display: flex;
flex-direction: column;
gap: var(--default-grid-baseline);
margin-top: calc(2 * var(--default-grid-baseline));
}

.question-linear-scale__target {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--default-grid-baseline);
container-type: inline-size;
}

.question__content {
display: flex;

Expand Down
1 change: 1 addition & 0 deletions src/types/Entities.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type FormsBranchCondition =
| { type: 'other_selected' }
| { type: 'options_combination'; optionIds: number[] }
| { type: 'date_range'; min?: string; max?: string }
| { type: 'value_equals'; value: number }

export interface FormsConditionalBranch {
id: string
Expand Down
27 changes: 27 additions & 0 deletions src/utils/Branches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const QUESTION_TYPES_WITH_BRANCHES: Record<
multiple: ['options_combination', 'other_selected'],
date: ['date_range'],
time: ['date_range'],
linearscale: ['value_equals'],
}

/**
Expand All @@ -51,6 +52,8 @@ const CONDITION_MATCHERS: Record<
!!values[0]
&& (condition.min === undefined || values[0] >= String(condition.min))
&& (condition.max === undefined || values[0] <= String(condition.max)),
value_equals: (condition, values) =>
!!values[0] && Number(values[0]) === Number(condition.value),
other_selected: (condition, values) =>
values.some(
(value) =>
Expand Down Expand Up @@ -168,6 +171,9 @@ function isSameAnswer(a: FormsBranchCondition, b: FormsBranchCondition): boolean
if (a.type === 'option_selected' && b.type === 'option_selected') {
return a.optionId === b.optionId
}
if (a.type === 'value_equals' && b.type === 'value_equals') {
return a.value === b.value
}
if (a.type === 'date_range' && b.type === 'date_range') {
return a.min === b.min && a.max === b.max
}
Expand Down Expand Up @@ -299,6 +305,27 @@ export function removeOptionFromConditions(
)
}

/**
* Remove the conditions on scale values outside the bounds of the scale
*
* @param branches the branches of the question
* @param lowest the lowest value of the scale
* @param highest the highest value of the scale
* @return the new branches
*/
export function removeValuesOutsideScale(
branches: FormsConditionalBranch[],
lowest: number,
highest: number,
): FormsConditionalBranch[] {
return mapConditions(branches, (condition) =>
condition.type === 'value_equals'
&& (condition.value < lowest || condition.value > highest)
? null
: condition,
)
}

/**
* Remove the conditions on the answer "Other" from the branches
*
Expand Down
20 changes: 20 additions & 0 deletions tests/Unit/Controller/ApiControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3614,6 +3614,26 @@ public function testDeleteOptionRemovesCombinationsWithIt(): void {
$this->apiController->deleteOption(1, 100, 10);
}

public function testUpdateQuestionRemovesScaleValuesOutsideTheScale(): void {
$this->mockEditableForm();
$this->formsService->method('areExtraSettingsValid')->willReturn(true);
$question = $this->createQuestionWithBranches();
$question->setType(Constants::ANSWER_TYPE_LINEARSCALE);
$this->questionMapper->method('findById')->with(100)->willReturn($question);
$this->questionMapper->method('findByParentQuestion')->willReturn([]);
$this->questionMapper->expects($this->once())
->method('update')
->with($this->callback(fn (Question $question): bool => $question->getExtraSettings()['branches'] === [
['id' => 'branch-a', 'conditions' => [['type' => 'value_equals', 'value' => 2]]],
['id' => 'branch-b', 'conditions' => []],
]));

$this->apiController->updateQuestion(1, 100, ['extraSettings' => ['optionsLowest' => 2, 'optionsHighest' => 3, 'branches' => [
['id' => 'branch-a', 'conditions' => [['type' => 'value_equals', 'value' => 1], ['type' => 'value_equals', 'value' => 2]]],
['id' => 'branch-b', 'conditions' => [['type' => 'value_equals', 'value' => 5]]],
]]]);
}

public function testUpdateQuestionRemovedBranchDeletesSubquestions(): void {
$this->mockEditableForm();
$this->formsService->method('areExtraSettingsValid')->willReturn(true);
Expand Down
Loading
Loading