Skip to content

Commit db7bee7

Browse files
committed
feat(date): add dateMin, dateMax and dateRange to question settings
This adds the possibility to add restrictions to the selectable date and to ask for a date range Signed-off-by: Christian Hartmann <[email protected]>
1 parent 87ed14d commit db7bee7

13 files changed

+352
-43
lines changed

docs/DataStructure.md

+3
Original file line numberDiff line numberDiff line change
@@ -226,3 +226,6 @@ Optional extra settings for some [Question Types](#question-types)
226226
| `allowedFileExtensions` | `file` | Array of strings | `'jpg', 'png'` | Allowed file extensions for file upload |
227227
| `maxAllowedFilesCount` | `file` | Integer | - | Maximum number of files that can be uploaded, 0 means no limit |
228228
| `maxFileSize` | `file` | Integer | - | Maximum file size in bytes, 0 means no limit |
229+
| `dateMax` | `date` | Integer | - | Maximum allowed date to be chosen (as Unix timestamp) |
230+
| `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

+6
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@ class Constants {
147147
'maxFileSize' => ['integer'],
148148
];
149149

150+
public const EXTRA_SETTINGS_DATE = [
151+
'dateMax' => ['integer', 'NULL'],
152+
'dateMin' => ['integer', 'NULL'],
153+
'dateRange' => ['boolean', 'NULL'],
154+
];
155+
150156
// should be in sync with FileTypes.js
151157
public const EXTRA_SETTINGS_ALLOWED_FILE_TYPES = [
152158
'image',

lib/ResponseDefinitions.php

+2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
* allowOtherAnswer?: bool,
2424
* allowedFileExtensions?: list<string>,
2525
* allowedFileTypes?: list<string>,
26+
* dateMax?: int,
27+
* dateMin?: int,
2628
* maxAllowedFilesCount?: int,
2729
* maxFileSize?: int,
2830
* optionsLimitMax?: int,

lib/Service/FormsService.php

+11-1
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,9 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType
629629
case Constants::ANSWER_TYPE_FILE:
630630
$allowed = Constants::EXTRA_SETTINGS_FILE;
631631
break;
632+
case Constants::ANSWER_TYPE_DATE:
633+
$allowed = Constants::EXTRA_SETTINGS_DATE;
634+
break;
632635
default:
633636
$allowed = [];
634637
}
@@ -646,7 +649,14 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType
646649
}
647650
}
648651

649-
if ($questionType === Constants::ANSWER_TYPE_MULTIPLE) {
652+
// Validate extraSettings for specific question types
653+
if ($questionType === Constants::ANSWER_TYPE_DATE) {
654+
// Ensure dateMin and dateMax don't overlap
655+
if (isset($extraSettings['dateMin']) && isset($extraSettings['dateMax'])
656+
&& $extraSettings['dateMin'] > $extraSettings['dateMax']) {
657+
return false;
658+
}
659+
} elseif ($questionType === Constants::ANSWER_TYPE_MULTIPLE) {
650660
// Ensure limits are sane
651661
if (isset($extraSettings['optionsLimitMax']) && isset($extraSettings['optionsLimitMin'])
652662
&& $extraSettings['optionsLimitMax'] < $extraSettings['optionsLimitMin']) {

lib/Service/SubmissionService.php

+34-11
Original file line numberDiff line numberDiff line change
@@ -374,18 +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
*/
386-
if (in_array($question['type'], Constants::ANSWER_TYPES_DATETIME) &&
387-
!$this->validateDateTime($answers[$questionId][0], Constants::ANSWER_PHPDATETIME_FORMAT[$question['type']])) {
388-
throw new \InvalidArgumentException(sprintf('Invalid date/time format for question "%s".', $question['text']));
390+
if (in_array($question['type'], Constants::ANSWER_TYPES_DATETIME)) {
391+
$this->validateDateTime($answers[$questionId], Constants::ANSWER_PHPDATETIME_FORMAT[$question['type']], $question['text'] ?? null, $question['extraSettings'] ?? null);
389392
}
390393

391394
// Check if all answers are within the possible options
@@ -434,13 +437,33 @@ public function validateSubmission(array $questions, array $answers, string $for
434437

435438
/**
436439
* Validate correct date/time formats
437-
* @param string $dateStr String with date from answer
440+
* @param array $answers Array with date from answer
438441
* @param string $format String with the format to validate
439-
* @return boolean If the submitted date/time is valid
442+
* @param string|null $text String with the title of the question
443+
* @param array|null $extraSettings Array with extra settings for validation
440444
*/
441-
private function validateDateTime(string $dateStr, string $format) {
442-
$d = DateTime::createFromFormat($format, $dateStr);
443-
return $d && $d->format($format) === $dateStr;
445+
private function validateDateTime(array $answers, string $format, ?string $text = null, ?array $extraSettings = null): void {
446+
$previousDate = null;
447+
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+
}
465+
}
466+
}
444467
}
445468

446469
/**

openapi.json

+8
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,14 @@
418418
"type": "string"
419419
}
420420
},
421+
"dateMax": {
422+
"type": "integer",
423+
"format": "int64"
424+
},
425+
"dateMin": {
426+
"type": "integer",
427+
"format": "int64"
428+
},
421429
"maxAllowedFilesCount": {
422430
"type": "integer",
423431
"format": "int64"

src/components/Questions/QuestionDate.vue

+142-7
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,37 @@
99
:title-placeholder="answerType.titlePlaceholder"
1010
:warning-invalid="answerType.warningInvalid"
1111
v-on="commonListeners">
12+
<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>
18+
<NcActionInput
19+
v-model="dateMin"
20+
type="date"
21+
:label="t('forms', 'Pick minimum date')"
22+
hide-label
23+
:formatter="extraSettingsFormatter"
24+
is-native-picker
25+
:max="dateMax">
26+
<template #icon>
27+
<Pencil :size="20" />
28+
</template>
29+
</NcActionInput>
30+
<NcActionInput
31+
v-model="dateMax"
32+
type="date"
33+
:label="t('forms', 'Pick maximum date')"
34+
hide-label
35+
:formatter="extraSettingsFormatter"
36+
is-native-picker
37+
:min="dateMin">
38+
<template #icon>
39+
<Pencil :size="20" />
40+
</template>
41+
</NcActionInput>
42+
</template>
1243
<div class="question__content">
1344
<NcDateTimePicker
1445
:value="time"
@@ -17,23 +48,31 @@
1748
:placeholder="datetimePickerPlaceholder"
1849
:show-second="false"
1950
:type="answerType.pickerType"
51+
:disabled-date="disabledDates"
2052
:input-attr="inputAttr"
53+
:range="extraSettings?.dateRange"
54+
range-separator=" - "
2155
@change="onValueChange" />
2256
</div>
2357
</Question>
2458
</template>
2559

2660
<script>
2761
import moment from '@nextcloud/moment'
28-
2962
import QuestionMixin from '../../mixins/QuestionMixin.js'
63+
import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox'
64+
import NcActionInput from '@nextcloud/vue/components/NcActionInput'
3065
import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePicker'
66+
import Pencil from 'vue-material-design-icons/Pencil.vue'
3167

3268
export default {
3369
name: 'QuestionDate',
3470

3571
components: {
72+
NcActionCheckbox,
73+
NcActionInput,
3674
NcDateTimePicker,
75+
Pencil,
3776
},
3877

3978
mixins: [QuestionMixin],
@@ -44,15 +83,23 @@ export default {
4483
stringify: this.stringify,
4584
parse: this.parse,
4685
},
86+
extraSettingsFormatter: {
87+
stringify: this.stringifyDate,
88+
parse: this.parseTimestampToDate,
89+
},
4790
}
4891
},
4992

5093
computed: {
5194
datetimePickerPlaceholder() {
5295
if (this.readOnly) {
53-
return this.answerType.submitPlaceholder
96+
return this.question.extraSettings?.dateRange
97+
? this.answerType.submitPlaceholderRange
98+
: this.answerType.submitPlaceholder
5499
}
55-
return this.answerType.createPlaceholder
100+
return this.question.extraSettings?.dateRange
101+
? this.answerType.createPlaceholderRange
102+
: this.answerType.createPlaceholder
56103
},
57104

58105
/**
@@ -68,8 +115,45 @@ export default {
68115
},
69116

70117
time() {
118+
if (this.extraSettings?.dateRange) {
119+
return this.values
120+
? [this.parse(this.values[0]), this.parse(this.values[1])]
121+
: null
122+
}
71123
return this.values ? this.parse(this.values[0]) : null
72124
},
125+
126+
/**
127+
* The maximum allowable date for the date input field
128+
*/
129+
dateMax: {
130+
get() {
131+
return this.extraSettings?.dateMax
132+
? moment(this.extraSettings.dateMax, 'X').toDate()
133+
: null
134+
},
135+
set(value) {
136+
this.onExtraSettingsChange({
137+
dateMax: parseInt(moment(value).format('X')),
138+
})
139+
},
140+
},
141+
142+
/**
143+
* The minimum allowable date for the date input field
144+
*/
145+
dateMin: {
146+
get() {
147+
return this.extraSettings?.dateMin
148+
? moment(this.extraSettings.dateMin, 'X').toDate()
149+
: null
150+
},
151+
set(value) {
152+
this.onExtraSettingsChange({
153+
dateMin: parseInt(moment(value).format('X')),
154+
})
155+
},
156+
},
73157
},
74158

75159
methods: {
@@ -99,12 +183,63 @@ export default {
99183
/**
100184
* Store Value
101185
*
102-
* @param {Date} date The date to store
186+
* @param {Date|Array<Date>} date The date or date range to store
103187
*/
104188
onValueChange(date) {
105-
this.$emit('update:values', [
106-
moment(date).format(this.answerType.storageFormat),
107-
])
189+
if (this.extraSettings?.dateRange) {
190+
this.$emit('update:values', [
191+
moment(date[0]).format(this.answerType.storageFormat),
192+
moment(date[1]).format(this.answerType.storageFormat),
193+
])
194+
} else {
195+
this.$emit('update:values', [
196+
moment(date).format(this.answerType.storageFormat),
197+
])
198+
}
199+
},
200+
201+
/**
202+
* Handles the change event for the date range setting.
203+
*
204+
* @param {boolean} checked - Indicates whether the date range option is enabled (true) or disabled (false).
205+
* Updates the extra settings with the date range value. If `checked` is true, the date range is set;
206+
* otherwise, it is set to null.
207+
*/
208+
onDateRangeChange(checked) {
209+
this.onExtraSettingsChange({ dateRange: checked === true ?? null })
210+
},
211+
212+
/**
213+
* Determines if a given date should be disabled.
214+
*
215+
* @param {Date} date - The date to check.
216+
* @return {boolean} - Returns true if the date should be disabled, otherwise false.
217+
*/
218+
disabledDates(date) {
219+
return (
220+
(this.dateMin && date < this.dateMin) ||
221+
(this.dateMax && date > this.dateMax)
222+
)
223+
},
224+
225+
/**
226+
* Datepicker timestamp to string
227+
*
228+
* @param {Date} datetime the datepicker Date
229+
* @return {string}
230+
*/
231+
stringifyDate(datetime) {
232+
return moment(datetime).format('L')
233+
},
234+
235+
/**
236+
* Form expires timestamp to Date of the datepicker
237+
*
238+
* @param {number} value the expires timestamp
239+
* @return {Date}
240+
*/
241+
parseTimestampToDate(value) {
242+
return moment(value, 'X').toDate()
108243
},
109244
},
110245
}

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">

0 commit comments

Comments
 (0)