Skip to content

feat(date): allow restrictions for dates and query date ranges #2646

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 1, 2025
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 @@ -226,3 +226,6 @@ Optional extra settings for some [Question Types](#question-types)
| `allowedFileExtensions` | `file` | Array of strings | `'jpg', 'png'` | Allowed file extensions for file upload |
| `maxAllowedFilesCount` | `file` | Integer | - | Maximum number of files that can be uploaded, 0 means no limit |
| `maxFileSize` | `file` | Integer | - | Maximum file size in bytes, 0 means no limit |
| `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 |
1 change: 1 addition & 0 deletions img/event.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/today.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 @@ -147,6 +147,12 @@ class Constants {
'maxFileSize' => ['integer'],
];

public const EXTRA_SETTINGS_DATE = [
'dateMax' => ['integer', 'NULL'],
'dateMin' => ['integer', 'NULL'],
'dateRange' => ['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 @@ -23,6 +23,9 @@
* allowOtherAnswer?: bool,
* allowedFileExtensions?: list<string>,
* allowedFileTypes?: list<string>,
* dateMax?: int,
* dateMin?: int,
* dateRange?: bool,
* maxAllowedFilesCount?: int,
* maxFileSize?: int,
* optionsLimitMax?: int,
Expand Down
12 changes: 11 additions & 1 deletion lib/Service/FormsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,9 @@
case Constants::ANSWER_TYPE_FILE:
$allowed = Constants::EXTRA_SETTINGS_FILE;
break;
case Constants::ANSWER_TYPE_DATE:

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

View check run for this annotation

Codecov / codecov/patch

lib/Service/FormsService.php#L632

Added line #L632 was not covered by tests
$allowed = Constants::EXTRA_SETTINGS_DATE;
break;
default:
$allowed = [];
}
Expand All @@ -646,7 +649,14 @@
}
}

if ($questionType === Constants::ANSWER_TYPE_MULTIPLE) {
// Validate extraSettings for specific question types
if ($questionType === Constants::ANSWER_TYPE_DATE) {
// Ensure dateMin and dateMax don't overlap
if (isset($extraSettings['dateMin']) && isset($extraSettings['dateMax'])
&& $extraSettings['dateMin'] > $extraSettings['dateMax']) {
return false;

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

View check run for this annotation

Codecov / codecov/patch

lib/Service/FormsService.php#L657

Added line #L657 was not covered by tests
}
} elseif ($questionType === Constants::ANSWER_TYPE_MULTIPLE) {
// Ensure limits are sane
if (isset($extraSettings['optionsLimitMax']) && isset($extraSettings['optionsLimitMin'])
&& $extraSettings['optionsLimitMax'] < $extraSettings['optionsLimitMin']) {
Expand Down
45 changes: 34 additions & 11 deletions lib/Service/SubmissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -374,18 +374,21 @@ public function validateSubmission(array $questions, array $answers, string $for
} elseif ($maxOptions > 0 && $answersCount > $maxOptions) {
throw new \InvalidArgumentException(sprintf('Question "%s" requires at most %d answers.', $question['text'], $maxOptions));
}
} elseif ($answersCount > 1 && $question['type'] !== Constants::ANSWER_TYPE_FILE) {
} 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']))) {
// Check if non-multiple questions have not more than one answer
throw new \InvalidArgumentException(sprintf('Question "%s" can only have one answer.', $question['text']));
}

/*
* Check if date questions have valid answers
* $answers[$questionId][0] -> date/time questions can only have one answer
* Validate answers for date/time questions
* If a date range is specified, validate all answers in the range
* Otherwise, validate the single answer for the date/time question
*/
if (in_array($question['type'], Constants::ANSWER_TYPES_DATETIME) &&
!$this->validateDateTime($answers[$questionId][0], Constants::ANSWER_PHPDATETIME_FORMAT[$question['type']])) {
throw new \InvalidArgumentException(sprintf('Invalid date/time format for question "%s".', $question['text']));
if (in_array($question['type'], Constants::ANSWER_TYPES_DATETIME)) {
$this->validateDateTime($answers[$questionId], Constants::ANSWER_PHPDATETIME_FORMAT[$question['type']], $question['text'] ?? null, $question['extraSettings'] ?? null);
}

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

/**
* Validate correct date/time formats
* @param string $dateStr String with date from answer
* @param array $answers Array with date from answer
* @param string $format String with the format to validate
* @return boolean If the submitted date/time is valid
* @param string|null $text String with the title of the question
* @param array|null $extraSettings Array with extra settings for validation
*/
private function validateDateTime(string $dateStr, string $format) {
$d = DateTime::createFromFormat($format, $dateStr);
return $d && $d->format($format) === $dateStr;
private function validateDateTime(array $answers, string $format, ?string $text = null, ?array $extraSettings = null): void {
$previousDate = null;

foreach ($answers as $dateStr) {
$d = DateTime::createFromFormat($format, $dateStr);
if (!$d || $d->format($format) !== $dateStr) {
throw new \InvalidArgumentException(sprintf('Invalid date/time format for question "%s".', $text));
}

if ($previousDate !== null && $d < $previousDate) {
throw new \InvalidArgumentException(sprintf('Dates 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']))
) {
throw new \InvalidArgumentException(sprintf('Date 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 @@ -418,6 +418,17 @@
"type": "string"
}
},
"dateMax": {
"type": "integer",
"format": "int64"
},
"dateMin": {
"type": "integer",
"format": "int64"
},
"dateRange": {
"type": "boolean"
},
"maxAllowedFilesCount": {
"type": "integer",
"format": "int64"
Expand Down
156 changes: 148 additions & 8 deletions src/components/Questions/QuestionDate.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,39 @@
:title-placeholder="answerType.titlePlaceholder"
:warning-invalid="answerType.warningInvalid"
v-on="commonListeners">
<template v-if="answerType.pickerType === 'date'" #actions>
<NcActionCheckbox v-model="dateRange">
{{ t('forms', 'Use date range') }}
</NcActionCheckbox>
<NcActionInput
v-model="dateMin"
type="date"
:label="t('forms', 'Earliest date')"
hide-label
:formatter="extraSettingsFormatter"
is-native-picker
:max="dateMax">
<template #icon>
<NcIconSvgWrapper
:svg="svgTodayIcon"
:name="t('forms', 'Earliest date')" />
</template>
</NcActionInput>
<NcActionInput
v-model="dateMax"
type="date"
:label="t('forms', 'Latest date')"
hide-label
:formatter="extraSettingsFormatter"
is-native-picker
:min="dateMin">
<template #icon>
<NcIconSvgWrapper
:svg="svgEventIcon"
:name="t('forms', 'Latest date')" />
</template>
</NcActionInput>
</template>
<div class="question__content">
<NcDateTimePicker
:value="time"
Expand All @@ -17,23 +50,34 @@
:placeholder="datetimePickerPlaceholder"
:show-second="false"
:type="answerType.pickerType"
:disabled-date="disabledDates"
:input-attr="inputAttr"
:range="extraSettings?.dateRange"
range-separator=" - "
@change="onValueChange" />
</div>
</Question>
</template>

<script>
import moment from '@nextcloud/moment'
import svgEventIcon from '../../../img/event.svg?raw'
import svgTodayIcon from '../../../img/today.svg?raw'

import QuestionMixin from '../../mixins/QuestionMixin.js'
import moment from '@nextcloud/moment'
import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox'
import NcActionInput from '@nextcloud/vue/components/NcActionInput'
import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePicker'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import QuestionMixin from '../../mixins/QuestionMixin.js'

export default {
name: 'QuestionDate',

components: {
NcActionCheckbox,
NcActionInput,
NcDateTimePicker,
NcIconSvgWrapper,
},

mixins: [QuestionMixin],
Expand All @@ -44,15 +88,25 @@ export default {
stringify: this.stringify,
parse: this.parse,
},
extraSettingsFormatter: {
stringify: this.stringifyDate,
parse: this.parseTimestampToDate,
},
svgEventIcon,
svgTodayIcon,
}
},

computed: {
datetimePickerPlaceholder() {
if (this.readOnly) {
return this.answerType.submitPlaceholder
return this.extraSettings?.dateRange
? this.answerType.submitPlaceholderRange
: this.answerType.submitPlaceholder
}
return this.answerType.createPlaceholder
return this.extraSettings?.dateRange
? this.answerType.createPlaceholderRange
: this.answerType.createPlaceholder
},

/**
Expand All @@ -68,8 +122,54 @@ export default {
},

time() {
if (this.extraSettings?.dateRange) {
return this.values
? [this.parse(this.values[0]), this.parse(this.values[1])]
: null
}
return this.values ? this.parse(this.values[0]) : null
},

/**
* The maximum allowable date for the date input field
*/
dateMax: {
get() {
return this.extraSettings?.dateMax
? moment(this.extraSettings.dateMax, 'X').toDate()
: null
},
set(value) {
this.onExtraSettingsChange({
dateMax: parseInt(moment(value).format('X')),
})
},
},

/**
* The minimum allowable date for the date input field
*/
dateMin: {
get() {
return this.extraSettings?.dateMin
? moment(this.extraSettings.dateMin, 'X').toDate()
: null
},
set(value) {
this.onExtraSettingsChange({
dateMin: parseInt(moment(value).format('X')),
})
},
},

dateRange: {
get() {
return this.extraSettings?.dateRange ?? false
},
set(value) {
this.onExtraSettingsChange({ dateRange: value === true ?? null })
},
},
},

methods: {
Expand Down Expand Up @@ -99,12 +199,52 @@ export default {
/**
* Store Value
*
* @param {Date} date The date to store
* @param {Date|Array<Date>} date The date or date range to store
*/
onValueChange(date) {
this.$emit('update:values', [
moment(date).format(this.answerType.storageFormat),
])
if (this.extraSettings?.dateRange) {
this.$emit('update:values', [
moment(date[0]).format(this.answerType.storageFormat),
moment(date[1]).format(this.answerType.storageFormat),
])
} else {
this.$emit('update:values', [
moment(date).format(this.answerType.storageFormat),
])
}
},

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

/**
* Datepicker timestamp to string
*
* @param {Date} datetime the datepicker Date
* @return {string}
*/
stringifyDate(datetime) {
return moment(datetime).format('L')
},

/**
* Form expires timestamp to Date of the datepicker
*
* @param {number} value the expires timestamp
* @return {Date}
*/
parseTimestampToDate(value) {
return moment(value, 'X').toDate()
},
},
}
Expand Down
8 changes: 4 additions & 4 deletions src/components/Results/Answer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@
:key="answer.id"
class="answer__text"
dir="auto">
<a :href="answer.url" target="_blank"
><IconFile :size="20" class="answer__text-icon" />
{{ answer.text }}</a
>
<a :href="answer.url" target="_blank">
<IconFile :size="20" class="answer__text-icon" />
{{ answer.text }}
</a>
</p>
</template>
<p v-else class="answer__text" dir="auto">
Expand Down
Loading
Loading