Skip to content

Commit 88a2440

Browse files
committed
feat(date): add dateRange option to question settings and update date handling logic
Signed-off-by: Christian Hartmann <[email protected]>
1 parent a1a2295 commit 88a2440

File tree

9 files changed

+185
-50
lines changed

9 files changed

+185
-50
lines changed

docs/DataStructure.md

+1
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,4 @@ Optional extra settings for some [Question Types](#question-types)
228228
| `maxFileSize` | `file` | Integer | - | Maximum file size in bytes, 0 means no limit |
229229
| `dateMax` | `date` | Integer | - | Maximum allowed date to be chosen (as Unix timestamp) |
230230
| `dateMin` | `date` | Integer | - | Minimum allowed date to be chosen (as Unix timestamp) |
231+
| `dateRange` | `date` | Boolean | `true/false` | The date picker should query a date range |

lib/Constants.php

+1
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ class Constants {
150150
public const EXTRA_SETTINGS_DATE = [
151151
'dateMax' => ['integer', 'NULL'],
152152
'dateMin' => ['integer', 'NULL'],
153+
'dateRange' => ['boolean', 'NULL'],
153154
];
154155

155156
// should be in sync with FileTypes.js

lib/Service/FormsService.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,7 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType
658658
}
659659
} elseif ($questionType === Constants::ANSWER_TYPE_MULTIPLE) {
660660
// Ensure limits are sane
661-
if (isset($extraSettings['optionsLimitMax']) && isset($extraSettings['optionsLimitMin'])
661+
if (isset($extraSettings['optionsLimitMax']) && isset($extraSettings['optionsLimitMin'])
662662
&& $extraSettings['optionsLimitMax'] < $extraSettings['optionsLimitMin']) {
663663
return false;
664664
}

lib/Service/SubmissionService.php

+28-15
Original file line numberDiff line numberDiff line change
@@ -374,17 +374,21 @@ public function validateSubmission(array $questions, array $answers, string $for
374374
} elseif ($maxOptions > 0 && $answersCount > $maxOptions) {
375375
throw new \InvalidArgumentException(sprintf('Question "%s" requires at most %d answers.', $question['text'], $maxOptions));
376376
}
377-
} elseif ($answersCount > 1 && $question['type'] !== Constants::ANSWER_TYPE_FILE) {
377+
} elseif ($answersCount != 2 && $question['type'] !== Constants::ANSWER_TYPE_DATE && isset($question['extraSettings']['dateRange'])) {
378+
// Check if date range questions have exactly two answers
379+
throw new \InvalidArgumentException(sprintf('Question "%s" can only have two answers.', $question['text']));
380+
} elseif ($answersCount > 1 && $question['type'] !== Constants::ANSWER_TYPE_FILE && !($question['type'] === Constants::ANSWER_TYPE_DATE && isset($question['extraSettings']['dateRange']))) {
378381
// Check if non-multiple questions have not more than one answer
379382
throw new \InvalidArgumentException(sprintf('Question "%s" can only have one answer.', $question['text']));
380383
}
381384

382385
/*
383-
* Check if date questions have valid answers
384-
* $answers[$questionId][0] -> date/time questions can only have one answer
386+
* Validate answers for date/time questions
387+
* If a date range is specified, validate all answers in the range
388+
* Otherwise, validate the single answer for the date/time question
385389
*/
386390
if (in_array($question['type'], Constants::ANSWER_TYPES_DATETIME)) {
387-
$this->validateDateTime($answers[$questionId][0], Constants::ANSWER_PHPDATETIME_FORMAT[$question['type']], $question['text'] ?? null, $question['extraSettings'] ?? null);
391+
$this->validateDateTime($answers[$questionId], Constants::ANSWER_PHPDATETIME_FORMAT[$question['type']], $question['text'] ?? null, $question['extraSettings'] ?? null);
388392
}
389393

390394
// Check if all answers are within the possible options
@@ -433,22 +437,31 @@ public function validateSubmission(array $questions, array $answers, string $for
433437

434438
/**
435439
* Validate correct date/time formats
436-
* @param string $dateStr String with date from answer
440+
* @param array $answers Array with date from answer
437441
* @param string $format String with the format to validate
438442
* @param string|null $text String with the title of the question
439443
* @param array|null $extraSettings Array with extra settings for validation
440444
*/
441-
private function validateDateTime(string $dateStr, string $format, ?string $text = null, ?array $extraSettings = null): void {
442-
$d = DateTime::createFromFormat($format, $dateStr);
443-
if (!$d || $d->format($format) !== $dateStr) {
444-
throw new \InvalidArgumentException(sprintf('Invalid date/time format for question "%s".', $text));
445-
}
445+
private function validateDateTime(array $answers, string $format, ?string $text = null, ?array $extraSettings = null): void {
446+
$previousDate = null;
446447

447-
if ($extraSettings) {
448-
if ((isset($extraSettings['dateMin']) && $d < (new DateTime())->setTimestamp($extraSettings['dateMin'])) ||
449-
(isset($extraSettings['dateMax']) && $d > (new DateTime())->setTimestamp($extraSettings['dateMax']))
450-
) {
451-
throw new \InvalidArgumentException(sprintf('Date is not in the allowed range for question "%s".', $text));
448+
foreach ($answers as $dateStr) {
449+
$d = DateTime::createFromFormat($format, $dateStr);
450+
if (!$d || $d->format($format) !== $dateStr) {
451+
throw new \InvalidArgumentException(sprintf('Invalid date/time format for question "%s".', $text));
452+
}
453+
454+
if ($previousDate !== null && $d < $previousDate) {
455+
throw new \InvalidArgumentException(sprintf('Dates for question "%s" must be in ascending order.', $text));
456+
}
457+
$previousDate = $d;
458+
459+
if ($extraSettings) {
460+
if ((isset($extraSettings['dateMin']) && $d < (new DateTime())->setTimestamp($extraSettings['dateMin'])) ||
461+
(isset($extraSettings['dateMax']) && $d > (new DateTime())->setTimestamp($extraSettings['dateMax']))
462+
) {
463+
throw new \InvalidArgumentException(sprintf('Date is not in the allowed range for question "%s".', $text));
464+
}
452465
}
453466
}
454467
}

src/components/Questions/QuestionDate.vue

+36-4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
:warning-invalid="answerType.warningInvalid"
1111
v-on="commonListeners">
1212
<template v-if="answerType.pickerType === 'date'" #actions>
13+
<NcActionCheckbox
14+
:checked="extraSettings?.dateRange"
15+
@update:checked="onDateRangeChange">
16+
{{ t('forms', 'Query date range') }}
17+
</NcActionCheckbox>
1318
<NcActionInput
1419
v-model="dateMin"
1520
type="date"
@@ -45,6 +50,8 @@
4550
:type="answerType.pickerType"
4651
:disabled-date="disabledDates"
4752
:input-attr="inputAttr"
53+
:range="extraSettings?.dateRange"
54+
range-separator=" - "
4855
@change="onValueChange" />
4956
</div>
5057
</Question>
@@ -53,6 +60,7 @@
5360
<script>
5461
import moment from '@nextcloud/moment'
5562
import QuestionMixin from '../../mixins/QuestionMixin.js'
63+
import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox'
5664
import NcActionInput from '@nextcloud/vue/components/NcActionInput'
5765
import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePicker'
5866
import Pencil from 'vue-material-design-icons/Pencil.vue'
@@ -61,6 +69,7 @@ export default {
6169
name: 'QuestionDate',
6270

6371
components: {
72+
NcActionCheckbox,
6473
NcActionInput,
6574
NcDateTimePicker,
6675
Pencil,
@@ -102,6 +111,11 @@ export default {
102111
},
103112

104113
time() {
114+
if (this.extraSettings?.dateRange) {
115+
return this.values
116+
? [this.parse(this.values[0]), this.parse(this.values[1])]
117+
: null
118+
}
105119
return this.values ? this.parse(this.values[0]) : null
106120
},
107121

@@ -165,12 +179,30 @@ export default {
165179
/**
166180
* Store Value
167181
*
168-
* @param {Date} date The date to store
182+
* @param {Date|Array<Date>} date The date or date range to store
169183
*/
170184
onValueChange(date) {
171-
this.$emit('update:values', [
172-
moment(date).format(this.answerType.storageFormat),
173-
])
185+
if (this.extraSettings?.dateRange) {
186+
this.$emit('update:values', [
187+
moment(date[0]).format(this.answerType.storageFormat),
188+
moment(date[1]).format(this.answerType.storageFormat),
189+
])
190+
} else {
191+
this.$emit('update:values', [
192+
moment(date).format(this.answerType.storageFormat),
193+
])
194+
}
195+
},
196+
197+
/**
198+
* Handles the change event for the date range setting.
199+
*
200+
* @param {boolean} checked - Indicates whether the date range option is enabled (true) or disabled (false).
201+
* Updates the extra settings with the date range value. If `checked` is true, the date range is set;
202+
* otherwise, it is set to null.
203+
*/
204+
onDateRangeChange(checked) {
205+
this.onExtraSettingsChange({ dateRange: checked === true ?? null })
174206
},
175207

176208
/**

src/components/Results/Answer.vue

+4-4
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@
1616
:key="answer.id"
1717
class="answer__text"
1818
dir="auto">
19-
<a :href="answer.url" target="_blank"
20-
><IconFile :size="20" class="answer__text-icon" />
21-
{{ answer.text }}</a
22-
>
19+
<a :href="answer.url" target="_blank">
20+
<IconFile :size="20" class="answer__text-icon" />
21+
{{ answer.text }}
22+
</a>
2323
</p>
2424
</template>
2525
<p v-else class="answer__text" dir="auto">

src/components/Results/ResultsSummary.vue

+28-20
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,10 @@
4343
<!-- eslint-disable-next-line -->
4444
<li v-for="answer in answers" :key="answer.id" dir="auto">
4545
<template v-if="answer.url">
46-
<a :href="answer.url" target="_blank"
47-
><IconFile :size="20" class="question-summary__text-icon" />
48-
{{ answer.text }}</a
49-
>
46+
<a :href="answer.url" target="_blank">
47+
<IconFile :size="20" class="question-summary__text-icon" />
48+
{{ answer.text }}
49+
</a>
5050
</template>
5151
<template v-else>
5252
{{ answer.text }}
@@ -179,22 +179,30 @@ export default {
179179
}
180180

181181
// Add text answers
182-
answers.forEach((answer) => {
183-
if (answer.fileId) {
184-
answersModels.push({
185-
id: answer.id,
186-
text: answer.text,
187-
url: generateUrl('/f/{fileId}', {
188-
fileId: answer.fileId,
189-
}),
190-
})
191-
} else {
192-
answersModels.push({
193-
id: answer.id,
194-
text: answer.text,
195-
})
196-
}
197-
})
182+
if (this.question.type === 'date' && answers.length === 2) {
183+
// Combine the first two answers in order for date range questions
184+
answersModels.push({
185+
id: `${answers[0].id}-${answers[1].id}`,
186+
text: `${answers[0].text} - ${answers[1].text}`,
187+
})
188+
} else {
189+
answers.forEach((answer) => {
190+
if (answer.fileId) {
191+
answersModels.push({
192+
id: answer.id,
193+
text: answer.text,
194+
url: generateUrl('/f/{fileId}', {
195+
fileId: answer.fileId,
196+
}),
197+
})
198+
} else {
199+
answersModels.push({
200+
id: answer.id,
201+
text: answer.text,
202+
})
203+
}
204+
})
205+
}
198206
})
199207

200208
// Calculate no response percentage

src/components/Results/Submission.vue

+10
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,16 @@ export default {
102102
}
103103
}),
104104
})
105+
} else if (question.type === 'date') {
106+
const squashedAnswers = answers
107+
.map((answer) => answer.text)
108+
.join(' - ')
109+
110+
answeredQuestionsArray.push({
111+
id: question.id,
112+
text: question.text,
113+
squashedAnswers,
114+
})
105115
} else {
106116
const squashedAnswers = answers
107117
.map((answer) => answer.text)

tests/Unit/Service/SubmissionServiceTest.php

+76-6
Original file line numberDiff line numberDiff line change
@@ -794,17 +794,87 @@ public function dataValidateSubmission() {
794794
'invalid-date-question' => [
795795
// Questions
796796
[
797-
['id' => 1, 'type' => 'date', 'text' => 'q1', 'isRequired' => false],
798-
['id' => 2, 'type' => 'date', 'text' => 'q2', 'isRequired' => false, 'extraSettings' => ['dateMin' => 1742857200]]
797+
['id' => 1, 'type' => 'date', 'text' => 'q1', 'isRequired' => false]
799798
],
800799
// Answers
801800
[
802-
'1' => ['31.12.2022'],
803-
'2' => ['2025-03-24']
801+
'1' => ['31.12.2022']
804802
],
805803
// Expected Result
806804
'Invalid date/time format for question "q1".',
807805
],
806+
'date-out-of-range-question' => [
807+
// Questions
808+
[
809+
['id' => 1, 'type' => 'date', 'text' => 'q1', 'isRequired' => false, 'extraSettings' => ['dateMin' => 1742860800]]
810+
],
811+
// Answers
812+
[
813+
'1' => ['2025-03-24']
814+
],
815+
// Expected Result
816+
'Date is not in the allowed range for question "q1".',
817+
],
818+
'valid-date-range' => [
819+
// Questions
820+
[
821+
['id' => 1, 'type' => 'date', 'text' => 'q1', 'isRequired' => true, 'extraSettings' => ['dateRange' => true]]
822+
],
823+
// Answers
824+
[
825+
'1' => ['2023-01-01', '2023-12-31']
826+
],
827+
// Expected Result
828+
null,
829+
],
830+
'invalid-date-range-single-date' => [
831+
// Questions
832+
[
833+
['id' => 1, 'type' => 'date', 'text' => 'q1', 'isRequired' => true, 'extraSettings' => ['dateRange' => true]]
834+
],
835+
// Answers
836+
[
837+
'1' => ['2023-01-01']
838+
],
839+
// Expected Result
840+
'Question "q1" can only have two answers.',
841+
],
842+
'invalid-date-range-wrong-order' => [
843+
// Questions
844+
[
845+
['id' => 1, 'type' => 'date', 'text' => 'q1', 'isRequired' => true, 'extraSettings' => ['dateRange' => true]]
846+
],
847+
// Answers
848+
[
849+
'1' => ['2023-12-31', '2023-01-01']
850+
],
851+
// Expected Result
852+
'Dates for question "q1" must be in ascending order.',
853+
],
854+
'valid-single-date' => [
855+
// Questions
856+
[
857+
['id' => 1, 'type' => 'date', 'text' => 'q1', 'isRequired' => true]
858+
],
859+
// Answers
860+
[
861+
'1' => ['2023-01-01']
862+
],
863+
// Expected Result
864+
null,
865+
],
866+
'invalid-single-date-multiple-dates' => [
867+
// Questions
868+
[
869+
['id' => 1, 'type' => 'date', 'text' => 'q1', 'isRequired' => true]
870+
],
871+
// Answers
872+
[
873+
'1' => ['2023-01-01', '2023-12-31']
874+
],
875+
// Expected Result
876+
'Question "q1" can only have one answer.',
877+
],
808878
'full-good-submission' => [
809879
// Questions
810880
[
@@ -835,8 +905,8 @@ public function dataValidateSubmission() {
835905
['id' => 12, 'type' => 'short', 'isRequired' => false, 'extraSettings' => ['validationType' => 'phone']],
836906
['id' => 13, 'type' => 'short', 'isRequired' => false, 'extraSettings' => ['validationType' => 'regex', 'validationRegex' => '/[a-z]{3}[0-9]{3}/']],
837907
['id' => 16, 'type' => 'date', 'isRequired' => false, 'extraSettings' => [
838-
'dateMin' => 1742857200,
839-
'dateMax' => 1743030000]
908+
'dateMin' => 1742860800,
909+
'dateMax' => 1743033600]
840910
],
841911
],
842912
// Answers

0 commit comments

Comments
 (0)