From 8797a8ef065ac3512fa8359647a518a333aa0e72 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 b21a127ef..bbbc51027 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -594,8 +594,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 @@ -610,6 +630,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 79f9760b5..104882d65 100644 --- a/tests/Unit/Service/SubmissionServiceTest.php +++ b/tests/Unit/Service/SubmissionServiceTest.php @@ -925,6 +925,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 433302bd7c9165496a8b2f3548f0379b8edf4d91 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 bbbc51027..dab992070 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -704,7 +704,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 104882d65..58cf73734 100644 --- a/tests/Unit/Service/SubmissionServiceTest.php +++ b/tests/Unit/Service/SubmissionServiceTest.php @@ -1337,6 +1337,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 [