diff --git a/REUSE.toml b/REUSE.toml index 249413bd9d..60bfea4ed3 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -47,6 +47,12 @@ precedence = "aggregate" SPDX-FileCopyrightText = "2019 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" +[[annotations]] +path = ["tests/fixtures/jump-paths.json"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2026 Nextcloud GmbH and Nextcloud contributors" +SPDX-License-Identifier = "AGPL-3.0-or-later" + [[annotations]] path = ["img/*"] precedence = "aggregate" diff --git a/docs/API_v3.md b/docs/API_v3.md index 0cb4a5b2e8..a84aa21e77 100644 --- a/docs/API_v3.md +++ b/docs/API_v3.md @@ -49,6 +49,7 @@ This file contains the API-Documentation. For more information on the returned D ### Other API changes +- Questions can jump to a later question or to the end of the form with the extra settings `goTo` and `next` (see [Jumps](DataStructure.md#jumps)). Submissions and submission updates reject answers to the questions the jumps skip. - In API version 3.0 the following endpoints were introduced/changed: - `GET /api/v3/forms/{formId}/questions` to get all questions of a form - `GET /api/v3/forms/{formId}/questions/{questionId}` to get a single question @@ -951,6 +952,7 @@ Store Submission to Database - An **array** of values as value --> Even for short Text Answers, wrapped into Array. - For Question-Types with pre-defined answers (`multiple`, `multiple_unique`, `dropdown`), the array contains the corresponding option-IDs. - For File-Uploads, the array contains objects with `uploadedFileId` and `uploadToken` (both from the [Upload a file] endpoint for the same `questionId`). + - Questions that the [jumps](DataStructure.md#jumps) of the answers skip must not be answered; an answer to a skipped question is rejected, and a skipped required question needs no answer. ``` { diff --git a/docs/DataStructure.md b/docs/DataStructure.md index d0e9acfa34..fc44ca7e90 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -270,6 +270,18 @@ Optional extra settings for some [Question Types](#question-types) | `columns` | `grid` | Array | - | Array of column identifiers / labels for grid questions | | `rows` | `grid` | Array | - | Array of row identifiers / labels for grid questions | | `questionType` | `grid` | String | `checkbox`, `number`, `radio` | Type of cell for grid questions (checkbox, numeric input, or radio) | +| `goTo` | `dropdown, multiple_unique` | Array | see [Jumps](#jumps) | Where to go next for an answer | +| `next` | `color, dropdown, file, grid, long, multiple_unique, ranking, short` | Integer / string | question id, `'submit'` | Where to go after the question when its answer has no `goTo` entry, see [Jumps](#jumps) | + +### Jumps + +An answer can lead to a later question or to the end of the form, as in Microsoft and Google Forms. The form stays a flat list of questions. + +- `goTo` is a list of jumps per answer: `{"optionId": 12, "target": 34}` for an option, `{"other": true, "target": "submit"}` for the answer "Other". There is at most one jump per answer. +- `next` is the target after the question when its answer has no jump, or when the question has no answers to choose from. +- A target is the id of a question of the same form or `"submit"` for the end of the form. Without a jump the respondent goes to the following question. + +The respondent's path starts at the first question and follows these targets. A target that is not a later question of the form, for example after the questions were reordered or the target was deleted, counts as the following question, and a jump of an option that no longer exists never matches. Questions off the path are skipped: answers to them are rejected and their required flag does not apply. ### Option Types diff --git a/lib/Analytics/AnalyticsDatasource.php b/lib/Analytics/AnalyticsDatasource.php index 62f520b4b4..37b493a30e 100644 --- a/lib/Analytics/AnalyticsDatasource.php +++ b/lib/Analytics/AnalyticsDatasource.php @@ -98,6 +98,7 @@ public function getTemplate(): array { public function readData($option): array { $questionMap = []; $answerCounts = []; + $results = []; $formId = $option['formId']; @@ -109,7 +110,10 @@ public function readData($option): array { $submissions = $this->submissionService->getSubmissions($formId); foreach ($submissions as $submission) { foreach ($submission['answers'] as $answer) { - $questionText = $questionMap[$answer['questionId']]; + $questionText = $questionMap[$answer['questionId']] ?? null; + if ($questionText === null) { + continue; + } $key = $questionText . '|' . $answer['text']; if (isset($answerCounts[$key])) { $answerCounts[$key]++; diff --git a/lib/Constants.php b/lib/Constants.php index 3e465cd0cd..c17b1a6f42 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -233,6 +233,24 @@ class Constants { 'shuffleOptions' => ['boolean'], ]; + public const JUMP_TARGET_SUBMIT = 'submit'; + + public const QUESTION_TYPES_WITH_GOTO = [ + self::ANSWER_TYPE_DROPDOWN, + self::ANSWER_TYPE_MULTIPLEUNIQUE, + ]; + + public const QUESTION_TYPES_WITH_NEXT = [ + self::ANSWER_TYPE_COLOR, + self::ANSWER_TYPE_DROPDOWN, + self::ANSWER_TYPE_FILE, + self::ANSWER_TYPE_GRID, + self::ANSWER_TYPE_LONG, + self::ANSWER_TYPE_MULTIPLEUNIQUE, + self::ANSWER_TYPE_RANKING, + self::ANSWER_TYPE_SHORT, + ]; + public const EXTRA_SETTINGS_GRID_QUESTION_TYPE = [ self::ANSWER_GRID_TYPE_CHECKBOX, self::ANSWER_GRID_TYPE_NUMBER, diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 41983200ae..f1098e2fa0 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -222,6 +222,9 @@ public function newForm(?int $fromId = null): DataResponse { // Get Questions, set new formId, reinsert $questions = $this->questionMapper->findByForm($oldForm->getId()); $oldConfirmationEmailQuestionId = $oldForm->getConfirmationEmailQuestionId(); + $questionIdMap = []; + $optionIdMap = []; + $newQuestions = []; foreach ($questions as $oldQuestion) { $questionData = $oldQuestion->read(); @@ -230,6 +233,8 @@ public function newForm(?int $fromId = null): DataResponse { $questionData['formId'] = $form->getId(); $newQuestion = Question::fromParams($questionData); $this->questionMapper->insert($newQuestion); + $questionIdMap[$oldQuestion->getId()] = $newQuestion->getId(); + $newQuestions[] = $newQuestion; if (isset($oldConfirmationEmailQuestionId) && $oldConfirmationEmailQuestionId === $oldQuestion->getId()) { $form->setConfirmationEmailQuestionId($newQuestion->getId()); @@ -244,6 +249,15 @@ public function newForm(?int $fromId = null): DataResponse { $optionData['questionId'] = $newQuestion->getId(); $newOption = Option::fromParams($optionData); $this->optionMapper->insert($newOption); + $optionIdMap[$oldOption->getId()] = $newOption->getId(); + } + } + + foreach ($newQuestions as $newQuestion) { + $extraSettings = $newQuestion->getExtraSettings(); + if (array_key_exists('goTo', $extraSettings) || array_key_exists('next', $extraSettings)) { + $newQuestion->setExtraSettings(FormsService::mapJumps($extraSettings, $questionIdMap, $optionIdMap)); + $this->questionMapper->update($newQuestion); } } @@ -617,6 +631,9 @@ public function newQuestion(int $formId, ?string $type = null, ?string $subtype if ($sourceQuestion->getFormId() !== $formId) { throw new OCSBadRequestException('Question doesn\'t belong to given form'); } + if ($sourceQuestion->getOrder() === 0) { + throw new OCSNotFoundException('Could not find question'); + } $sourceOptions = $this->optionMapper->findByQuestion($fromId); } catch (IMapperException) { $this->logger->debug('Could not find question'); @@ -641,6 +658,7 @@ public function newQuestion(int $formId, ?string $type = null, ?string $subtype $response = $newQuestion->read(); $response['options'] = []; $response['accept'] = []; + $optionIdMap = []; foreach ($sourceOptions as $sourceOption) { $optionData = $sourceOption->read(); @@ -649,9 +667,16 @@ public function newQuestion(int $formId, ?string $type = null, ?string $subtype $optionData['questionId'] = $newQuestion->getId(); $newOption = Option::fromParams($optionData); $insertedOption = $this->optionMapper->insert($newOption); + $optionIdMap[$sourceOption->getId()] = $insertedOption->getId(); $response['options'][] = $insertedOption->read(); } + + if (array_key_exists('goTo', $newQuestion->getExtraSettings())) { + $newQuestion->setExtraSettings(FormsService::mapJumps($newQuestion->getExtraSettings(), null, $optionIdMap)); + $this->questionMapper->update($newQuestion); + $response['extraSettings'] = $newQuestion->getExtraSettings(); + } } $this->formMapper->update($form); @@ -795,6 +820,10 @@ public function deleteQuestion(int $formId, int $questionId): DataResponse { throw new OCSBadRequestException('Question doesn\'t belong to given Form'); } + if ($question->getOrder() === 0) { + throw new OCSNotFoundException('Could not find question'); + } + // Store Order of deleted Question $deletedOrder = $question->getOrder(); diff --git a/lib/FormsMigrator.php b/lib/FormsMigrator.php index b0fc92855e..a9bf608ca5 100644 --- a/lib/FormsMigrator.php +++ b/lib/FormsMigrator.php @@ -77,7 +77,6 @@ public function export(IUser $user, IExportDestination $exportDestination, Outpu // Do NOT unset ID of question here, as it is necessary for answers. unset($formData['questions'][$qKey]['formId']); foreach ($question['options'] as $oKey => $option) { - unset($formData['questions'][$qKey]['options'][$oKey]['id']); unset($formData['questions'][$qKey]['options'][$oKey]['questionId']); } } @@ -164,6 +163,8 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface $this->formMapper->insert($form); $questionIdMap = []; + $optionIdMap = []; + $questions = []; foreach ($formData['questions'] as $questionData) { $question = new Question(); $question->setFormId($form->getId()); @@ -178,6 +179,7 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface // Store QuestionId to map Answers. $questionIdMap[$questionData['id']] = $question->getId(); + $questions[] = $question; foreach ($questionData['options'] as $optionData) { $option = new Option(); @@ -185,6 +187,17 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface $option->setText($optionData['text']); $this->optionMapper->insert($option); + if (isset($optionData['id'])) { + $optionIdMap[$optionData['id']] = $option->getId(); + } + } + } + + foreach ($questions as $question) { + $extraSettings = $question->getExtraSettings(); + if (array_key_exists('goTo', $extraSettings) || array_key_exists('next', $extraSettings)) { + $question->setExtraSettings(FormsService::mapJumps($extraSettings, $questionIdMap, $optionIdMap)); + $this->questionMapper->update($question); } } @@ -203,6 +216,10 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface $this->submissionMapper->insert($submission); foreach ($submissionData['answers'] as $answerData) { + if (!array_key_exists($answerData['questionId'], $questionIdMap)) { + continue; + } + $answer = new Answer(); $answer->setSubmissionId($submission->getId()); $answer->setQuestionId($questionIdMap[$answerData['questionId']]); diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 15ed973248..12251e2679 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -27,8 +27,10 @@ * dateMax?: string|int, * dateMin?: string|int, * dateRange?: bool, + * goTo?: list, * maxAllowedFilesCount?: int, * maxFileSize?: int, + * next?: int|string, * optionsHighest?: 2|3|4|5|6|7|8|9|10, * optionsLabelHighest?: string, * optionsLabelLowest?: string, diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index 197ababbcb..5eb684ba80 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -913,6 +913,12 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType Constants::ANSWER_TYPE_LINEARSCALE => Constants::EXTRA_SETTINGS_LINEARSCALE, default => [], }; + if (in_array($questionType, Constants::QUESTION_TYPES_WITH_NEXT, true)) { + $allowed['next'] = ['integer', 'string']; + } + if (in_array($questionType, Constants::QUESTION_TYPES_WITH_GOTO, true)) { + $allowed['goTo'] = ['array']; + } // Number of keys in extraSettings but not in allowed (but not the other way round) $diff = array_diff(array_keys($extraSettings), array_keys($allowed)); if (count($diff) > 0) { @@ -927,6 +933,13 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType } } + if (array_key_exists('next', $extraSettings) && !self::isJumpTarget($extraSettings['next'])) { + return false; + } + if (array_key_exists('goTo', $extraSettings) && !self::isGoToValid($extraSettings['goTo'], $questionType)) { + return false; + } + // Validate extraSettings for specific question types if ($questionType === Constants::ANSWER_TYPE_DATE) { $format = Constants::ANSWER_PHPDATETIME_FORMAT['date']; @@ -1055,6 +1068,98 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType return true; } + /** + * Whether the value is a jump target: the id of a question or the end of the form + * + * @param mixed $target the value + */ + private static function isJumpTarget(mixed $target): bool { + return (is_int($target) && $target > 0) || $target === Constants::JUMP_TARGET_SUBMIT; + } + + /** + * Whether the value is a list of jumps per answer, each with one answer key and a target, at most one per answer + * + * @param mixed $goTo the value of `goTo` + * @param string $questionType the question type + */ + private static function isGoToValid(mixed $goTo, string $questionType): bool { + if (!is_array($goTo) || !array_is_list($goTo)) { + return false; + } + + $answers = []; + foreach ($goTo as $jump) { + if (!is_array($jump) || count($jump) !== 2 || !self::isJumpTarget($jump['target'] ?? null)) { + return false; + } + + if (is_int($jump['optionId'] ?? null) && $jump['optionId'] > 0) { + $answer = 'option:' . $jump['optionId']; + } elseif (($jump['other'] ?? null) === true) { + $answer = 'other'; + } else { + return false; + } + + if (isset($answers[$answer])) { + return false; + } + $answers[$answer] = true; + } + + return true; + } + + /** + * The extra settings of a copied question, with its jumps pointing at the copied questions and options. + * Jumps whose target or option has no copy are removed. + * + * @param array $extraSettings the extra settings of the original question + * @param ?array $questionIdMap the ids of the copied questions by original id, `null` keeps the targets + * @param array $optionIdMap the ids of the copied options by original id + * @return array the extra settings of the copy + */ + public static function mapJumps(array $extraSettings, ?array $questionIdMap, array $optionIdMap): array { + $mapTarget = static function (mixed $target) use ($questionIdMap): string|int|null { + if ($target === Constants::JUMP_TARGET_SUBMIT || ($questionIdMap === null && is_int($target))) { + return $target; + } + return is_int($target) ? ($questionIdMap[$target] ?? null) : null; + }; + + if (array_key_exists('next', $extraSettings)) { + $next = $mapTarget($extraSettings['next']); + if ($next === null) { + unset($extraSettings['next']); + } else { + $extraSettings['next'] = $next; + } + } + + if (array_key_exists('goTo', $extraSettings)) { + $goTo = []; + foreach (is_array($extraSettings['goTo']) ? $extraSettings['goTo'] : [] as $jump) { + if (!is_array($jump)) { + continue; + } + $jump['target'] = $mapTarget($jump['target'] ?? null); + if (array_key_exists('optionId', $jump)) { + $jump['optionId'] = is_int($jump['optionId']) ? ($optionIdMap[$jump['optionId']] ?? null) : null; + if ($jump['optionId'] === null) { + continue; + } + } + if ($jump['target'] !== null) { + $goTo[] = $jump; + } + } + $extraSettings['goTo'] = $goTo; + } + + return $extraSettings; + } + /** * Get list of forms * diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index 278fb28286..473c2b38b3 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -594,8 +594,10 @@ private function escapeCSV(string $value): string { * @throw \InvalidArgumentException if validation failed */ public function validateSubmission(array $questions, array $answers, string $formOwnerId, int $formId): void { + $visibleQuestions = $this->getVisibleQuestions($questions, $answers); + // Check by questions - foreach ($questions as $question) { + foreach ($visibleQuestions as $question) { $questionId = $question['id']; $questionAnswered = array_key_exists($questionId, $answers); @@ -665,7 +667,7 @@ public function validateSubmission(array $questions, array $answers, string $for } // Check if all answers are within the possible options - if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED) && empty($question['extraSettings']['allowOtherAnswer'])) { + if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED)) { // Normalize option IDs once for consistent comparison (DB may return ints, request may send strings) $optionIds = $this->normalizeOptionIds($question['options'] ?? []); @@ -683,12 +685,12 @@ public function validateSubmission(array $questions, array $answers, string $for $this->validateGridQuestion($question, $answers[$questionId]); } // Search corresponding option, return false if non-existent - else { + elseif (!$this->isOtherAnswer($question, $answer)) { // Accept numeric strings like "46" from JSON payloads reliably (e.g. with hardening extensions enabled) - $answerId = is_int($answer) ? $answer : (is_string($answer) ? intval(trim($answer)) : null); + $answerId = is_int($answer) ? $answer : (is_string($answer) ? intval($answer) : null); // Reject non-numeric / malformed values early - if ($answerId === null || (string)$answerId !== (string)intval($answerId)) { + if ($answerId === null || (string)$answerId !== (string)$answer) { throw new \InvalidArgumentException(sprintf('Answer "%s" for question "%s" is not a valid option.', is_scalar($answer) ? (string)$answer : gettype($answer), $question['text'])); } @@ -754,12 +756,73 @@ public function validateSubmission(array $questions, array $answers, string $for } // Check for excess answers + $visibleQuestionIds = array_column($visibleQuestions, 'id'); foreach ($answers as $id => $answerArray) { // Search corresponding question, return false if not found - if (!in_array($id, array_column($questions, 'id'))) { + if (!in_array($id, array_column($questions, 'id'), true)) { throw new \InvalidArgumentException(sprintf('Answer for non-existent question with ID %d.', $id)); } + if (!in_array($id, $visibleQuestionIds, true)) { + throw new \InvalidArgumentException(sprintf('Answer for skipped question with ID %d.', $id)); + } + } + } + + /** + * The questions on the path of the respondent: from the first question, each question leads to the target of its + * answer in `goTo`, else to its `next` target, else to the following question, until the end of the form. + * A target that is not a later question of the form counts as the following question. + * + * @param array $questions the questions of the form in their order + * @param array $answers the answers by question id + * @return list the questions on the path, in their order + */ + public function getVisibleQuestions(array $questions, array $answers): array { + $questions = array_values($questions); + $positions = array_flip(array_column($questions, 'id')); + $visibleQuestions = []; + + $position = 0; + while ($position < count($questions)) { + $question = $questions[$position]; + $visibleQuestions[] = $question; + + $target = $this->getJumpTarget($question, $answers[$question['id']] ?? []); + if ($target === Constants::JUMP_TARGET_SUBMIT) { + break; + } + $targetPosition = is_int($target) ? ($positions[$target] ?? null) : null; + $position = $targetPosition !== null && $targetPosition > $position ? $targetPosition : $position + 1; + } + + return $visibleQuestions; + } + + /** + * The jump target of a question for its answer: the target of the matching `goTo` entry, else `next` + * + * @param array $question the question + * @param mixed $answer the values of the answer to the question + * @return string|int|null the target, `null` for the following question + */ + private function getJumpTarget(array $question, mixed $answer): string|int|null { + $extraSettings = $question['extraSettings'] ?? []; + $values = is_array($answer) ? $answer : []; + + foreach (is_array($extraSettings['goTo'] ?? null) ? $extraSettings['goTo'] : [] as $jump) { + foreach ($values as $value) { + if (!is_int($value) && !is_float($value) && !is_string($value)) { + continue; + } + if ((isset($jump['optionId']) && (string)$jump['optionId'] === (string)$value) + || (($jump['other'] ?? false) === true && $this->isOtherAnswer($question, $value))) { + return $jump['target'] ?? null; + } + } } + + $next = $extraSettings['next'] ?? null; + return is_int($next) || is_string($next) ? $next : null; } /** @@ -898,6 +961,18 @@ private function validateGridQuestion(array $question, array $answers): void { } } + /** + * Whether the value is the answer "Other" of a question that allows it + * + * @param array $question the question + * @param mixed $answer a value of the answer + */ + private function isOtherAnswer(array $question, mixed $answer): bool { + return !empty($question['extraSettings']['allowOtherAnswer']) + && is_string($answer) + && str_starts_with($answer, Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX); + } + private function normalizeNumericOptionId(mixed $value): ?int { if (is_int($value)) { return $value; diff --git a/openapi.json b/openapi.json index 4336a8c924..74350af7bf 100644 --- a/openapi.json +++ b/openapi.json @@ -530,6 +530,35 @@ "dateRange": { "type": "boolean" }, + "goTo": { + "type": "array", + "items": { + "type": "object", + "required": [ + "target" + ], + "properties": { + "optionId": { + "type": "integer", + "format": "int64" + }, + "other": { + "type": "boolean" + }, + "target": { + "oneOf": [ + { + "type": "integer", + "format": "int64" + }, + { + "type": "string" + } + ] + } + } + } + }, "maxAllowedFilesCount": { "type": "integer", "format": "int64" @@ -538,6 +567,17 @@ "type": "integer", "format": "int64" }, + "next": { + "oneOf": [ + { + "type": "integer", + "format": "int64" + }, + { + "type": "string" + } + ] + }, "optionsHighest": { "type": "integer", "format": "int64", diff --git a/playwright/e2e/jump-paths.spec.ts b/playwright/e2e/jump-paths.spec.ts new file mode 100644 index 0000000000..d69cae5963 --- /dev/null +++ b/playwright/e2e/jump-paths.spec.ts @@ -0,0 +1,33 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { FormsQuestion } from '../../src/types/Entities.d.ts' + +import { expect, test } from '@playwright/test' +import { readFileSync } from 'node:fs' +import { getVisibleQuestions } from '../../src/utils/Jumps.ts' + +/** + * Runs the cases of tests/fixtures/jump-paths.json, shared with SubmissionServiceTest::testJumpPaths() + */ +const cases: Array<{ + name: string + questions: FormsQuestion[] + answers: Record + visible: number[] +}> = JSON.parse( + readFileSync( + new URL('../../tests/fixtures/jump-paths.json', import.meta.url), + 'utf-8', + ), +) + +for (const { name, questions, answers, visible } of cases) { + test(`jump path: ${name}`, () => { + expect( + getVisibleQuestions(questions, answers).map((question) => question.id), + ).toEqual(visible) + }) +} diff --git a/playwright/e2e/jumps.spec.ts b/playwright/e2e/jumps.spec.ts new file mode 100644 index 0000000000..7e7eb50702 --- /dev/null +++ b/playwright/e2e/jumps.spec.ts @@ -0,0 +1,156 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { APIRequestContext } from '@playwright/test' + +import { expect, mergeTests } from '@playwright/test' +import { test as randomUserTest } from '../support/fixtures/random-user.ts' +import { test as submitTest } from '../support/fixtures/submit.ts' + +const test = mergeTests(randomUserTest, submitTest) + +/** + * Call the Forms OCS API as the logged in user + * + * @param request the request context of the page + * @param method the HTTP method + * @param path the path below `api/v3` + * @param data the JSON body + */ +async function api( + request: APIRequestContext, + method: string, + path: string, + data?: unknown, +) { + const response = await request.fetch(`../ocs/v2.php/apps/forms/api/v3${path}`, { + method, + data, + headers: { 'OCS-APIRequest': 'true', Accept: 'application/json' }, + }) + expect(response.ok()).toBeTruthy() + return (await response.json()).ocs.data +} + +/** + * Create a form with a radio question "Do you have a pet?" (Yes, No), a required short question "Which pet?" and a short question "Comments" + * + * @param request the request context of the page + */ +async function createPetForm(request: APIRequestContext) { + const form = await api(request, 'POST', '/forms') + await api(request, 'PATCH', `/forms/${form.id}`, { + keyValuePairs: { title: 'Pet form' }, + }) + const pet = await api(request, 'POST', `/forms/${form.id}/questions`, { + type: 'multiple_unique', + text: 'Do you have a pet?', + }) + const options = await api( + request, + 'POST', + `/forms/${form.id}/questions/${pet.id}/options`, + { optionTexts: ['Yes', 'No'] }, + ) + const which = await api(request, 'POST', `/forms/${form.id}/questions`, { + type: 'short', + text: 'Which pet?', + }) + await api(request, 'PATCH', `/forms/${form.id}/questions/${which.id}`, { + keyValuePairs: { isRequired: true }, + }) + const comments = await api(request, 'POST', `/forms/${form.id}/questions`, { + type: 'short', + text: 'Comments', + }) + return { form, pet, options, which, comments } +} + +test.describe('Jumps', () => { + test('An answer jumps to a later question, set in the editor', async ({ + page, + }) => { + const { form, pet, options, comments } = await createPetForm(page.request) + await page.goto(`apps/forms/${form.hash}/edit`) + + const question = page.getByRole('listitem', { name: 'Question number 1' }) + await question.getByRole('button', { name: 'Actions', exact: true }).click() + await page + .getByRole('checkbox', { name: 'Go to based on answer' }) + .click({ force: true }) + await page.keyboard.press('Escape') + + await question.getByRole('combobox', { name: 'After "No" go to' }).click() + await page + .getByRole('option') + .filter({ hasText: 'Question 3: Comments' }) + .click() + + await expect(async () => { + const stored = await api( + page.request, + 'GET', + `/forms/${form.id}/questions/${pet.id}`, + ) + expect(stored.extraSettings.goTo).toEqual([ + { optionId: options[1].id, target: comments.id }, + ]) + }).toPass() + }) + + test('Skipped questions are hidden, not required and not submitted', async ({ + page, + submitView, + }) => { + const { form, pet, options, comments } = await createPetForm(page.request) + await api(page.request, 'PATCH', `/forms/${form.id}/questions/${pet.id}`, { + keyValuePairs: { + extraSettings: { + goTo: [{ optionId: options[1].id, target: comments.id }], + }, + }, + }) + await page.goto(`apps/forms/${form.hash}/submit`) + + await expect(submitView.getQuestion(/Which pet\?/)).toBeVisible() + await submitView.checkRadio('Do you have a pet?', 'Yes') + await submitView.fillText(/Which pet\?/, 'Rex') + await submitView.checkRadio('Do you have a pet?', 'No') + await expect(submitView.getQuestion(/Which pet\?/)).toBeHidden() + await submitView.fillText('Comments', 'None') + + await submitView.submit() + await expect(submitView.successMessage).toBeVisible() + + const { submissions } = await api( + page.request, + 'GET', + `/forms/${form.id}/submissions`, + ) + expect(submissions[0].answers.map((answer) => answer.text).sort()).toEqual([ + 'No', + 'None', + ]) + }) + + test('"End of the form" after a question hides the questions after it', async ({ + page, + submitView, + }) => { + const { form, pet, options } = await createPetForm(page.request) + await api(page.request, 'PATCH', `/forms/${form.id}/questions/${pet.id}`, { + keyValuePairs: { + extraSettings: { + goTo: [{ optionId: options[1].id, target: 'submit' }], + }, + }, + }) + await page.goto(`apps/forms/${form.hash}/submit`) + + await submitView.checkRadio('Do you have a pet?', 'No') + await expect(submitView.getQuestion(/Which pet\?/)).toBeHidden() + await expect(submitView.getQuestion('Comments')).toBeHidden() + }) +}) diff --git a/src/components/Questions/AnswerInput.vue b/src/components/Questions/AnswerInput.vue index 9181f22ffe..c82f0f9624 100644 --- a/src/components/Questions/AnswerInput.vue +++ b/src/components/Questions/AnswerInput.vue @@ -4,7 +4,10 @@ -->