Skip to content

Commit b2310a2

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 b2310a2

13 files changed

+350
-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

+138-7
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,35 @@
99
:title-placeholder="answerType.titlePlaceholder"
1010
:warning-invalid="answerType.warningInvalid"
1111
v-on="commonListeners">
12+
<template v-if="answerType.pickerType === 'date'" #actions>
13+
<NcActionCheckbox v-model="dateRange">
14+
{{ t('forms', 'Use date range') }}
15+
</NcActionCheckbox>
16+
<NcActionInput
17+
v-model="dateMin"
18+
type="date"
19+
:label="t('forms', 'Earliest date')"
20+
hide-label
21+
:formatter="extraSettingsFormatter"
22+
is-native-picker
23+
:max="dateMax">
24+
<template #icon>
25+
<Pencil :size="20" />
26+
</template>
27+
</NcActionInput>
28+
<NcActionInput
29+
v-model="dateMax"
30+
type="date"
31+
:label="t('forms', 'Latest date')"
32+
hide-label
33+
:formatter="extraSettingsFormatter"
34+
is-native-picker
35+
:min="dateMin">
36+
<template #icon>
37+
<Pencil :size="20" />
38+
</template>
39+
</NcActionInput>
40+
</template>
1241
<div class="question__content">
1342
<NcDateTimePicker
1443
:value="time"
@@ -17,23 +46,31 @@
1746
:placeholder="datetimePickerPlaceholder"
1847
:show-second="false"
1948
:type="answerType.pickerType"
49+
:disabled-date="disabledDates"
2050
:input-attr="inputAttr"
51+
:range="extraSettings?.dateRange"
52+
range-separator=" - "
2153
@change="onValueChange" />
2254
</div>
2355
</Question>
2456
</template>
2557

2658
<script>
2759
import moment from '@nextcloud/moment'
28-
2960
import QuestionMixin from '../../mixins/QuestionMixin.js'
61+
import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox'
62+
import NcActionInput from '@nextcloud/vue/components/NcActionInput'
3063
import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePicker'
64+
import Pencil from 'vue-material-design-icons/Pencil.vue'
3165

3266
export default {
3367
name: 'QuestionDate',
3468

3569
components: {
70+
NcActionCheckbox,
71+
NcActionInput,
3672
NcDateTimePicker,
73+
Pencil,
3774
},
3875

3976
mixins: [QuestionMixin],
@@ -44,15 +81,23 @@ export default {
4481
stringify: this.stringify,
4582
parse: this.parse,
4683
},
84+
extraSettingsFormatter: {
85+
stringify: this.stringifyDate,
86+
parse: this.parseTimestampToDate,
87+
},
4788
}
4889
},
4990

5091
computed: {
5192
datetimePickerPlaceholder() {
5293
if (this.readOnly) {
53-
return this.answerType.submitPlaceholder
94+
return this.extraSettings?.dateRange
95+
? this.answerType.submitPlaceholderRange
96+
: this.answerType.submitPlaceholder
5497
}
55-
return this.answerType.createPlaceholder
98+
return this.extraSettings?.dateRange
99+
? this.answerType.createPlaceholderRange
100+
: this.answerType.createPlaceholder
56101
},
57102

58103
/**
@@ -68,8 +113,54 @@ export default {
68113
},
69114

70115
time() {
116+
if (this.extraSettings?.dateRange) {
117+
return this.values
118+
? [this.parse(this.values[0]), this.parse(this.values[1])]
119+
: null
120+
}
71121
return this.values ? this.parse(this.values[0]) : null
72122
},
123+
124+
/**
125+
* The maximum allowable date for the date input field
126+
*/
127+
dateMax: {
128+
get() {
129+
return this.extraSettings?.dateMax
130+
? moment(this.extraSettings.dateMax, 'X').toDate()
131+
: null
132+
},
133+
set(value) {
134+
this.onExtraSettingsChange({
135+
dateMax: parseInt(moment(value).format('X')),
136+
})
137+
},
138+
},
139+
140+
/**
141+
* The minimum allowable date for the date input field
142+
*/
143+
dateMin: {
144+
get() {
145+
return this.extraSettings?.dateMin
146+
? moment(this.extraSettings.dateMin, 'X').toDate()
147+
: null
148+
},
149+
set(value) {
150+
this.onExtraSettingsChange({
151+
dateMin: parseInt(moment(value).format('X')),
152+
})
153+
},
154+
},
155+
156+
dateRange: {
157+
get() {
158+
return this.extraSettings?.dateRange ?? false
159+
},
160+
set(value) {
161+
this.onExtraSettingsChange({ dateRange: value === true ?? null })
162+
},
163+
},
73164
},
74165

75166
methods: {
@@ -99,12 +190,52 @@ export default {
99190
/**
100191
* Store Value
101192
*
102-
* @param {Date} date The date to store
193+
* @param {Date|Array<Date>} date The date or date range to store
103194
*/
104195
onValueChange(date) {
105-
this.$emit('update:values', [
106-
moment(date).format(this.answerType.storageFormat),
107-
])
196+
if (this.extraSettings?.dateRange) {
197+
this.$emit('update:values', [
198+
moment(date[0]).format(this.answerType.storageFormat),
199+
moment(date[1]).format(this.answerType.storageFormat),
200+
])
201+
} else {
202+
this.$emit('update:values', [
203+
moment(date).format(this.answerType.storageFormat),
204+
])
205+
}
206+
},
207+
208+
/**
209+
* Determines if a given date should be disabled.
210+
*
211+
* @param {Date} date - The date to check.
212+
* @return {boolean} - Returns true if the date should be disabled, otherwise false.
213+
*/
214+
disabledDates(date) {
215+
return (
216+
(this.dateMin && date < this.dateMin) ||
217+
(this.dateMax && date > this.dateMax)
218+
)
219+
},
220+
221+
/**
222+
* Datepicker timestamp to string
223+
*
224+
* @param {Date} datetime the datepicker Date
225+
* @return {string}
226+
*/
227+
stringifyDate(datetime) {
228+
return moment(datetime).format('L')
229+
},
230+
231+
/**
232+
* Form expires timestamp to Date of the datepicker
233+
*
234+
* @param {number} value the expires timestamp
235+
* @return {Date}
236+
*/
237+
parseTimestampToDate(value) {
238+
return moment(value, 'X').toDate()
108239
},
109240
},
110241
}

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)