Skip to content

Commit 6b264a2

Browse files
committed
feat(time): Add time range support
Signed-off-by: Christian Hartmann <chris-hartmann@gmx.de>
1 parent 5598b5e commit 6b264a2

11 files changed

Lines changed: 299 additions & 13 deletions

File tree

img/clock_arrow_down.svg

Lines changed: 1 addition & 0 deletions
Loading

img/clock_arrow_up.svg

Lines changed: 1 addition & 0 deletions
Loading

lib/Constants.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,12 @@ class Constants {
153153
'dateRange' => ['boolean', 'NULL'],
154154
];
155155

156+
public const EXTRA_SETTINGS_TIME = [
157+
'timeMax' => ['string', 'NULL'],
158+
'timeMin' => ['string', 'NULL'],
159+
'timeRange' => ['boolean', 'NULL'],
160+
];
161+
156162
// should be in sync with FileTypes.js
157163
public const EXTRA_SETTINGS_ALLOWED_FILE_TYPES = [
158164
'image',

lib/ResponseDefinitions.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131
* optionsLimitMax?: int,
3232
* optionsLimitMin?: int,
3333
* shuffleOptions?: bool,
34+
* timeMax?: int,
35+
* timeMin?: int,
36+
* timeRange?: bool,
3437
* validationRegex?: string,
3538
* validationType?: string
3639
* }

lib/Service/FormsService.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,9 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType
632632
case Constants::ANSWER_TYPE_DATE:
633633
$allowed = Constants::EXTRA_SETTINGS_DATE;
634634
break;
635+
case Constants::ANSWER_TYPE_TIME:
636+
$allowed = Constants::EXTRA_SETTINGS_TIME;
637+
break;
635638
default:
636639
$allowed = [];
637640
}
@@ -656,6 +659,32 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType
656659
&& $extraSettings['dateMin'] > $extraSettings['dateMax']) {
657660
return false;
658661
}
662+
} elseif ($questionType === Constants::ANSWER_TYPE_TIME) {
663+
$format = Constants::ANSWER_PHPDATETIME_FORMAT['time'];
664+
665+
// Validate timeMin format
666+
if (isset($extraSettings['timeMin'])) {
667+
$timeMinString = $extraSettings['timeMin'];
668+
$timeMinDate = \DateTime::createFromFormat($format, $timeMinString);
669+
if (!$timeMinDate || $timeMinDate->format($format) !== $timeMinString) {
670+
return false;
671+
}
672+
}
673+
674+
// Validate timeMax format
675+
if (isset($extraSettings['timeMax'])) {
676+
$timeMaxString = $extraSettings['timeMax'];
677+
$timeMaxDate = \DateTime::createFromFormat($format, $timeMaxString);
678+
if (!$timeMaxDate || $timeMaxDate->format($format) !== $timeMaxString) {
679+
return false;
680+
}
681+
}
682+
683+
// Ensure timeMin and timeMax don't overlap
684+
if (isset($extraSettings['timeMin']) && isset($extraSettings['timeMax'])
685+
&& $timeMinDate > $timeMaxDate) {
686+
return false;
687+
}
659688
} elseif ($questionType === Constants::ANSWER_TYPE_MULTIPLE) {
660689
// Ensure limits are sane
661690
if (isset($extraSettings['optionsLimitMax']) && isset($extraSettings['optionsLimitMin'])

lib/Service/SubmissionService.php

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,13 @@ public function validateSubmission(array $questions, array $answers, string $for
382382
} elseif ($answersCount != 2 && $question['type'] === Constants::ANSWER_TYPE_DATE && isset($question['extraSettings']['dateRange'])) {
383383
// Check if date range questions have exactly two answers
384384
throw new \InvalidArgumentException(sprintf('Question "%s" can only have two answers.', $question['text']));
385-
} elseif ($answersCount > 1 && $question['type'] !== Constants::ANSWER_TYPE_FILE && !($question['type'] === Constants::ANSWER_TYPE_DATE && isset($question['extraSettings']['dateRange']))) {
385+
} elseif ($answersCount != 2 && $question['type'] === Constants::ANSWER_TYPE_TIME && isset($question['extraSettings']['timeRange'])) {
386+
// Check if date range questions have exactly two answers
387+
throw new \InvalidArgumentException(sprintf('Question "%s" can only have two answers.', $question['text']));
388+
} elseif ($answersCount > 1
389+
&& $question['type'] !== Constants::ANSWER_TYPE_FILE
390+
&& !($question['type'] === Constants::ANSWER_TYPE_DATE && isset($question['extraSettings']['dateRange'])
391+
|| $question['type'] === Constants::ANSWER_TYPE_TIME && isset($question['extraSettings']['timeRange']))) {
386392
// Check if non-multiple questions have not more than one answer
387393
throw new \InvalidArgumentException(sprintf('Question "%s" can only have one answer.', $question['text']));
388394
}
@@ -457,15 +463,17 @@ private function validateDateTime(array $answers, string $format, ?string $text
457463
}
458464

459465
if ($previousDate !== null && $d < $previousDate) {
460-
throw new \InvalidArgumentException(sprintf('Dates for question "%s" must be in ascending order.', $text));
466+
throw new \InvalidArgumentException(sprintf('Date/time values for question "%s" must be in ascending order.', $text));
461467
}
462468
$previousDate = $d;
463469

464470
if ($extraSettings) {
465471
if ((isset($extraSettings['dateMin']) && $d < (new DateTime())->setTimestamp($extraSettings['dateMin'])) ||
466-
(isset($extraSettings['dateMax']) && $d > (new DateTime())->setTimestamp($extraSettings['dateMax']))
472+
(isset($extraSettings['dateMax']) && $d > (new DateTime())->setTimestamp($extraSettings['dateMax'])) ||
473+
(isset($extraSettings['timeMin']) && $d < DateTime::createFromFormat($format, $extraSettings['timeMin'])) ||
474+
(isset($extraSettings['timeMax']) && $d > DateTime::createFromFormat($format, $extraSettings['timeMax']))
467475
) {
468-
throw new \InvalidArgumentException(sprintf('Date is not in the allowed range for question "%s".', $text));
476+
throw new \InvalidArgumentException(sprintf('Date/time is not in the allowed range for question "%s".', $text));
469477
}
470478
}
471479
}

openapi.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,17 @@
448448
"shuffleOptions": {
449449
"type": "boolean"
450450
},
451+
"timeMax": {
452+
"type": "integer",
453+
"format": "int64"
454+
},
455+
"timeMin": {
456+
"type": "integer",
457+
"format": "int64"
458+
},
459+
"timeRange": {
460+
"type": "boolean"
461+
},
451462
"validationRegex": {
452463
"type": "string"
453464
},

src/components/Questions/QuestionDate.vue

Lines changed: 135 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,24 +46,61 @@
4646
</template>
4747
</NcActionInput>
4848
</template>
49+
<template v-else-if="answerType.pickerType === 'time'" #actions>
50+
<NcActionCheckbox
51+
:model-value="timeRange"
52+
@update:model-value="onTimeRangeChange">
53+
{{ t('forms', 'Use time range') }}
54+
</NcActionCheckbox>
55+
<NcActionInput
56+
type="time"
57+
is-native-picker
58+
:model-value="timeMin"
59+
:label="t('forms', 'Earliest time')"
60+
hide-label
61+
:max="timeMax"
62+
@update:model-value="onTimeMinChange">
63+
<template #icon>
64+
<NcIconSvgWrapper
65+
:svg="svgClockArrowUp"
66+
:name="t('forms', 'Earliest time')" />
67+
</template>
68+
</NcActionInput>
69+
<NcActionInput
70+
type="time"
71+
is-native-picker
72+
:model-value="timeMax"
73+
:label="t('forms', 'Latest time')"
74+
hide-label
75+
:min="timeMin"
76+
@update:model-value="onTimeMaxChange">
77+
<template #icon>
78+
<NcIconSvgWrapper
79+
:svg="svgClockArrowDown"
80+
:name="t('forms', 'Latest time')" />
81+
</template>
82+
</NcActionInput>
83+
</template>
4984
<div class="question__content">
5085
<NcDateTimePicker
5186
:value="time"
5287
:disabled="!readOnly"
5388
:formatter="formatter"
5489
:placeholder="datetimePickerPlaceholder"
5590
:show-second="false"
56-
:type="answerType.pickerType"
91+
:type="dateTimePickerType"
5792
:disabled-date="disabledDates"
93+
:disabled-time="disabledTimes"
5894
:input-attr="inputAttr"
59-
:range="extraSettings?.dateRange"
6095
range-separator=" - "
6196
@change="onValueChange" />
6297
</div>
6398
</Question>
6499
</template>
65100

66101
<script>
102+
import svgClockArrowDown from '../../../img/clock_arrow_down.svg?raw'
103+
import svgClockArrowUp from '../../../img/clock_arrow_up.svg?raw'
67104
import svgEventIcon from '../../../img/event.svg?raw'
68105
import svgTodayIcon from '../../../img/today.svg?raw'
69106
@@ -96,6 +133,8 @@ export default {
96133
stringify: this.stringifyDate,
97134
parse: this.parseTimestampToDate,
98135
},
136+
svgClockArrowDown,
137+
svgClockArrowUp,
99138
svgEventIcon,
100139
svgTodayIcon,
101140
}
@@ -104,15 +143,21 @@ export default {
104143
computed: {
105144
datetimePickerPlaceholder() {
106145
if (this.readOnly) {
107-
return this.extraSettings?.dateRange
146+
return this.extraSettings?.dateRange || this.extraSettings?.timeRange
108147
? this.answerType.submitPlaceholderRange
109148
: this.answerType.submitPlaceholder
110149
}
111-
return this.extraSettings?.dateRange
150+
return this.extraSettings?.dateRange || this.extraSettings?.timeRange
112151
? this.answerType.createPlaceholderRange
113152
: this.answerType.createPlaceholder
114153
},
115154
155+
dateTimePickerType() {
156+
return this.extraSettings?.dateRange || this.extraSettings?.timeRange
157+
? this.answerType.pickerType + '-range'
158+
: this.answerType.pickerType
159+
},
160+
116161
/**
117162
* All non-exposed props onto datepicker input-element.
118163
*
@@ -126,7 +171,7 @@ export default {
126171
},
127172
128173
time() {
129-
if (this.extraSettings?.dateRange) {
174+
if (this.extraSettings?.dateRange || this.extraSettings?.timeRange) {
130175
return this.values
131176
? [this.parse(this.values[0]), this.parse(this.values[1])]
132177
: null
@@ -155,6 +200,34 @@ export default {
155200
dateRange() {
156201
return this.extraSettings?.dateRange ?? false
157202
},
203+
204+
/**
205+
* The maximum allowable time for the time input field
206+
*/
207+
timeMax() {
208+
return this.extraSettings?.timeMax
209+
? moment(
210+
this.extraSettings.timeMax,
211+
this.answerType.storageFormat,
212+
).toDate()
213+
: new Date(new Date().setHours(24, 0, 0, 0))
214+
},
215+
216+
/**
217+
* The minimum allowable time for the time input field
218+
*/
219+
timeMin() {
220+
return this.extraSettings?.timeMin
221+
? moment(
222+
this.extraSettings.timeMin,
223+
this.answerType.storageFormat,
224+
).toDate()
225+
: new Date(new Date().setHours(0, 0, 0, 0))
226+
},
227+
228+
timeRange() {
229+
return this.extraSettings?.timeRange ?? false
230+
},
158231
},
159232
160233
methods: {
@@ -216,13 +289,56 @@ export default {
216289
this.onExtraSettingsChange({ dateRange: value === true ?? null })
217290
},
218291
292+
/**
293+
* Handles the change event for the maximum time input.
294+
* Updates the maximum allowable date based on the provided value.
295+
*
296+
* @param {string | Date} value - The new maximum date value. Can be a string or a Date object.
297+
*/
298+
onTimeMaxChange(value) {
299+
this.onExtraSettingsChange({
300+
timeMax:
301+
value === null
302+
|| value === new Date(new Date().setHours(24, 0, 0, 0))
303+
? null
304+
: moment(value).format(this.answerType.storageFormat),
305+
})
306+
},
307+
308+
/**
309+
* Handles the change event for the minimum date input.
310+
* Updates the minimum allowable date based on the provided value.
311+
*
312+
* @param {string | Date} value - The new minimum date value. Can be a string or a Date object.
313+
*/
314+
onTimeMinChange(value) {
315+
this.onExtraSettingsChange({
316+
timeMin:
317+
value === null
318+
|| value === new Date(new Date().setHours(0, 0, 0, 0))
319+
? null
320+
: moment(value).format(this.answerType.storageFormat),
321+
})
322+
},
323+
324+
/**
325+
* Handles the change event for the date range selection.
326+
* Updates the extra settings with the new date range value.
327+
*
328+
* @param {boolean} value - The new value of the date range selection.
329+
* If true, the date range is enabled; otherwise, null.
330+
*/
331+
onTimeRangeChange(value) {
332+
this.onExtraSettingsChange({ timeRange: value === true ?? null })
333+
},
334+
219335
/**
220336
* Store Value
221337
*
222338
* @param {Date|Array<Date>} date The date or date range to store
223339
*/
224340
onValueChange(date) {
225-
if (this.extraSettings?.dateRange) {
341+
if (this.extraSettings?.dateRange || this.extraSettings?.timeRange) {
226342
this.$emit('update:values', [
227343
moment(date[0]).format(this.answerType.storageFormat),
228344
moment(date[1]).format(this.answerType.storageFormat),
@@ -247,6 +363,19 @@ export default {
247363
)
248364
},
249365
366+
/**
367+
* Determines if a given time should be disabled.
368+
*
369+
* @param {Date} time - The time to check.
370+
* @return {boolean} - Returns true if the time should be disabled, otherwise false.
371+
*/
372+
disabledTimes(time) {
373+
return (
374+
(this.timeMin && time < this.timeMin)
375+
|| (this.timeMax && time > this.timeMax)
376+
)
377+
},
378+
250379
/**
251380
* Datepicker timestamp to string
252381
*

src/models/AnswerTypes.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,9 @@ export default {
176176

177177
titlePlaceholder: t('forms', 'Time question title'),
178178
createPlaceholder: t('forms', 'People can pick a time'),
179+
createPlaceholderRange: t('forms', 'People can pick a time range'),
179180
submitPlaceholder: t('forms', 'Pick a time'),
181+
submitPlaceholderRange: t('forms', 'Pick a time range'),
180182
warningInvalid: t('forms', 'This question needs a title!'),
181183

182184
pickerType: 'time',

tests/Unit/Service/FormsServiceTest.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1397,6 +1397,31 @@ public function dataAreExtraSettingsValid() {
13971397
'questionType' => Constants::ANSWER_TYPE_MULTIPLE,
13981398
'expected' => false
13991399
],
1400+
'valid-time-settings' => [
1401+
'extraSettings' => [
1402+
'dateMin' => '12:34',
1403+
'dateMax' => null,
1404+
],
1405+
'questionType' => Constants::ANSWER_TYPE_DATE,
1406+
'expected' => true
1407+
],
1408+
'invalid-time-settings' => [
1409+
'extraSettings' => [
1410+
'dateMin' => 'today',
1411+
'dateMax2' => null,
1412+
],
1413+
'questionType' => Constants::ANSWER_TYPE_DATE,
1414+
'expected' => false
1415+
],
1416+
'invalid-time-limits' => [
1417+
// max < min
1418+
'extraSettings' => [
1419+
'dateMin' => '12:34',
1420+
'dateMax' => '12:33',
1421+
],
1422+
'questionType' => Constants::ANSWER_TYPE_MULTIPLE,
1423+
'expected' => false
1424+
],
14001425
'valid-dropdown-settings' => [
14011426
'extraSettings' => [
14021427
'shuffleOptions' => false,

0 commit comments

Comments
 (0)