Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/DataStructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,9 @@ Optional extra settings for some [Question Types](#question-types)
| `dateMax` | `date` | Integer | - | Maximum allowed date to be chosen (as Unix timestamp) |
| `dateMin` | `date` | Integer | - | Minimum allowed date to be chosen (as Unix timestamp) |
| `dateRange` | `date` | Boolean | `true/false` | The date picker should query a date range |
| `timeMax` | `time` | string | - | Maximum allowed time to be chosen (as `HH:mm` string) |
| `timeMin` | `time` | string | - | Minimum allowed time to be chosen (as `HH:mm` string) |
| `timeRange` | `time` | Boolean | `true/false` | The time picker should query a time range |
| `optionsLowest` | `linearscale` | Integer | `0, 1` | Set the lowest value of the scale, default: `1` |
| `optionsHighest` | `linearscale` | Integer | `2, 3, 4, 5, 6, 7, 8, 9, 10` | Set the highest value of the scale, default: `5` |
| `optionsLabelLowest` | `linearscale` | string | - | Set the label of the lowest value, default: `'Strongly disagree'` |
Expand Down
1 change: 1 addition & 0 deletions img/clock_loader_20.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions img/clock_loader_80.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions lib/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,12 @@ class Constants {
'dateRange' => ['boolean', 'NULL'],
];

public const EXTRA_SETTINGS_TIME = [
'timeMax' => ['string', 'NULL'],
'timeMin' => ['string', 'NULL'],
'timeRange' => ['boolean', 'NULL'],
];

// should be in sync with FileTypes.js
public const EXTRA_SETTINGS_ALLOWED_FILE_TYPES = [
'image',
Expand Down
3 changes: 3 additions & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
* optionsLimitMin?: int,
* optionsLowest?: 0|1,
* shuffleOptions?: bool,
* timeMax?: int,
* timeMin?: int,
* timeRange?: bool,
* validationRegex?: string,
* validationType?: string
* }
Expand Down
29 changes: 29 additions & 0 deletions lib/Service/FormsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,9 @@
case Constants::ANSWER_TYPE_DATE:
$allowed = Constants::EXTRA_SETTINGS_DATE;
break;
case Constants::ANSWER_TYPE_TIME:

Check warning on line 635 in lib/Service/FormsService.php

View check run for this annotation

Codecov / codecov/patch

lib/Service/FormsService.php#L635

Added line #L635 was not covered by tests
$allowed = Constants::EXTRA_SETTINGS_TIME;
break;
case Constants::ANSWER_TYPE_LINEARSCALE:
$allowed = Constants::EXTRA_SETTINGS_LINEARSCALE;
break;
Expand Down Expand Up @@ -659,6 +662,32 @@
&& $extraSettings['dateMin'] > $extraSettings['dateMax']) {
return false;
}
} elseif ($questionType === Constants::ANSWER_TYPE_TIME) {
$format = Constants::ANSWER_PHPDATETIME_FORMAT['time'];

// Validate timeMin format
if (isset($extraSettings['timeMin'])) {
$timeMinString = $extraSettings['timeMin'];
$timeMinDate = \DateTime::createFromFormat($format, $timeMinString);
if (!$timeMinDate || $timeMinDate->format($format) !== $timeMinString) {
return false;

Check warning on line 673 in lib/Service/FormsService.php

View check run for this annotation

Codecov / codecov/patch

lib/Service/FormsService.php#L673

Added line #L673 was not covered by tests
}
}

// Validate timeMax format
if (isset($extraSettings['timeMax'])) {
$timeMaxString = $extraSettings['timeMax'];
$timeMaxDate = \DateTime::createFromFormat($format, $timeMaxString);
if (!$timeMaxDate || $timeMaxDate->format($format) !== $timeMaxString) {
return false;

Check warning on line 682 in lib/Service/FormsService.php

View check run for this annotation

Codecov / codecov/patch

lib/Service/FormsService.php#L679-L682

Added lines #L679 - L682 were not covered by tests
}
}

// Ensure timeMin and timeMax don't overlap
if (isset($extraSettings['timeMin']) && isset($extraSettings['timeMax'])
&& $timeMinDate > $timeMaxDate) {
return false;

Check warning on line 689 in lib/Service/FormsService.php

View check run for this annotation

Codecov / codecov/patch

lib/Service/FormsService.php#L689

Added line #L689 was not covered by tests
}
} elseif ($questionType === Constants::ANSWER_TYPE_MULTIPLE) {
// Ensure limits are sane
if (isset($extraSettings['optionsLimitMax']) && isset($extraSettings['optionsLimitMin'])
Expand Down
16 changes: 12 additions & 4 deletions lib/Service/SubmissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,13 @@ public function validateSubmission(array $questions, array $answers, string $for
} elseif ($answersCount != 2 && $question['type'] === Constants::ANSWER_TYPE_DATE && isset($question['extraSettings']['dateRange'])) {
// Check if date range questions have exactly two answers
throw new \InvalidArgumentException(sprintf('Question "%s" can only have two answers.', $question['text']));
} elseif ($answersCount > 1 && $question['type'] !== Constants::ANSWER_TYPE_FILE && !($question['type'] === Constants::ANSWER_TYPE_DATE && isset($question['extraSettings']['dateRange']))) {
} elseif ($answersCount != 2 && $question['type'] === Constants::ANSWER_TYPE_TIME && isset($question['extraSettings']['timeRange'])) {
// Check if date range questions have exactly two answers
throw new \InvalidArgumentException(sprintf('Question "%s" can only have two answers.', $question['text']));
} elseif ($answersCount > 1
&& $question['type'] !== Constants::ANSWER_TYPE_FILE
&& !($question['type'] === Constants::ANSWER_TYPE_DATE && isset($question['extraSettings']['dateRange'])
|| $question['type'] === Constants::ANSWER_TYPE_TIME && isset($question['extraSettings']['timeRange']))) {
// Check if non-multiple questions have not more than one answer
throw new \InvalidArgumentException(sprintf('Question "%s" can only have one answer.', $question['text']));
}
Expand Down Expand Up @@ -466,15 +472,17 @@ private function validateDateTime(array $answers, string $format, ?string $text
}

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

if ($extraSettings) {
if ((isset($extraSettings['dateMin']) && $d < (new DateTime())->setTimestamp($extraSettings['dateMin'])) ||
(isset($extraSettings['dateMax']) && $d > (new DateTime())->setTimestamp($extraSettings['dateMax']))
(isset($extraSettings['dateMax']) && $d > (new DateTime())->setTimestamp($extraSettings['dateMax'])) ||
(isset($extraSettings['timeMin']) && $d < DateTime::createFromFormat($format, $extraSettings['timeMin'])) ||
(isset($extraSettings['timeMax']) && $d > DateTime::createFromFormat($format, $extraSettings['timeMax']))
) {
throw new \InvalidArgumentException(sprintf('Date is not in the allowed range for question "%s".', $text));
throw new \InvalidArgumentException(sprintf('Date/time is not in the allowed range for question "%s".', $text));
}
}
}
Expand Down
11 changes: 11 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,17 @@
"shuffleOptions": {
"type": "boolean"
},
"timeMax": {
"type": "integer",
"format": "int64"
},
"timeMin": {
"type": "integer",
"format": "int64"
},
"timeRange": {
"type": "boolean"
},
"validationRegex": {
"type": "string"
},
Expand Down
141 changes: 135 additions & 6 deletions src/components/Questions/QuestionDate.vue
Original file line number Diff line number Diff line change
Expand Up @@ -46,24 +46,61 @@
</template>
</NcActionInput>
</template>
<template v-else-if="answerType.pickerType === 'time'" #actions>
<NcActionCheckbox
:model-value="timeRange"
@update:model-value="onTimeRangeChange">
{{ t('forms', 'Use time range') }}
</NcActionCheckbox>
<NcActionInput
type="time"
is-native-picker
:model-value="timeMin"
:label="t('forms', 'Earliest time')"
hide-label
:max="timeMax"
@update:model-value="onTimeMinChange">
<template #icon>
<NcIconSvgWrapper
:svg="svgClockLoader20"
:name="t('forms', 'Earliest time')" />
</template>
</NcActionInput>
<NcActionInput
type="time"
is-native-picker
:model-value="timeMax"
:label="t('forms', 'Latest time')"
hide-label
:min="timeMin"
@update:model-value="onTimeMaxChange">
<template #icon>
<NcIconSvgWrapper
:svg="svgClockLoader80"
:name="t('forms', 'Latest time')" />
</template>
</NcActionInput>
</template>
<div class="question__content">
<NcDateTimePicker
:value="time"
:disabled="!readOnly"
:formatter="formatter"
:placeholder="datetimePickerPlaceholder"
:show-second="false"
:type="answerType.pickerType"
:type="dateTimePickerType"
:disabled-date="disabledDates"
:disabled-time="disabledTimes"
:input-attr="inputAttr"
:range="extraSettings?.dateRange"
range-separator=" - "
@change="onValueChange" />
</div>
</Question>
</template>

<script>
import svgClockLoader20 from '../../../img/clock_loader_20.svg?raw'
import svgClockLoader80 from '../../../img/clock_loader_80.svg?raw'
import svgEventIcon from '../../../img/event.svg?raw'
import svgTodayIcon from '../../../img/today.svg?raw'

Expand Down Expand Up @@ -96,6 +133,8 @@ export default {
stringify: this.stringifyDate,
parse: this.parseTimestampToDate,
},
svgClockLoader80,
svgClockLoader20,
svgEventIcon,
svgTodayIcon,
}
Expand All @@ -104,15 +143,21 @@ export default {
computed: {
datetimePickerPlaceholder() {
if (this.readOnly) {
return this.extraSettings?.dateRange
return this.extraSettings?.dateRange || this.extraSettings?.timeRange
? this.answerType.submitPlaceholderRange
: this.answerType.submitPlaceholder
}
return this.extraSettings?.dateRange
return this.extraSettings?.dateRange || this.extraSettings?.timeRange
? this.answerType.createPlaceholderRange
: this.answerType.createPlaceholder
},

dateTimePickerType() {
return this.extraSettings?.dateRange || this.extraSettings?.timeRange
? this.answerType.pickerType + '-range'
: this.answerType.pickerType
},

/**
* All non-exposed props onto datepicker input-element.
*
Expand All @@ -126,7 +171,7 @@ export default {
},

time() {
if (this.extraSettings?.dateRange) {
if (this.extraSettings?.dateRange || this.extraSettings?.timeRange) {
return this.values
? [this.parse(this.values[0]), this.parse(this.values[1])]
: null
Expand Down Expand Up @@ -155,6 +200,34 @@ export default {
dateRange() {
return this.extraSettings?.dateRange ?? false
},

/**
* The maximum allowable time for the time input field
*/
timeMax() {
return this.extraSettings?.timeMax
? moment(
this.extraSettings.timeMax,
this.answerType.storageFormat,
).toDate()
: new Date(new Date().setHours(24, 0, 0, 0))
},

/**
* The minimum allowable time for the time input field
*/
timeMin() {
return this.extraSettings?.timeMin
? moment(
this.extraSettings.timeMin,
this.answerType.storageFormat,
).toDate()
: new Date(new Date().setHours(0, 0, 0, 0))
},

timeRange() {
return this.extraSettings?.timeRange ?? false
},
},

methods: {
Expand Down Expand Up @@ -216,13 +289,56 @@ export default {
this.onExtraSettingsChange({ dateRange: value === true ?? null })
},

/**
* Handles the change event for the maximum time input.
* Updates the maximum allowable date based on the provided value.
*
* @param {string | Date} value - The new maximum date value. Can be a string or a Date object.
*/
onTimeMaxChange(value) {
this.onExtraSettingsChange({
timeMax:
value === null
|| value === new Date(new Date().setHours(24, 0, 0, 0))
? null
: moment(value).format(this.answerType.storageFormat),
})
},

/**
* Handles the change event for the minimum date input.
* Updates the minimum allowable date based on the provided value.
*
* @param {string | Date} value - The new minimum date value. Can be a string or a Date object.
*/
onTimeMinChange(value) {
this.onExtraSettingsChange({
timeMin:
value === null
|| value === new Date(new Date().setHours(0, 0, 0, 0))
? null
: moment(value).format(this.answerType.storageFormat),
})
},

/**
* Handles the change event for the date range selection.
* Updates the extra settings with the new date range value.
*
* @param {boolean} value - The new value of the date range selection.
* If true, the date range is enabled; otherwise, null.
*/
onTimeRangeChange(value) {
this.onExtraSettingsChange({ timeRange: value === true ?? null })
},

/**
* Store Value
*
* @param {Date|Array<Date>} date The date or date range to store
*/
onValueChange(date) {
if (this.extraSettings?.dateRange) {
if (this.extraSettings?.dateRange || this.extraSettings?.timeRange) {
this.$emit('update:values', [
moment(date[0]).format(this.answerType.storageFormat),
moment(date[1]).format(this.answerType.storageFormat),
Expand All @@ -247,6 +363,19 @@ export default {
)
},

/**
* Determines if a given time should be disabled.
*
* @param {Date} time - The time to check.
* @return {boolean} - Returns true if the time should be disabled, otherwise false.
*/
disabledTimes(time) {
return (
(this.timeMin && time < this.timeMin)
|| (this.timeMax && time > this.timeMax)
)
},

/**
* Datepicker timestamp to string
*
Expand Down
5 changes: 4 additions & 1 deletion src/components/Results/ResultsSummary.vue
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,10 @@ export default {
}

// Add text answers
if (this.question.type === 'date' && answers.length === 2) {
if (
['date', 'time'].includes(this.question.type)
&& answers.length === 2
) {
// Combine the first two answers in order for date range questions
answersModels.push({
id: `${answers[0].id}-${answers[1].id}`,
Expand Down
2 changes: 1 addition & 1 deletion src/components/Results/Submission.vue
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export default {
}
}),
})
} else if (question.type === 'date') {
} else if (['date', 'time'].includes(question.type)) {
const squashedAnswers = answers
.map((answer) => answer.text)
.join(' - ')
Expand Down
2 changes: 2 additions & 0 deletions src/models/AnswerTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,9 @@ export default {

titlePlaceholder: t('forms', 'Time question title'),
createPlaceholder: t('forms', 'People can pick a time'),
createPlaceholderRange: t('forms', 'People can pick a time range'),
submitPlaceholder: t('forms', 'Pick a time'),
submitPlaceholderRange: t('forms', 'Pick a time range'),
warningInvalid: t('forms', 'This question needs a title!'),

pickerType: 'time',
Expand Down
Loading
Loading