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
15 changes: 15 additions & 0 deletions docs/DataStructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,9 @@ 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 |
| `date` | `date_range` | Date - range including its bounds (`YYYY-MM-DD`) |
| `datetime` | `date_range` | Deprecated date and time (`YYYY-MM-DD HH:mm`) |
| `time` | `date_range` | Time - range including its bounds (`HH:mm`) |

A branch is shown when any of its conditions matches. Several branches can be shown at once.

Expand Down Expand Up @@ -357,6 +360,18 @@ Conditions determine when a branch is activated. The structure depends on the tr

All options in `optionIds` must be selected for the condition to match.

#### date_range (for date, datetime, time)

```json
{ "type": "date_range", "min": "2024-01-01", "max": "2024-12-31" }
```

```json
{ "type": "date_range", "min": "09:00", "max": "17:00" }
```

`min` and `max` use the answer format of the trigger type and are optional.

### Conditional Question Example

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

/**
* Trigger types allowed for conditional questions
Expand All @@ -270,6 +271,9 @@ class Constants {
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_DATE => [self::CONDITION_TYPE_DATE_RANGE],
self::ANSWER_TYPE_DATETIME => [self::CONDITION_TYPE_DATE_RANGE],
self::ANSWER_TYPE_TIME => [self::CONDITION_TYPE_DATE_RANGE],
];

/**
Expand Down
30 changes: 30 additions & 0 deletions lib/Service/SubmissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -1159,6 +1159,36 @@ private function evaluateBranchConditions(string $triggerType, array $triggerAns
}
}
return false;
case Constants::ANSWER_TYPE_DATE:
case Constants::ANSWER_TYPE_DATETIME:
case Constants::ANSWER_TYPE_TIME:
// Date range conditions
$dateValue = $triggerAnswer[0] ?? '';
if (empty($dateValue)) {
return false;
}
$format = Constants::ANSWER_PHPDATETIME_FORMAT[$triggerType] ?? 'Y-m-d';
$date = \DateTime::createFromFormat('!' . $format, $dateValue);
if (!$date) {
return false;
}

foreach ($conditions as $condition) {
$min = isset($condition['min']) ? \DateTime::createFromFormat('!' . $format, $condition['min']) : null;
$max = isset($condition['max']) ? \DateTime::createFromFormat('!' . $format, $condition['max']) : null;

$inRange = true;
if ($min && $date < $min) {
$inRange = false;
}
if ($max && $date > $max) {
$inRange = false;
}
if ($inRange) {
return true;
}
}
return false;
default:
return false;
}
Expand Down
162 changes: 162 additions & 0 deletions src/components/Conditional/BranchConditionEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,39 @@
@update:modelValue="onMultipleOptionsSelect" />
</div>
</template>

<!-- Date/time-based conditions -->
<template v-else-if="isDateBasedTrigger">
<div class="condition-row">
<NcDateTimePicker
v-model="conditionDateMin"
:type="datePickerType"
:placeholder="t('forms', 'From')"
:ariaLabel="t('forms', 'From')"
class="condition-date-input" />
<span class="condition-range-separator">-</span>
<NcDateTimePicker
v-model="conditionDateMax"
:type="datePickerType"
:placeholder="t('forms', 'To')"
:ariaLabel="t('forms', 'To')"
class="condition-date-input" />
</div>
</template>
</div>
</template>

<script>
import { t } from '@nextcloud/l10n'
import moment from '@nextcloud/moment'
import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePicker'
import NcSelect from '@nextcloud/vue/components/NcSelect'

export default {
name: 'BranchConditionEditor',

components: {
NcDateTimePicker,
NcSelect,
},

Expand Down Expand Up @@ -90,6 +112,13 @@ export default {
return ['multiple_unique', 'dropdown'].includes(this.triggerType)
},

/**
* Check if trigger is date-based (date, datetime, time)
*/
isDateBasedTrigger() {
return ['date', 'datetime', 'time'].includes(this.triggerType)
},

/**
* Label for option-based conditions
*/
Expand Down Expand Up @@ -132,9 +161,112 @@ export default {

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

/**
* Current condition type from branch
*/
conditionType: {
get() {
return this.branch.conditions?.[0]?.type || this.defaultConditionType
},

set(value) {
this.updateCondition({ type: value })
},
},

/**
* Default condition type based on trigger
*/
defaultConditionType() {
if (this.isDateBasedTrigger) return 'date_range'
return null
},

/**
* Storage format of date and time conditions, the same as for answers
*/
dateStorageFormat() {
return this.triggerType === 'time' ? 'HH:mm' : 'YYYY-MM-DD'
},

conditionDateMin: {
get() {
return this.parseDate(this.conditionMin)
},

set(value) {
this.conditionMin = this.formatDate(value)
},
},

conditionDateMax: {
get() {
return this.parseDate(this.conditionMax)
},

set(value) {
this.conditionMax = this.formatDate(value)
},
},

/**
* Condition min value (for ranges)
*/
conditionMin: {
get() {
return this.branch.conditions?.[0]?.min || ''
},

set(value) {
this.updateCondition({ min: value })
},
},

/**
* Condition max value (for ranges)
*/
conditionMax: {
get() {
return this.branch.conditions?.[0]?.max || ''
},

set(value) {
this.updateCondition({ max: value })
},
},

/**
* Date picker type based on trigger type
*/
datePickerType() {
if (this.triggerType === 'time') return 'time'
if (this.triggerType === 'datetime') return 'datetime'
return 'date'
},
},

methods: {
/**
* @param {string} value stored date or time
* @return {Date|null} the date for the picker
*/
parseDate(value) {
if (!value) {
return null
}
const date = moment(value, this.dateStorageFormat, true)
return date.isValid() ? date.toDate() : null
},

/**
* @param {Date|null} value date from the picker
* @return {string} the date in the storage format of answers
*/
formatDate(value) {
return value ? moment(value).format(this.dateStorageFormat) : ''
},

/**
* Handle single option selection (radio/dropdown)
*
Expand All @@ -156,6 +288,21 @@ export default {
this.emitUpdate({ conditions })
},

/**
* Update a condition property
*
* @param {object} updates The condition properties to update
*/
updateCondition(updates) {
const currentCondition = this.branch.conditions?.[0] || {}
const newCondition = {
...currentCondition,
type: this.conditionType,
...updates,
}
this.emitUpdate({ conditions: [newCondition] })
},

/**
* Emit branch update
*
Expand All @@ -179,4 +326,19 @@ export default {
.condition-options {
max-width: 400px;
}

.condition-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}

.condition-range-separator {
color: var(--color-text-maxcontrast);
}

.condition-date-input {
flex: 1;
}
</style>
22 changes: 22 additions & 0 deletions src/components/Questions/QuestionConditional.vue
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,10 @@ export default defineComponent({
label: t('forms', 'Choice'),
types: ['multiple_unique', 'multiple', 'dropdown'],
},
{
label: t('forms', 'Date and time'),
types: ['date', 'time'],
},
]

return groups.map((group) => ({
Expand Down Expand Up @@ -964,6 +968,24 @@ export default defineComponent({
return false
}

if (['date', 'time'].includes(this.triggerType)) {
const dateValue = this.triggerValues[0] || ''
if (!dateValue) {
return false
}
return branch.conditions.some((c) => {
const min = c.min
const max = c.max
if (min && dateValue < min) {
return false
}
if (max && dateValue > max) {
return false
}
return true
})
}

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 @@ -1936,6 +1936,16 @@ public static function dataAreExtraSettingsValid() {
'questionType' => Constants::ANSWER_TYPE_CONDITIONAL,
'expected' => true
],
'valid-conditional-date-trigger' => [
'extraSettings' => [
'triggerType' => Constants::ANSWER_TYPE_DATE,
'branches' => [
['id' => 'branch-1', 'conditions' => [['type' => 'date_range', 'min' => '2024-01-01', 'max' => '2024-12-31']]]
]
],
'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
],
'valid-conditional-time-trigger' => [
'extraSettings' => [
'triggerType' => Constants::ANSWER_TYPE_TIME,
'branches' => [
['id' => 'branch-1', 'conditions' => [['type' => 'date_range', 'min' => '08:00', 'max' => '12:00']]]
]
],
'questionType' => Constants::ANSWER_TYPE_CONDITIONAL,
'expected' => true
],
'invalid-conditional-option-id-not-a-number' => [
'extraSettings' => [
'triggerType' => Constants::ANSWER_TYPE_DROPDOWN,
Expand Down
25 changes: 25 additions & 0 deletions tests/Unit/Service/SubmissionServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1855,6 +1855,31 @@ public static function dataValidateSubmission() {
// Expected Result
'Invalid answer for conditional question.',
],
'invalid-conditional-date-range-includes-its-bounds' => [
// Questions - date range of a single day
[
[
'id' => 100,
'type' => Constants::ANSWER_TYPE_CONDITIONAL,
'text' => 'Date Conditional',
'isRequired' => false,
'extraSettings' => [
'triggerType' => Constants::ANSWER_TYPE_DATE,
'branches' => [
[
'id' => 'branch-day',
'conditions' => [['type' => 'date_range', 'min' => '2026-11-04', 'max' => '2026-11-04']],
'subQuestions' => [['id' => 101, 'type' => 'short', 'text' => 'On that day', 'isRequired' => true]],
],
],
],
],
],
// Answers - the day itself, required subquestion not answered
['100' => ['trigger' => ['2026-11-04'], 'subQuestions' => []]],
// Expected Result
'Question "On that day" is required.',
],
];
}

Expand Down
Loading