Skip to content

Commit 53835cd

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 b23e596 commit 53835cd

15 files changed

+366
-44
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 |

img/event.svg

+1
Loading

img/today.svg

+1
Loading

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

+3
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
* allowOtherAnswer?: bool,
2424
* allowedFileExtensions?: list<string>,
2525
* allowedFileTypes?: list<string>,
26+
* dateMax?: int,
27+
* dateMin?: int,
28+
* dateRange?: bool,
2629
* maxAllowedFilesCount?: int,
2730
* maxFileSize?: int,
2831
* 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

+11
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,17 @@
418418
"type": "string"
419419
}
420420
},
421+
"dateMax": {
422+
"type": "integer",
423+
"format": "int64"
424+
},
425+
"dateMin": {
426+
"type": "integer",
427+
"format": "int64"
428+
},
429+
"dateRange": {
430+
"type": "boolean"
431+
},
421432
"maxAllowedFilesCount": {
422433
"type": "integer",
423434
"format": "int64"

src/components/Questions/QuestionDate.vue

+148-8
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,39 @@
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+
<NcIconSvgWrapper
26+
:svg="svgTodayIcon"
27+
:name="t('forms', 'Earliest date')" />
28+
</template>
29+
</NcActionInput>
30+
<NcActionInput
31+
v-model="dateMax"
32+
type="date"
33+
:label="t('forms', 'Latest date')"
34+
hide-label
35+
:formatter="extraSettingsFormatter"
36+
is-native-picker
37+
:min="dateMin">
38+
<template #icon>
39+
<NcIconSvgWrapper
40+
:svg="svgEventIcon"
41+
:name="t('forms', 'Latest date')" />
42+
</template>
43+
</NcActionInput>
44+
</template>
1245
<div class="question__content">
1346
<NcDateTimePicker
1447
:value="time"
@@ -17,23 +50,34 @@
1750
:placeholder="datetimePickerPlaceholder"
1851
:show-second="false"
1952
:type="answerType.pickerType"
53+
:disabled-date="disabledDates"
2054
:input-attr="inputAttr"
55+
:range="extraSettings?.dateRange"
56+
range-separator=" - "
2157
@change="onValueChange" />
2258
</div>
2359
</Question>
2460
</template>
2561

2662
<script>
27-
import moment from '@nextcloud/moment'
63+
import svgEventIcon from '../../../img/event.svg?raw'
64+
import svgTodayIcon from '../../../img/today.svg?raw'
2865

29-
import QuestionMixin from '../../mixins/QuestionMixin.js'
66+
import moment from '@nextcloud/moment'
67+
import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox'
68+
import NcActionInput from '@nextcloud/vue/components/NcActionInput'
3069
import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePicker'
70+
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
71+
import QuestionMixin from '../../mixins/QuestionMixin.js'
3172

3273
export default {
3374
name: 'QuestionDate',
3475

3576
components: {
77+
NcActionCheckbox,
78+
NcActionInput,
3679
NcDateTimePicker,
80+
NcIconSvgWrapper,
3781
},
3882

3983
mixins: [QuestionMixin],
@@ -44,15 +88,25 @@ export default {
4488
stringify: this.stringify,
4589
parse: this.parse,
4690
},
91+
extraSettingsFormatter: {
92+
stringify: this.stringifyDate,
93+
parse: this.parseTimestampToDate,
94+
},
95+
svgEventIcon,
96+
svgTodayIcon,
4797
}
4898
},
4999

50100
computed: {
51101
datetimePickerPlaceholder() {
52102
if (this.readOnly) {
53-
return this.answerType.submitPlaceholder
103+
return this.extraSettings?.dateRange
104+
? this.answerType.submitPlaceholderRange
105+
: this.answerType.submitPlaceholder
54106
}
55-
return this.answerType.createPlaceholder
107+
return this.extraSettings?.dateRange
108+
? this.answerType.createPlaceholderRange
109+
: this.answerType.createPlaceholder
56110
},
57111

58112
/**
@@ -68,8 +122,54 @@ export default {
68122
},
69123

70124
time() {
125+
if (this.extraSettings?.dateRange) {
126+
return this.values
127+
? [this.parse(this.values[0]), this.parse(this.values[1])]
128+
: null
129+
}
71130
return this.values ? this.parse(this.values[0]) : null
72131
},
132+
133+
/**
134+
* The maximum allowable date for the date input field
135+
*/
136+
dateMax: {
137+
get() {
138+
return this.extraSettings?.dateMax
139+
? moment(this.extraSettings.dateMax, 'X').toDate()
140+
: null
141+
},
142+
set(value) {
143+
this.onExtraSettingsChange({
144+
dateMax: parseInt(moment(value).format('X')),
145+
})
146+
},
147+
},
148+
149+
/**
150+
* The minimum allowable date for the date input field
151+
*/
152+
dateMin: {
153+
get() {
154+
return this.extraSettings?.dateMin
155+
? moment(this.extraSettings.dateMin, 'X').toDate()
156+
: null
157+
},
158+
set(value) {
159+
this.onExtraSettingsChange({
160+
dateMin: parseInt(moment(value).format('X')),
161+
})
162+
},
163+
},
164+
165+
dateRange: {
166+
get() {
167+
return this.extraSettings?.dateRange ?? false
168+
},
169+
set(value) {
170+
this.onExtraSettingsChange({ dateRange: value === true ?? null })
171+
},
172+
},
73173
},
74174

75175
methods: {
@@ -99,12 +199,52 @@ export default {
99199
/**
100200
* Store Value
101201
*
102-
* @param {Date} date The date to store
202+
* @param {Date|Array<Date>} date The date or date range to store
103203
*/
104204
onValueChange(date) {
105-
this.$emit('update:values', [
106-
moment(date).format(this.answerType.storageFormat),
107-
])
205+
if (this.extraSettings?.dateRange) {
206+
this.$emit('update:values', [
207+
moment(date[0]).format(this.answerType.storageFormat),
208+
moment(date[1]).format(this.answerType.storageFormat),
209+
])
210+
} else {
211+
this.$emit('update:values', [
212+
moment(date).format(this.answerType.storageFormat),
213+
])
214+
}
215+
},
216+
217+
/**
218+
* Determines if a given date should be disabled.
219+
*
220+
* @param {Date} date - The date to check.
221+
* @return {boolean} - Returns true if the date should be disabled, otherwise false.
222+
*/
223+
disabledDates(date) {
224+
return (
225+
(this.dateMin && date < this.dateMin) ||
226+
(this.dateMax && date > this.dateMax)
227+
)
228+
},
229+
230+
/**
231+
* Datepicker timestamp to string
232+
*
233+
* @param {Date} datetime the datepicker Date
234+
* @return {string}
235+
*/
236+
stringifyDate(datetime) {
237+
return moment(datetime).format('L')
238+
},
239+
240+
/**
241+
* Form expires timestamp to Date of the datepicker
242+
*
243+
* @param {number} value the expires timestamp
244+
* @return {Date}
245+
*/
246+
parseTimestampToDate(value) {
247+
return moment(value, 'X').toDate()
108248
},
109249
},
110250
}

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)