From 53835cd07c4fe6133a88a92249f8d08fc5650f82 Mon Sep 17 00:00:00 2001 From: Christian Hartmann Date: Mon, 17 Mar 2025 23:37:32 +0100 Subject: [PATCH] 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 --- docs/DataStructure.md | 3 + img/event.svg | 1 + img/today.svg | 1 + lib/Constants.php | 6 + lib/ResponseDefinitions.php | 3 + lib/Service/FormsService.php | 12 +- lib/Service/SubmissionService.php | 45 ++++-- openapi.json | 11 ++ src/components/Questions/QuestionDate.vue | 156 ++++++++++++++++++- src/components/Results/Answer.vue | 8 +- src/components/Results/ResultsSummary.vue | 48 +++--- src/components/Results/Submission.vue | 10 ++ src/models/AnswerTypes.js | 4 + tests/Unit/Service/FormsServiceTest.php | 25 +++ tests/Unit/Service/SubmissionServiceTest.php | 77 +++++++++ 15 files changed, 366 insertions(+), 44 deletions(-) create mode 100644 img/event.svg create mode 100644 img/today.svg diff --git a/docs/DataStructure.md b/docs/DataStructure.md index f83d5bb71..29588473d 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -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 | diff --git a/img/event.svg b/img/event.svg new file mode 100644 index 000000000..acbe57c8d --- /dev/null +++ b/img/event.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/img/today.svg b/img/today.svg new file mode 100644 index 000000000..9fca4fd26 --- /dev/null +++ b/img/today.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/Constants.php b/lib/Constants.php index 1ef116453..cdee36930 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -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', diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index c13808147..c87c0d274 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -23,6 +23,9 @@ * allowOtherAnswer?: bool, * allowedFileExtensions?: list, * allowedFileTypes?: list, + * dateMax?: int, + * dateMin?: int, + * dateRange?: bool, * maxAllowedFilesCount?: int, * maxFileSize?: int, * optionsLimitMax?: int, diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index 5d8f60940..db0a547b5 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -629,6 +629,9 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType case Constants::ANSWER_TYPE_FILE: $allowed = Constants::EXTRA_SETTINGS_FILE; break; + case Constants::ANSWER_TYPE_DATE: + $allowed = Constants::EXTRA_SETTINGS_DATE; + break; default: $allowed = []; } @@ -646,7 +649,14 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType } } - 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; + } + } elseif ($questionType === Constants::ANSWER_TYPE_MULTIPLE) { // Ensure limits are sane if (isset($extraSettings['optionsLimitMax']) && isset($extraSettings['optionsLimitMin']) && $extraSettings['optionsLimitMax'] < $extraSettings['optionsLimitMin']) { diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index 9023d5816..31ad1e135 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -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 @@ -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)); + } + } + } } /** diff --git a/openapi.json b/openapi.json index b59f89dee..0dc887668 100644 --- a/openapi.json +++ b/openapi.json @@ -418,6 +418,17 @@ "type": "string" } }, + "dateMax": { + "type": "integer", + "format": "int64" + }, + "dateMin": { + "type": "integer", + "format": "int64" + }, + "dateRange": { + "type": "boolean" + }, "maxAllowedFilesCount": { "type": "integer", "format": "int64" diff --git a/src/components/Questions/QuestionDate.vue b/src/components/Questions/QuestionDate.vue index f4531cce5..ea0ff51a8 100644 --- a/src/components/Questions/QuestionDate.vue +++ b/src/components/Questions/QuestionDate.vue @@ -9,6 +9,39 @@ :title-placeholder="answerType.titlePlaceholder" :warning-invalid="answerType.warningInvalid" v-on="commonListeners"> +