From d29d4fa7472e2e6be445adea8b9b6ee127e0bffe Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 10 Nov 2026 20:00:38 +0100 Subject: [PATCH 1/2] fix(submission): do not require questions the form does not show The submit view hides questions without a title, choice, dropdown and ranking questions without options, and grid questions without rows or columns. The server still required an answer to such a question when it was marked as required, so every submission failed with "Question "" is required." and respondents could not fix it. Skip the required check for these questions, using the same rules as the submit view. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Andy Scherzinger --- lib/Service/SubmissionService.php | 21 ++++++++++ tests/Unit/Service/SubmissionServiceTest.php | 40 ++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index 278fb2828..b0b4c076d 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -585,8 +585,28 @@ private function escapeCSV(string $value): string { return $value; } + /** + * Whether the submit view shows the question to respondents: it needs a title, choice, dropdown and ranking questions need options, and grid questions need rows and columns + * + * @param array $question the question with its options + */ + private static function isShownToRespondents(array $question): bool { + if (preg_match('/^[\s\p{Z}\x{FEFF}]*$/u', (string)($question['text'] ?? '')) === 1) { + return false; + } + + $optionTypes = array_map(static fn (mixed $option): mixed => is_array($option) ? ($option['optionType'] ?? null) : null, $question['options'] ?? []); + return match ($question['type'] ?? null) { + Constants::ANSWER_TYPE_MULTIPLE, Constants::ANSWER_TYPE_MULTIPLEUNIQUE, Constants::ANSWER_TYPE_DROPDOWN, Constants::ANSWER_TYPE_RANKING => $optionTypes !== [], + Constants::ANSWER_TYPE_GRID => in_array(Option::OPTION_TYPE_ROW, $optionTypes, true) && in_array(Option::OPTION_TYPE_COLUMN, $optionTypes, true), + default => true, + }; + } + /** * Validate all answers against the questions + * Required questions the submit view does not show to respondents need no answer + * * @param array $questions Array of the questions of the form * @param array $answers Array of the submitted answers * @param string $formOwnerId Owner of the form @@ -601,6 +621,7 @@ public function validateSubmission(array $questions, array $answers, string $for // Check if all required questions have an answer if ($question['isRequired'] + && self::isShownToRespondents($question) && (!$questionAnswered || !array_filter($answers[$questionId], static function (string|array $value): bool { if (is_array($value)) { diff --git a/tests/Unit/Service/SubmissionServiceTest.php b/tests/Unit/Service/SubmissionServiceTest.php index e3d19a7e4..612bbe101 100644 --- a/tests/Unit/Service/SubmissionServiceTest.php +++ b/tests/Unit/Service/SubmissionServiceTest.php @@ -813,6 +813,46 @@ private function setUpCsvTest(array $questions, array $submissions, string $csvT // Data for validation of Submissions public static function dataValidateSubmission() { return [ + 'required-without-title-is-not-shown' => [ + [ + ['id' => 1, 'type' => 'short', 'text' => ' ', 'isRequired' => true] + ], + [], + null, + ], + 'required-with-unicode-space-title-is-not-shown' => [ + [ + ['id' => 1, 'type' => 'short', 'text' => "\u{00A0}\u{3000}\u{FEFF}", 'isRequired' => true] + ], + [], + null, + ], + 'required-choice-without-options-is-not-shown' => [ + [ + ['id' => 1, 'type' => 'multiple_unique', 'text' => 'q1', 'isRequired' => true, 'options' => []] + ], + [], + null, + ], + 'required-grid-without-columns-is-not-shown' => [ + [ + ['id' => 1, 'type' => 'grid', 'text' => 'q1', 'isRequired' => true, 'options' => [ + ['id' => 3, 'optionType' => 'row'] + ]] + ], + [], + null, + ], + 'required-grid-with-rows-and-columns' => [ + [ + ['id' => 1, 'type' => 'grid', 'text' => 'q1', 'isRequired' => true, 'options' => [ + ['id' => 3, 'optionType' => 'row'], + ['id' => 4, 'optionType' => 'column'] + ]] + ], + [], + 'Question "q1" is required.', + ], 'required-not-answered' => [ // Questions [ From 2b8861e5bd786c9fe7af78a56ef85aa3080affb1 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Wed, 11 Nov 2026 07:07:39 +0100 Subject: [PATCH 2/2] fix(submission): reject booleans and lists as answers to linear scales A linear scale accepted the answer true as the value 1, as the check cast it to the string "1". A list was cast to "Array" with a warning before it was rejected. Both are now rejected as not being a value of the scale. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Andy Scherzinger --- lib/Service/SubmissionService.php | 2 +- tests/Unit/Service/SubmissionServiceTest.php | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index b0b4c076d..507140995 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -695,7 +695,7 @@ public function validateSubmission(array $questions, array $answers, string $for if ($question['type'] === Constants::ANSWER_TYPE_LINEARSCALE) { $optionsLowest = $question['extraSettings']['optionsLowest'] ?? 1; $optionsHighest = $question['extraSettings']['optionsHighest'] ?? 5; - if (!ctype_digit((string)$answer) || intval($answer) < $optionsLowest || intval($answer) > $optionsHighest) { + if (!is_scalar($answer) || is_bool($answer) || !ctype_digit((string)$answer) || intval($answer) < $optionsLowest || intval($answer) > $optionsHighest) { throw new \InvalidArgumentException(sprintf('The answer for question "%s" must be an integer between %d and %d.', $question['text'], $optionsLowest, $optionsHighest)); } } diff --git a/tests/Unit/Service/SubmissionServiceTest.php b/tests/Unit/Service/SubmissionServiceTest.php index 612bbe101..1abe556ba 100644 --- a/tests/Unit/Service/SubmissionServiceTest.php +++ b/tests/Unit/Service/SubmissionServiceTest.php @@ -1225,6 +1225,24 @@ public static function dataValidateSubmission() { // Expected Result 'The answer for question "q1" must be an integer between 0 and 5.', ], + 'boolean-linearscale-answer' => [ + [ + ['id' => 1, 'type' => 'linearscale', 'text' => 'q1', 'isRequired' => false, 'extraSettings' => ['optionsLowest' => 0]] + ], + [ + '1' => [true] + ], + 'The answer for question "q1" must be an integer between 0 and 5.', + ], + 'list-linearscale-answer' => [ + [ + ['id' => 1, 'type' => 'linearscale', 'text' => 'q1', 'isRequired' => false, 'extraSettings' => ['optionsLowest' => 0]] + ], + [ + '1' => [['3']] + ], + 'The answer for question "q1" must be an integer between 0 and 5.', + ], 'invalid-color-question' => [ // Questions [