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
4 changes: 2 additions & 2 deletions docs/DataStructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,8 +255,8 @@ 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) |
| `dateMax` | `date` | string / Integer | - | Maximum allowed date to be chosen (as `YYYY-MM-DD` string, or Unix timestamp) |
| `dateMin` | `date` | string / Integer | - | Minimum allowed date to be chosen (as `YYYY-MM-DD` string, or 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) |
Expand Down
4 changes: 2 additions & 2 deletions lib/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,8 @@ class Constants {
];

public const EXTRA_SETTINGS_DATE = [
'dateMax' => ['integer', 'NULL'],
'dateMin' => ['integer', 'NULL'],
'dateMax' => ['string', 'integer', 'NULL'],
'dateMin' => ['string', 'integer', 'NULL'],
'dateRange' => ['boolean', 'NULL'],
];

Expand Down
8 changes: 4 additions & 4 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
* allowOtherAnswer?: bool,
* allowedFileExtensions?: list<string>,
* allowedFileTypes?: list<string>,
* dateMax?: int,
* dateMin?: int,
* dateMax?: string|int,
* dateMin?: string|int,
* dateRange?: bool,
* maxAllowedFilesCount?: int,
* maxFileSize?: int,
Expand All @@ -36,8 +36,8 @@
* optionsLimitMin?: int,
* optionsLowest?: 0|1,
* shuffleOptions?: bool,
* timeMax?: int,
* timeMin?: int,
* timeMax?: string,
* timeMin?: string,
* timeRange?: bool,
* validationRegex?: string,
* validationType?: string,
Expand Down
35 changes: 33 additions & 2 deletions lib/Service/FormsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -861,9 +861,40 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType

// Validate extraSettings for specific question types
if ($questionType === Constants::ANSWER_TYPE_DATE) {
$format = Constants::ANSWER_PHPDATETIME_FORMAT['date'];
$dateMinDate = null;
$dateMaxDate = null;

// Validate dateMin format
if (isset($extraSettings['dateMin'])) {
if (is_numeric($extraSettings['dateMin'])) {
$dateMinDate = \DateTime::createFromFormat('!Y-m-d', (new \DateTime('@' . (int)$extraSettings['dateMin']))
->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d'));
} else {
$dateMinString = $extraSettings['dateMin'];
$dateMinDate = \DateTime::createFromFormat('!' . $format, $dateMinString);
if (!$dateMinDate || $dateMinDate->format($format) !== $dateMinString) {
return false;
}
}
}

// Validate dateMax format
if (isset($extraSettings['dateMax'])) {
if (is_numeric($extraSettings['dateMax'])) {
$dateMaxDate = \DateTime::createFromFormat('!Y-m-d', (new \DateTime('@' . (int)$extraSettings['dateMax']))
->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d'));
} else {
$dateMaxString = $extraSettings['dateMax'];
$dateMaxDate = \DateTime::createFromFormat('!' . $format, $dateMaxString);
if (!$dateMaxDate || $dateMaxDate->format($format) !== $dateMaxString) {
return false;
}
}
}

// Ensure dateMin and dateMax don't overlap
if (isset($extraSettings['dateMin']) && isset($extraSettings['dateMax'])
&& $extraSettings['dateMin'] > $extraSettings['dateMax']) {
if ($dateMinDate !== null && $dateMaxDate !== null && $dateMinDate > $dateMaxDate) {
return false;
}
} elseif ($questionType === Constants::ANSWER_TYPE_TIME) {
Expand Down
31 changes: 27 additions & 4 deletions lib/Service/SubmissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -752,10 +752,33 @@ private function validateDateTime(array $answers, string $format, ?string $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['timeMin']) && $d < DateTime::createFromFormat($format, $extraSettings['timeMin']))
|| (isset($extraSettings['timeMax']) && $d > DateTime::createFromFormat($format, $extraSettings['timeMax']))
$dateMin = isset($extraSettings['dateMin'])
? (is_numeric($extraSettings['dateMin'])
? DateTime::createFromFormat('!Y-m-d', (new DateTime('@' . (int)$extraSettings['dateMin']))
->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d'))
: DateTime::createFromFormat('!' . $format, $extraSettings['dateMin']))
: null;
$dateMax = isset($extraSettings['dateMax'])
? (is_numeric($extraSettings['dateMax'])
? DateTime::createFromFormat('!Y-m-d', (new DateTime('@' . (int)$extraSettings['dateMax']))
->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d'))
: DateTime::createFromFormat('!' . $format, $extraSettings['dateMax']))
: null;
$timeMin = isset($extraSettings['timeMin'])
? DateTime::createFromFormat($format, $extraSettings['timeMin'])
: null;
$timeMax = isset($extraSettings['timeMax'])
? DateTime::createFromFormat($format, $extraSettings['timeMax'])
: null;

$compareDate = ($dateMin !== null || $dateMax !== null)
? (DateTime::createFromFormat('!' . $format, $dateStr) ?: $d)
: $d;

if (($dateMin instanceof \DateTimeInterface && $compareDate < $dateMin)
|| ($dateMax instanceof \DateTimeInterface && $compareDate > $dateMax)
|| ($timeMin instanceof \DateTimeInterface && $d < $timeMin)
|| ($timeMax instanceof \DateTimeInterface && $d > $timeMax)
) {
throw new \InvalidArgumentException(sprintf('Date/time is not in the allowed range for question "%s".', $text));
}
Expand Down
28 changes: 20 additions & 8 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -496,12 +496,26 @@
}
},
"dateMax": {
"type": "integer",
"format": "int64"
"oneOf": [
{
"type": "string"
},
{
"type": "integer",
"format": "int64"
}
]
},
"dateMin": {
"type": "integer",
"format": "int64"
"oneOf": [
{
"type": "string"
},
{
"type": "integer",
"format": "int64"
}
]
},
"dateRange": {
"type": "boolean"
Expand Down Expand Up @@ -555,12 +569,10 @@
"type": "boolean"
},
"timeMax": {
"type": "integer",
"format": "int64"
"type": "string"
},
"timeMin": {
"type": "integer",
"format": "int64"
"type": "string"
},
"timeRange": {
"type": "boolean"
Expand Down
34 changes: 23 additions & 11 deletions src/components/Questions/QuestionDate.vue
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,8 @@ type PickerType =

type QuestionDateExtraSettings = {
dateRange?: boolean
dateMax?: number | null
dateMin?: number | null
dateMax?: string | number | null
dateMin?: string | number | null
timeRange?: boolean
timeMax?: string | null
timeMin?: string | null
Expand Down Expand Up @@ -183,11 +183,11 @@ export default defineComponent({
/**
* Form expires timestamp to Date of the datepicker
*
* @param value the expires timestamp
* @param value the expires timestamp or formatted date string
* @return
*/
const parseTimestampToDate = (value: number): Date => {
return moment(value, 'X').toDate()
const parseTimestampToDate = (value: string | number): Date => {
return moment(value, [props.answerType.storageFormat, 'X']).toDate()
}

/**
Expand Down Expand Up @@ -251,7 +251,10 @@ export default defineComponent({
*/
const dateMax = computed<Date | undefined>(() => {
return extraSettings.value.dateMax
? moment(extraSettings.value.dateMax, 'X').toDate()
? moment(extraSettings.value.dateMax, [
props.answerType.storageFormat,
'X',
]).toDate()
: undefined
})

Expand All @@ -260,7 +263,10 @@ export default defineComponent({
*/
const dateMin = computed<Date | undefined>(() => {
return extraSettings.value.dateMin
? moment(extraSettings.value.dateMin, 'X').toDate()
? moment(extraSettings.value.dateMin, [
props.answerType.storageFormat,
'X',
]).toDate()
: undefined
})

Expand Down Expand Up @@ -315,9 +321,12 @@ export default defineComponent({
*
* @param value - The new maximum date value. Can be a string or a Date object.
*/
const onDateMaxChange = (value: string | Date): void => {
const onDateMaxChange = (value: string | Date | null): void => {
question.onExtraSettingsChange({
dateMax: parseInt(moment(value).format('X')),
dateMax:
value === null || value === ''
? null
: moment(value).format(props.answerType.storageFormat),
})
}

Expand All @@ -327,9 +336,12 @@ export default defineComponent({
*
* @param value - The new minimum date value. Can be a string or a Date object.
*/
const onDateMinChange = (value: string | Date): void => {
const onDateMinChange = (value: string | Date | null): void => {
question.onExtraSettingsChange({
dateMin: parseInt(moment(value).format('X')),
dateMin:
value === null || value === ''
? null
: moment(value).format(props.answerType.storageFormat),
})
}

Expand Down
30 changes: 30 additions & 0 deletions tests/Unit/Service/FormsServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1390,6 +1390,20 @@ public function testAreExtraSettingsValid(array $extraSettings, string $question
$this->assertEquals($expected, $this->formsService->areExtraSettingsValid($extraSettings, $questionType));
}

public function testAreExtraSettingsValidNormalizesLegacyDateLimitsAsUtcDates(): void {
$previousTimezone = date_default_timezone_get();
date_default_timezone_set('America/Los_Angeles');

try {
$this->assertTrue($this->formsService->areExtraSettingsValid([
'dateMin' => 1742860800,
'dateMax' => 1742860800,
], Constants::ANSWER_TYPE_DATE));
} finally {
date_default_timezone_set($previousTimezone);
}
}

public static function dataAreExtraSettingsValid() {
return [
'empty-extra-settings' => [
Expand Down Expand Up @@ -1528,6 +1542,14 @@ public static function dataAreExtraSettingsValid() {
'questionType' => Constants::ANSWER_TYPE_DATE,
'expected' => true
],
'valid-date-settings-string' => [
'extraSettings' => [
'dateMin' => '2026-08-20',
'dateMax' => '2026-08-25',
],
'questionType' => Constants::ANSWER_TYPE_DATE,
'expected' => true
],
'invalid-date-settings' => [
'extraSettings' => [
'dateMin' => 'today',
Expand All @@ -1536,6 +1558,14 @@ public static function dataAreExtraSettingsValid() {
'questionType' => Constants::ANSWER_TYPE_DATE,
'expected' => false
],
'invalid-date-settings-overlap' => [
'extraSettings' => [
'dateMin' => '2026-08-25',
'dateMax' => '2026-08-20',
],
'questionType' => Constants::ANSWER_TYPE_DATE,
'expected' => false
],
'invalid-date-limits' => [
// max < min
'extraSettings' => [
Expand Down
56 changes: 56 additions & 0 deletions tests/Unit/Service/SubmissionServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,42 @@ public static function dataValidateSubmission() {
// Expected Result
'Date/time is not in the allowed range for question "q1".',
],
'date-exact-limits-question-string' => [
// Questions
[
['id' => 1, 'type' => 'date', 'text' => 'q1', 'isRequired' => false, 'extraSettings' => ['dateMin' => '2026-08-20', 'dateMax' => '2026-08-24']]
],
// Answers
[
'1' => ['2026-08-24']
],
// Expected Result
null,
],
'date-below-min-question-string' => [
// Questions
[
['id' => 1, 'type' => 'date', 'text' => 'q1', 'isRequired' => false, 'extraSettings' => ['dateMin' => '2026-08-20', 'dateMax' => '2026-08-24']]
],
// Answers
[
'1' => ['2026-08-19']
],
// Expected Result
'Date/time is not in the allowed range for question "q1".',
],
'date-above-max-question-string' => [
// Questions
[
['id' => 1, 'type' => 'date', 'text' => 'q1', 'isRequired' => false, 'extraSettings' => ['dateMin' => '2026-08-20', 'dateMax' => '2026-08-24']]
],
// Answers
[
'1' => ['2026-08-25']
],
// Expected Result
'Date/time is not in the allowed range for question "q1".',
],
'valid-date-range' => [
// Questions
[
Expand Down Expand Up @@ -1380,6 +1416,26 @@ public function testValidateSubmission(array $questions, array $answers, ?string
$this->assertTrue(true);
}

public function testValidateSubmissionNormalizesLegacyDateLimitAsUtcDate(): void {
$previousTimezone = date_default_timezone_get();
date_default_timezone_set('America/Los_Angeles');

try {
$questions = [[
'id' => 1,
'type' => 'date',
'text' => 'q1',
'isRequired' => false,
'extraSettings' => ['dateMin' => 1742860800],
]];

$this->submissionService->validateSubmission($questions, ['1' => ['2025-03-25']], 'admin', 1);
$this->assertTrue(true);
} finally {
date_default_timezone_set($previousTimezone);
}
}

public function testValidateSubmission_rejectsForeignUploadToken(): void {
$questions = [[
'id' => 8,
Expand Down
Loading