diff --git a/docs/DataStructure.md b/docs/DataStructure.md index a4dc03c64..8e5d1cb05 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -1,5 +1,5 @@ @@ -227,6 +227,7 @@ Currently supported Question-Types are: | `file` | One or multiple files. It is possible to specify which mime types are allowed | | `linearscale` | A linear or Likert scale question where you choose an option that best fits your opinion | | `color` | A color answer, hex string representation (e. g. `#123456`) | +| `conditional` | A conditional branching question with a trigger question and multiple branches containing subquestions | ## Extra Settings @@ -254,3 +255,189 @@ Optional extra settings for some [Question Types](#question-types) | `optionsHighest` | `linearscale` | Integer | `2, 3, 4, 5, 6, 7, 8, 9, 10` | Set the highest value of the scale, default: `5` | | `optionsLabelLowest` | `linearscale` | string | - | Set the label of the lowest value, default: `'Strongly disagree'` | | `optionsLabelHighest` | `linearscale` | string | - | Set the label of the highest value, default: `'Strongly agree'` | +| `triggerType` | `conditional` | string | [See trigger types](#conditional-trigger-types) | The type of trigger question (dropdown, multiple_unique, etc.) | +| `branches` | `conditional` | Array | Array of [Branch objects](#branch-object) | The branches with conditions and subquestions | + +## Conditional Questions + +Conditional questions enable branching logic in forms. A trigger question determines which branch of subquestions appears based on the respondent's answer. + +### Question Properties for Subquestions + +Subquestions (questions belonging to a conditional question's branch) have additional properties: + +| Property | Type | Description | +| ---------------- | ------- | -------------------------------------------------------- | +| parentQuestionId | Integer | The ID of the parent conditional question (null for regular questions) | +| branchId | String | The ID of the branch this subquestion belongs to | + +### Conditional Trigger Types + +Supported trigger types for conditional questions: + +| Trigger Type | Condition Type | Description | +| ----------------- | -------------------- | ------------------------------------------------ | +| `multiple_unique` | `option_selected` | Radio buttons - single option selection | +| `dropdown` | `option_selected` | Dropdown - single option selection | +| `multiple` | `options_combination`| Checkboxes - all specified options must be selected | +| `short` | `string_equals`, `string_contains`, `regex` | Short text with string/regex matching | +| `long` | `string_contains`, `regex` | Long text with string/regex matching | +| `linearscale` | `value_equals`, `value_range` | Linear scale with value matching | +| `date` | `date_range` | Date with date range matching (YYYY-MM-DD) | +| `time` | `time_range` | Time with time range matching (HH:mm) | +| `color` | `value_equals` | Color with exact value matching | +| `file` | `file_uploaded` | File with upload status matching | + +### Branch Object + +A branch defines conditions and subquestions that appear when those conditions are met. + +| Property | Type | Description | +| ------------ | ------------------------------------------- | --------------------------------------------- | +| id | String | Unique identifier for the branch | +| conditions | Array of [Conditions](#condition-object) | Conditions that must be met to show the branch| +| subQuestions | Array of [Questions](#question) | Questions shown when conditions are met | + +```json +{ + "id": "branch-1705587600000", + "conditions": [ + { "type": "option_selected", "optionId": 42 } + ], + "subQuestions": [ + { + "id": 101, + "formId": 3, + "order": 1, + "type": "short", + "text": "Please provide details", + "parentQuestionId": 100, + "branchId": "branch-1705587600000" + } + ] +} +``` + +### Condition Object + +Conditions determine when a branch is activated. The structure depends on the trigger type. + +#### option_selected (for dropdown, multiple_unique) + +```json +{ "type": "option_selected", "optionId": 42 } +``` + +#### options_combination (for multiple/checkboxes) + +```json +{ "type": "options_combination", "optionIds": [42, 43] } +``` +All options in `optionIds` must be selected for the branch to activate (AND logic). + +#### string_equals (for short text) + +```json +{ "type": "string_equals", "value": "yes" } +``` + +#### string_contains (for short, long text) + +```json +{ "type": "string_contains", "value": "keyword" } +``` + +#### regex (for short, long text) + +```json +{ "type": "regex", "value": "^yes.*" } +``` + +#### value_equals (for color) + +```json +{ "type": "value_equals", "value": "#ff0000" } +``` + +#### value_range (for linearscale, time) + +```json +{ "type": "value_range", "min": 3, "max": 5 } +``` + +#### date_range (for date) + +```json +{ "type": "date_range", "min": "2024-01-01", "max": "2024-12-31" } +``` + +#### time_range (for time) + +```json +{ "type": "time_range", "min": "09:00", "max": "17:00" } +``` + +#### file_uploaded (for file) + +```json +{ "type": "file_uploaded", "fileUploaded": true } +``` + +### Conditional Question Example + +A complete conditional question structure: + +```json +{ + "id": 100, + "formId": 3, + "order": 1, + "type": "conditional", + "isRequired": true, + "text": "Do you have any dietary restrictions?", + "options": [ + { "id": 42, "questionId": 100, "order": 1, "text": "Yes" }, + { "id": 43, "questionId": 100, "order": 2, "text": "No" } + ], + "extraSettings": { + "triggerType": "dropdown", + "branches": [ + { + "id": "branch-yes", + "conditions": [{ "type": "option_selected", "optionId": 42 }], + "subQuestions": [ + { + "id": 101, + "formId": 3, + "order": 1, + "type": "long", + "text": "Please describe your dietary restrictions", + "parentQuestionId": 100, + "branchId": "branch-yes" + } + ] + } + ] + } +} +``` + +### Conditional Answer Structure + +When submitting or storing conditional question answers, the structure differs from regular questions: + +```json +{ + "100": { + "trigger": ["42"], + "subQuestions": { + "101": ["Vegetarian, no nuts"] + } + } +} +``` + +| Property | Type | Description | +| ------------ | --------------------------- | ----------------------------------------------------- | +| trigger | Array of strings | Answer values for the trigger question | +| subQuestions | Object (questionId → Array) | Map of subquestion IDs to their answer value arrays | diff --git a/lib/Constants.php b/lib/Constants.php index 7b09bda65..7cdeb52b0 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -1,7 +1,7 @@ ['string', 'NULL'], ]; + /** + * Extra settings for conditional questions + * - triggerType: The question type used for the trigger (e.g., 'multiple_unique', 'dropdown', 'short') + * - branches: Array of branch definitions, each containing: + * - id: Unique branch identifier + * - conditions: Array of condition objects defining when this branch is active + * For predefined types: [{ optionId: number }] + * For text types: [{ type: 'string_equals'|'string_contains'|'regex', value: string }] + * For numeric/scale: [{ type: 'value_equals'|'value_range', value: number, min?: number, max?: number }] + */ + public const EXTRA_SETTINGS_CONDITIONAL = [ + 'triggerType' => ['string'], + 'branches' => ['array'], + ]; + + /** + * Condition types for conditional questions + */ + public const CONDITION_TYPE_OPTION_SELECTED = 'option_selected'; + public const CONDITION_TYPE_OPTIONS_COMBINATION = 'options_combination'; + public const CONDITION_TYPE_STRING_EQUALS = 'string_equals'; + public const CONDITION_TYPE_STRING_CONTAINS = 'string_contains'; + public const CONDITION_TYPE_REGEX = 'regex'; + public const CONDITION_TYPE_VALUE_EQUALS = 'value_equals'; + public const CONDITION_TYPE_VALUE_RANGE = 'value_range'; + public const CONDITION_TYPE_DATE_RANGE = 'date_range'; + public const CONDITION_TYPE_FILE_UPLOADED = 'file_uploaded'; + + public const CONDITION_TYPES = [ + self::CONDITION_TYPE_OPTION_SELECTED, + self::CONDITION_TYPE_OPTIONS_COMBINATION, + self::CONDITION_TYPE_STRING_EQUALS, + self::CONDITION_TYPE_STRING_CONTAINS, + self::CONDITION_TYPE_REGEX, + self::CONDITION_TYPE_VALUE_EQUALS, + self::CONDITION_TYPE_VALUE_RANGE, + self::CONDITION_TYPE_DATE_RANGE, + self::CONDITION_TYPE_FILE_UPLOADED, + ]; + + /** + * Trigger types allowed for conditional questions + * Maps each trigger type to its supported condition types + */ + public const CONDITIONAL_TRIGGER_TYPES = [ + self::ANSWER_TYPE_MULTIPLEUNIQUE => [self::CONDITION_TYPE_OPTION_SELECTED], + self::ANSWER_TYPE_DROPDOWN => [self::CONDITION_TYPE_OPTION_SELECTED], + self::ANSWER_TYPE_MULTIPLE => [self::CONDITION_TYPE_OPTIONS_COMBINATION], + self::ANSWER_TYPE_SHORT => [self::CONDITION_TYPE_STRING_EQUALS, self::CONDITION_TYPE_STRING_CONTAINS, self::CONDITION_TYPE_REGEX], + self::ANSWER_TYPE_LONG => [self::CONDITION_TYPE_STRING_CONTAINS, self::CONDITION_TYPE_REGEX], + self::ANSWER_TYPE_LINEARSCALE => [self::CONDITION_TYPE_VALUE_EQUALS, self::CONDITION_TYPE_VALUE_RANGE], + self::ANSWER_TYPE_DATE => [self::CONDITION_TYPE_DATE_RANGE], + self::ANSWER_TYPE_DATETIME => [self::CONDITION_TYPE_DATE_RANGE], + self::ANSWER_TYPE_TIME => [self::CONDITION_TYPE_VALUE_RANGE], + self::ANSWER_TYPE_COLOR => [self::CONDITION_TYPE_VALUE_EQUALS], + self::ANSWER_TYPE_FILE => [self::CONDITION_TYPE_FILE_UPLOADED], + ]; + public const FILENAME_INVALID_CHARS = [ "\n", '/', diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 1f9d95f6f..c01e8ae38 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -1,7 +1,7 @@ * @throws OCSBadRequestException Invalid type * @throws OCSBadRequestException Datetime question type no longer supported @@ -461,7 +463,7 @@ public function getQuestion(int $formId, int $questionId): DataResponse { #[NoAdminRequired()] #[BruteForceProtection(action: 'form')] #[ApiRoute(verb: 'POST', url: '/api/v3/forms/{formId}/questions')] - public function newQuestion(int $formId, ?string $type = null, string $text = '', ?int $fromId = null): DataResponse { + public function newQuestion(int $formId, ?string $type = null, string $text = '', ?int $fromId = null, ?int $parentQuestionId = null, ?string $branchId = null): DataResponse { $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_EDIT); $this->formsService->obtainFormLock($form); @@ -471,10 +473,12 @@ public function newQuestion(int $formId, ?string $type = null, string $text = '' } if ($fromId === null) { - $this->logger->debug('Adding new question: formId: {formId}, type: {type}, text: {text}', [ + $this->logger->debug('Adding new question: formId: {formId}, type: {type}, text: {text}, parentQuestionId: {parentQuestionId}, branchId: {branchId}', [ 'formId' => $formId, 'type' => $type, 'text' => $text, + 'parentQuestionId' => $parentQuestionId, + 'branchId' => $branchId, ]); if (array_search($type, Constants::ANSWER_TYPES) === false) { @@ -488,8 +492,47 @@ public function newQuestion(int $formId, ?string $type = null, string $text = '' throw new OCSBadRequestException('Datetime question type no longer supported'); } + // Block creation of nested conditional questions (conditional within conditional) + if ($type === Constants::ANSWER_TYPE_CONDITIONAL && $parentQuestionId !== null) { + $this->logger->debug('Nested conditional questions are not supported'); + throw new OCSBadRequestException('Nested conditional questions are not supported'); + } + + // Validate parent question if creating a subquestion + if ($parentQuestionId !== null) { + try { + $parentQuestion = $this->questionMapper->findById($parentQuestionId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find parent question'); + throw new OCSNotFoundException('Could not find parent question'); + } + + // Parent must be a conditional question + if ($parentQuestion->getType() !== Constants::ANSWER_TYPE_CONDITIONAL) { + $this->logger->debug('Parent question must be a conditional question'); + throw new OCSBadRequestException('Parent question must be a conditional question'); + } + + // Parent must belong to the same form + if ($parentQuestion->getFormId() !== $formId) { + $this->logger->debug('Parent question does not belong to this form'); + throw new OCSBadRequestException('Parent question does not belong to this form'); + } + + // branchId is required when parentQuestionId is set + if (empty($branchId)) { + $this->logger->debug('branchId is required when creating a subquestion'); + throw new OCSBadRequestException('branchId is required when creating a subquestion'); + } + } + // Retrieve all active questions sorted by Order. Takes the order of the last array-element and adds one. - $questions = $this->questionMapper->findByForm($formId); + // For subquestions, get order within the branch + if ($parentQuestionId !== null) { + $questions = $this->questionMapper->findByBranch($parentQuestionId, $branchId); + } else { + $questions = $this->questionMapper->findByForm($formId); + } $lastQuestion = array_pop($questions); if ($lastQuestion) { $questionOrder = $lastQuestion->getOrder() + 1; @@ -507,6 +550,12 @@ public function newQuestion(int $formId, ?string $type = null, string $text = '' $question->setIsRequired(false); $question->setExtraSettings([]); + // Set parent question and branch for subquestions + if ($parentQuestionId !== null) { + $question->setParentQuestionId($parentQuestionId); + $question->setBranchId($branchId); + } + $question = $this->questionMapper->insert($question); $response = $this->formsService->getQuestion($question->getId()); @@ -693,6 +742,15 @@ public function deleteQuestion(int $formId, int $questionId): DataResponse { // Store Order of deleted Question $deletedOrder = $question->getOrder(); + // If this is a conditional question, also soft-delete its subquestions + if ($question->getType() === Constants::ANSWER_TYPE_CONDITIONAL) { + $subQuestions = $this->questionMapper->findByParentQuestion($questionId); + foreach ($subQuestions as $subQuestion) { + $subQuestion->setOrder(0); + $this->questionMapper->update($subQuestion); + } + } + // Mark question as deleted $question->setOrder(0); $this->questionMapper->update($question); @@ -1691,6 +1749,12 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = '' * @param string[]|array $answerArray */ private function storeAnswersForQuestion(Form $form, $submissionId, array $question, array $answerArray): void { + // Special handling for conditional questions + if ($question['type'] === Constants::ANSWER_TYPE_CONDITIONAL) { + $this->storeConditionalAnswer($form, $submissionId, $question, $answerArray); + return; + } + foreach ($answerArray as $answer) { $answerEntity = new Answer(); $answerEntity->setSubmissionId($submissionId); @@ -1742,6 +1806,118 @@ private function storeAnswersForQuestion(Form $form, $submissionId, array $quest } } + /** + * Store answers for a conditional question + * + * Conditional answers have the structure: + * - trigger: array of trigger answer values (option IDs for predefined types, or text) + * - subQuestions: array of subquestion answers keyed by subquestion ID + * + * @param Form $form + * @param int $submissionId + * @param array $question The conditional question + * @param array $answerData The conditional answer data + */ + private function storeConditionalAnswer(Form $form, int $submissionId, array $question, array $answerData): void { + $extraSettings = $question['extraSettings'] ?? []; + $triggerType = $extraSettings['triggerType'] ?? null; + $branches = $extraSettings['branches'] ?? []; + + // Handle the structure: could be {trigger: [...], subQuestions: {...}} or just trigger array + $triggerAnswers = $answerData['trigger'] ?? $answerData; + $subQuestionAnswers = $answerData['subQuestions'] ?? []; + + // Ensure triggerAnswers is an array + if (!is_array($triggerAnswers)) { + $triggerAnswers = [$triggerAnswers]; + } + + // Store trigger answers + foreach ($triggerAnswers as $answer) { + if ($answer === '' || $answer === null) { + continue; + } + + $answerEntity = new Answer(); + $answerEntity->setSubmissionId($submissionId); + $answerEntity->setQuestionId($question['id']); + + $answerText = ''; + + // For predefined trigger types, convert option ID to text + if (in_array($triggerType, [Constants::ANSWER_TYPE_MULTIPLE, Constants::ANSWER_TYPE_MULTIPLEUNIQUE, Constants::ANSWER_TYPE_DROPDOWN])) { + $optionIndex = array_search($answer, array_column($question['options'] ?? [], 'id')); + if ($optionIndex !== false) { + $answerText = $question['options'][$optionIndex]['text']; + } else { + // Could be an "other" answer or direct text + $answerText = is_string($answer) ? $answer : ''; + } + } else { + // For text-based triggers, use the answer directly + $answerText = is_string($answer) ? $answer : ''; + } + + if ($answerText !== '') { + $answerEntity->setText($answerText); + $this->answerMapper->insert($answerEntity); + } + } + + // Store subquestion answers + // Find the active branch to get subquestion definitions + foreach ($branches as $branch) { + $branchSubQuestions = $branch['subQuestions'] ?? []; + foreach ($branchSubQuestions as $subQuestion) { + $subQuestionId = $subQuestion['id'] ?? null; + if ($subQuestionId === null) { + continue; + } + + $subAnswers = $subQuestionAnswers[$subQuestionId] ?? []; + if (empty($subAnswers)) { + continue; + } + + // Ensure subAnswers is an array + if (!is_array($subAnswers)) { + $subAnswers = [$subAnswers]; + } + + // Store each subquestion answer + foreach ($subAnswers as $subAnswer) { + if ($subAnswer === '' || $subAnswer === null) { + continue; + } + + $answerEntity = new Answer(); + $answerEntity->setSubmissionId($submissionId); + $answerEntity->setQuestionId($subQuestionId); + + $answerText = ''; + $subQuestionType = $subQuestion['type'] ?? Constants::ANSWER_TYPE_SHORT; + + // For predefined types, convert option ID to text + if (in_array($subQuestionType, Constants::ANSWER_TYPES_PREDEFINED) && $subQuestionType !== Constants::ANSWER_TYPE_LINEARSCALE) { + $optionIndex = array_search($subAnswer, array_column($subQuestion['options'] ?? [], 'id')); + if ($optionIndex !== false) { + $answerText = $subQuestion['options'][$optionIndex]['text']; + } else { + $answerText = is_string($subAnswer) ? $subAnswer : ''; + } + } else { + $answerText = is_string($subAnswer) ? $subAnswer : ''; + } + + if ($answerText !== '') { + $answerEntity->setText($answerText); + $this->answerMapper->insert($answerEntity); + } + } + } + } + } + /** * Throws if forbidden keys are present in update */ diff --git a/lib/Db/Question.php b/lib/Db/Question.php index e5e226cf8..a78293636 100644 --- a/lib/Db/Question.php +++ b/lib/Db/Question.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2019-2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ @@ -21,9 +21,9 @@ * @method void setOrder(integer $value) * @psalm-method FormsQuestionType getType() * @method string getType() - * @psalm-method 'date'|'datetime'|'dropdown'|'file'|'long'|'multiple'|'multiple_unique'|'short'|'time' getType() + * @psalm-method 'color'|'conditional'|'date'|'datetime'|'dropdown'|'file'|'long'|'multiple'|'multiple_unique'|'short'|'time' getType() * @method void setType(string $value) - * @psalm-method void setType('date'|'datetime'|'dropdown'|'file'|'long'|'multiple'|'multiple_unique'|'short'|'time' $value) + * @psalm-method void setType('color'|'conditional'|'date'|'datetime'|'dropdown'|'file'|'long'|'multiple'|'multiple_unique'|'short'|'time' $value) * @method bool getIsRequired() * @method void setIsRequired(bool $value) * @method string getText() @@ -32,6 +32,10 @@ * @method void setDescription(string $value) * @method string getName() * @method void setName(string $value) + * @method ?int getParentQuestionId() + * @method void setParentQuestionId(?int $value) + * @method ?string getBranchId() + * @method void setBranchId(?string $value) */ class Question extends Entity { protected $formId; @@ -42,6 +46,8 @@ class Question extends Entity { protected $name; protected $description; protected $extraSettingsJson; + protected $parentQuestionId; + protected $branchId; public function __construct() { $this->addType('formId', 'integer'); @@ -51,6 +57,8 @@ public function __construct() { $this->addType('text', 'string'); $this->addType('description', 'string'); $this->addType('name', 'string'); + $this->addType('parentQuestionId', 'integer'); + $this->addType('branchId', 'string'); } /** @@ -85,6 +93,8 @@ public function setExtraSettings(array $extraSettings): void { * name: string, * description: string, * extraSettings: FormsQuestionExtraSettings, + * parentQuestionId: ?int, + * branchId: ?string, * } */ public function read(): array { @@ -98,6 +108,8 @@ public function read(): array { 'name' => (string)$this->getName(), 'description' => (string)$this->getDescription(), 'extraSettings' => $this->getExtraSettings(), + 'parentQuestionId' => $this->getParentQuestionId(), + 'branchId' => $this->getBranchId(), ]; } } diff --git a/lib/Db/QuestionMapper.php b/lib/Db/QuestionMapper.php index fefd9fd25..1f142961f 100644 --- a/lib/Db/QuestionMapper.php +++ b/lib/Db/QuestionMapper.php @@ -1,7 +1,7 @@ from($this->getTableName()) ->where( $qb->expr()->eq('form_id', $qb->createNamedParameter($formId, IQueryBuilder::PARAM_INT)) + ) + // Only load top-level questions (not subquestions of conditional questions) + ->andWhere( + $qb->expr()->isNull('parent_question_id') ); if (!$loadDeleted) { @@ -50,6 +54,81 @@ public function findByForm(int $formId, bool $loadDeleted = false): array { return $this->findEntities($qb); } + /** + * Find subquestions belonging to a parent conditional question + * + * @param int $parentQuestionId The ID of the parent conditional question + * @param bool $loadDeleted Whether to include soft-deleted questions + * @return Question[] + */ + public function findByParentQuestion(int $parentQuestionId, bool $loadDeleted = false): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('parent_question_id', $qb->createNamedParameter($parentQuestionId, IQueryBuilder::PARAM_INT)) + ); + + if (!$loadDeleted) { + $qb->andWhere( + $qb->expr()->neq('order', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)) + ); + } + + // Sort by order within the conditional question + $qb->orderBy('order'); + + return $this->findEntities($qb); + } + + /** + * Find subquestions belonging to a specific branch of a conditional question + * + * @param int $parentQuestionId The ID of the parent conditional question + * @param string $branchId The branch identifier + * @return Question[] + */ + public function findByBranch(int $parentQuestionId, string $branchId): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('parent_question_id', $qb->createNamedParameter($parentQuestionId, IQueryBuilder::PARAM_INT)) + ) + ->andWhere( + $qb->expr()->eq('branch_id', $qb->createNamedParameter($branchId)) + ) + ->andWhere( + $qb->expr()->neq('order', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)) + ) + ->orderBy('order'); + + return $this->findEntities($qb); + } + + /** + * Delete all subquestions of a parent conditional question + * + * @param int $parentQuestionId The ID of the parent conditional question + */ + public function deleteByParentQuestion(int $parentQuestionId): void { + // First delete options for all subquestions + $subQuestions = $this->findByParentQuestion($parentQuestionId, true); + foreach ($subQuestions as $subQuestion) { + $this->optionMapper->deleteByQuestion($subQuestion->getId()); + } + + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where( + $qb->expr()->eq('parent_question_id', $qb->createNamedParameter($parentQuestionId, IQueryBuilder::PARAM_INT)) + ); + + $qb->executeStatement(); + } + /** * @param int $formId */ diff --git a/lib/Migration/Version050300Date20260118000000.php b/lib/Migration/Version050300Date20260118000000.php new file mode 100644 index 000000000..fd562c211 --- /dev/null +++ b/lib/Migration/Version050300Date20260118000000.php @@ -0,0 +1,60 @@ +getTable('forms_v2_questions'); + + // Add parent_question_id column - references parent conditional question + if (!$table->hasColumn('parent_question_id')) { + $table->addColumn('parent_question_id', Types::INTEGER, [ + 'notnull' => false, + 'default' => null, + 'comment' => 'ID of parent conditional question, null for top-level questions', + ]); + } + + // Add branch_id column - identifies which branch this subquestion belongs to + if (!$table->hasColumn('branch_id')) { + $table->addColumn('branch_id', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + 'default' => null, + 'comment' => 'Branch identifier within parent conditional question', + ]); + } + + // Add index for efficient lookup of subquestions by parent + if (!$table->hasIndex('forms_v2_q_parent_idx')) { + $table->addIndex(['parent_question_id'], 'forms_v2_q_parent_idx'); + } + + return $schema; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index f4833aa50..f94a09361 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -42,7 +42,7 @@ * validationType?: string * } * - * @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime" + * @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"|"color"|"conditional" * * @psalm-type FormsQuestion = array{ * id: int, @@ -56,6 +56,8 @@ * extraSettings: FormsQuestionExtraSettings|\stdClass, * options: list, * accept: list, + * parentQuestionId?: ?int, + * branchId?: ?string, * } * * @psalm-type FormsAnswer = array{ diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index 9a4c2d546..11d305481 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -1,7 +1,7 @@ loadConditionalSubQuestions($question); + } + $questionList[] = $question; } } catch (DoesNotExistException $e) { @@ -138,6 +143,57 @@ public function getQuestions(int $formId): array { } } + /** + * Load subquestions for a conditional question and attach them to branches + * + * @param array $question The conditional question data + * @return array The question with subquestions attached to branches + */ + private function loadConditionalSubQuestions(array $question): array { + $subQuestionEntities = $this->questionMapper->findByParentQuestion($question['id']); + + // Group subquestions by branchId + $subQuestionsByBranch = []; + foreach ($subQuestionEntities as $subQuestionEntity) { + $subQuestion = $subQuestionEntity->read(); + $subQuestion['options'] = $this->getOptions($subQuestion['id']); + $subQuestion['accept'] = []; + + // Handle file type accept for subquestions + if ($subQuestion['type'] === Constants::ANSWER_TYPE_FILE) { + if ($subQuestion['extraSettings']['allowedFileTypes'] ?? null) { + $subQuestion['accept'] = array_map(function (string $fileType) { + return str_contains($fileType, '/') ? $fileType : $fileType . '/*'; + }, $subQuestion['extraSettings']['allowedFileTypes']); + } + + if ($subQuestion['extraSettings']['allowedFileExtensions'] ?? null) { + foreach ($subQuestion['extraSettings']['allowedFileExtensions'] as $extension) { + $subQuestion['accept'][] = '.' . $extension; + } + } + } + + $branchId = $subQuestion['branchId'] ?? null; + if ($branchId !== null) { + if (!isset($subQuestionsByBranch[$branchId])) { + $subQuestionsByBranch[$branchId] = []; + } + $subQuestionsByBranch[$branchId][] = $subQuestion; + } + } + + // Attach subquestions to their respective branches in extraSettings + if (isset($question['extraSettings']['branches']) && is_array($question['extraSettings']['branches'])) { + foreach ($question['extraSettings']['branches'] as $index => $branch) { + $branchId = $branch['id'] ?? null; + $question['extraSettings']['branches'][$index]['subQuestions'] = $subQuestionsByBranch[$branchId] ?? []; + } + } + + return $question; + } + /** * Load specific question * @@ -163,6 +219,12 @@ public function getQuestion(int $questionId): ?array { } } } + + // Load subquestions for conditional questions + if ($question['type'] === Constants::ANSWER_TYPE_CONDITIONAL) { + $question = $this->loadConditionalSubQuestions($question); + } + return $question; } catch (DoesNotExistException $e) { return null; @@ -811,6 +873,9 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType case Constants::ANSWER_TYPE_LINEARSCALE: $allowed = Constants::EXTRA_SETTINGS_LINEARSCALE; break; + case Constants::ANSWER_TYPE_CONDITIONAL: + $allowed = Constants::EXTRA_SETTINGS_CONDITIONAL; + break; default: $allowed = []; } @@ -921,6 +986,39 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType || isset($extraSettings['optionsHighest']) && ($extraSettings['optionsHighest'] < 2 || $extraSettings['optionsHighest'] > 10)) { return false; } + } elseif ($questionType === Constants::ANSWER_TYPE_CONDITIONAL) { + // Validate conditional question settings + if (!isset($extraSettings['triggerType']) || !is_string($extraSettings['triggerType'])) { + return false; + } + + // Validate trigger type is a valid question type (and not nested conditional) + if (!array_key_exists($extraSettings['triggerType'], Constants::CONDITIONAL_TRIGGER_TYPES)) { + return false; + } + + // Branches are required for conditional questions + if (!isset($extraSettings['branches']) || !is_array($extraSettings['branches'])) { + return false; + } + + // Branches cannot be empty + if (count($extraSettings['branches']) === 0) { + return false; + } + + // Validate branches structure + foreach ($extraSettings['branches'] as $branch) { + // Each branch must have an id + if (!isset($branch['id']) || !is_string($branch['id'])) { + return false; + } + + // Branches must have conditions array + if (!isset($branch['conditions']) || !is_array($branch['conditions'])) { + return false; + } + } } return true; } diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index 7d2b2be7d..34944d1a9 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -1,7 +1,7 @@ validateConditionalQuestion($question, $answers[$questionId], $formOwnerId); + } + continue; + } + // Check if all required questions have an answer if ($question['isRequired'] && (!$questionAnswered @@ -576,4 +590,258 @@ private function validateShortQuestion(array $question, string $data): bool { return false; } } + + /** + * Validate conditional question answers + * + * Conditional questions have a special answer structure: + * - trigger: array of trigger answer values + * - subQuestions: array of subquestion answers keyed by subquestion ID + * + * @param array $question The conditional question + * @param array $answerData The answer data for the conditional question + * @param string $formOwnerId Owner of the form + * @throws \InvalidArgumentException if validation failed + */ + private function validateConditionalQuestion(array $question, array $answerData, string $formOwnerId): void { + // Answer structure should have 'trigger' key + // For conditional questions, the answerData may be structured differently + // Check if this is a structured conditional answer or a flat array + $triggerAnswer = $answerData['trigger'] ?? $answerData; + $subQuestionAnswers = $answerData['subQuestions'] ?? []; + + $extraSettings = $question['extraSettings'] ?? []; + $triggerType = $extraSettings['triggerType'] ?? null; + $branches = $extraSettings['branches'] ?? []; + + if (!$triggerType) { + throw new \InvalidArgumentException(sprintf('Conditional question "%s" is missing trigger type configuration.', $question['text'])); + } + + // Find the active branch based on trigger answer + $activeBranch = $this->findActiveBranch($triggerType, $triggerAnswer, $branches, $question['options'] ?? []); + + if ($activeBranch === null && !empty($branches)) { + // No branch matched but branches are defined - this might be okay if trigger has no value yet + // Only throw if trigger has a value that doesn't match any branch + if (!empty($triggerAnswer)) { + $this->logger->warning('No branch matched for conditional question', [ + 'questionId' => $question['id'], + 'triggerAnswer' => $triggerAnswer, + ]); + } + return; + } + + // If we have an active branch, validate its subquestions + if ($activeBranch !== null && isset($activeBranch['subQuestions'])) { + $subQuestions = $activeBranch['subQuestions']; + + // Build a questions array from subquestions for validation + foreach ($subQuestions as $subQuestion) { + $subQuestionId = $subQuestion['id']; + $subQuestionAnswered = isset($subQuestionAnswers[$subQuestionId]); + + // Check if required subquestions have an answer + if ($subQuestion['isRequired'] ?? false) { + if (!$subQuestionAnswered || empty($subQuestionAnswers[$subQuestionId])) { + throw new \InvalidArgumentException(sprintf('Subquestion "%s" in conditional question "%s" is required.', $subQuestion['text'] ?? 'Unknown', $question['text'])); + } + } + } + } + } + + /** + * Find the active branch based on trigger answer + * + * @param string $triggerType The type of the trigger question + * @param array $triggerAnswer The trigger answer values + * @param array $branches The available branches + * @param array $options The options for the trigger question + * @return array|null The active branch or null if none matches + */ + private function findActiveBranch(string $triggerType, array $triggerAnswer, array $branches, array $options): ?array { + foreach ($branches as $branch) { + $conditions = $branch['conditions'] ?? []; + + if (empty($conditions)) { + continue; + } + + $matches = $this->evaluateBranchConditions($triggerType, $triggerAnswer, $conditions); + + if ($matches) { + return $branch; + } + } + + return null; + } + + /** + * Evaluate if branch conditions match the trigger answer + * + * @param string $triggerType The type of the trigger question + * @param array $triggerAnswer The trigger answer values + * @param array $conditions The conditions to evaluate + * @return bool True if conditions match + */ + private function evaluateBranchConditions(string $triggerType, array $triggerAnswer, array $conditions): bool { + switch ($triggerType) { + case Constants::ANSWER_TYPE_MULTIPLEUNIQUE: + case Constants::ANSWER_TYPE_DROPDOWN: + // Single select: check if selected option matches any condition + foreach ($conditions as $condition) { + $optionId = $condition['optionId'] ?? null; + if ($optionId !== null && in_array((string)$optionId, $triggerAnswer, true)) { + return true; + } + } + return false; + + case Constants::ANSWER_TYPE_MULTIPLE: + // Multi-select: all condition option IDs must be selected + foreach ($conditions as $condition) { + $optionIds = $condition['optionIds'] ?? []; + if (empty($optionIds) || !is_array($optionIds)) { + continue; + } + foreach ($optionIds as $optionId) { + if (!in_array((string)$optionId, $triggerAnswer, true)) { + return false; + } + } + return true; + } + return false; + + case Constants::ANSWER_TYPE_SHORT: + case Constants::ANSWER_TYPE_LONG: + // Text-based: evaluate regex/string conditions + $text = $triggerAnswer[0] ?? ''; + foreach ($conditions as $condition) { + $type = $condition['type'] ?? 'string_contains'; + $value = $condition['value'] ?? ''; + + switch ($type) { + case 'string_equals': + if ($text === $value) { + return true; + } + break; + case 'string_contains': + if (str_contains($text, $value)) { + return true; + } + break; + case 'regex': + if ($this->safeRegexMatch($value, $text)) { + return true; + } + break; + } + } + return false; + + case Constants::ANSWER_TYPE_LINEARSCALE: + $numValue = (float)($triggerAnswer[0] ?? 0); + foreach ($conditions as $condition) { + $type = $condition['type'] ?? 'value_equals'; + if ($type === 'value_equals') { + if ($numValue == (float)($condition['value'] ?? 0)) { + return true; + } + } elseif ($type === 'value_range') { + $min = $condition['min'] ?? PHP_FLOAT_MIN; + $max = $condition['max'] ?? PHP_FLOAT_MAX; + if ($numValue >= $min && $numValue <= $max) { + return true; + } + } + } + return false; + + case Constants::ANSWER_TYPE_COLOR: + $colorValue = $triggerAnswer[0] ?? ''; + foreach ($conditions as $condition) { + if (strcasecmp($colorValue, $condition['value'] ?? '') === 0) { + return true; + } + } + return false; + + case Constants::ANSWER_TYPE_FILE: + $hasFile = !empty($triggerAnswer); + foreach ($conditions as $condition) { + if (($condition['fileUploaded'] ?? true) === $hasFile) { + return true; + } + } + return false; + + case Constants::ANSWER_TYPE_DATE: + case Constants::ANSWER_TYPE_DATETIME: + case Constants::ANSWER_TYPE_TIME: + // Date range conditions + $dateValue = $triggerAnswer[0] ?? ''; + if (empty($dateValue)) { + return false; + } + $format = Constants::ANSWER_PHPDATETIME_FORMAT[$triggerType] ?? 'Y-m-d'; + $date = \DateTime::createFromFormat($format, $dateValue); + if (!$date) { + return false; + } + + foreach ($conditions as $condition) { + $min = isset($condition['min']) ? \DateTime::createFromFormat($format, $condition['min']) : null; + $max = isset($condition['max']) ? \DateTime::createFromFormat($format, $condition['max']) : null; + + $inRange = true; + if ($min && $date < $min) { + $inRange = false; + } + if ($max && $date > $max) { + $inRange = false; + } + if ($inRange) { + return true; + } + } + return false; + + default: + return false; + } + } + + /** + * Safely execute a regex match with validation to prevent ReDoS attacks + * + * @param string $pattern The regex pattern to match + * @param string $subject The string to match against + * @return bool True if the pattern matches, false otherwise + */ + private function safeRegexMatch(string $pattern, string $subject): bool { + if (empty($pattern) || strlen($subject) > 10000) { + return false; + } + + // Validate regex syntax + if (@preg_match($pattern, '') === false) { + return false; + } + + // Set backtrack limit to prevent catastrophic backtracking + $previousLimit = ini_get('pcre.backtrack_limit'); + ini_set('pcre.backtrack_limit', '10000'); + + try { + $result = @preg_match($pattern, $subject); + return $result === 1; + } finally { + ini_set('pcre.backtrack_limit', $previousLimit); + } + } } diff --git a/openapi.json b/openapi.json index 1e8e8bc83..1dd4ee0f9 100644 --- a/openapi.json +++ b/openapi.json @@ -426,6 +426,15 @@ "items": { "type": "string" } + }, + "parentQuestionId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "branchId": { + "type": "string", + "nullable": true } } }, @@ -536,7 +545,9 @@ "short", "long", "file", - "datetime" + "datetime", + "color", + "conditional" ] }, "Share": { @@ -1621,6 +1632,19 @@ "nullable": true, "default": null, "description": "(optional) id of the question that should be cloned" + }, + "parentQuestionId": { + "type": "integer", + "format": "int64", + "nullable": true, + "default": null, + "description": "(optional) id of the parent conditional question (for subquestions)" + }, + "branchId": { + "type": "string", + "nullable": true, + "default": null, + "description": "(optional) branch id within the parent conditional question" } } } diff --git a/package-lock.json b/package-lock.json index 73b3b66b4..61e0949c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "@nextcloud/vite-config": "^1.7.2", "@playwright/test": "^1.57.0", "@vue/tsconfig": "^0.5.1", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.5", "prettier": "^3.8.0", "vite": "^7.3.1" @@ -2018,19 +2019,19 @@ } }, "node_modules/@parcel/watcher": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", - "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.4.tgz", + "integrity": "sha512-WYa2tUVV5HiArWPB3ydlOc4R2ivq0IDrlqhMi3l7mVsFEXNcTfxYFPIHXHXIh/ca/y/V5N4E1zecyxdIBjYnkQ==", "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, "peer": true, "dependencies": { - "detect-libc": "^1.0.3", + "detect-libc": "^2.0.3", "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">= 10.0.0" @@ -2040,25 +2041,25 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.1", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-freebsd-x64": "2.5.1", - "@parcel/watcher-linux-arm-glibc": "2.5.1", - "@parcel/watcher-linux-arm-musl": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-ia32": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1" + "@parcel/watcher-android-arm64": "2.5.4", + "@parcel/watcher-darwin-arm64": "2.5.4", + "@parcel/watcher-darwin-x64": "2.5.4", + "@parcel/watcher-freebsd-x64": "2.5.4", + "@parcel/watcher-linux-arm-glibc": "2.5.4", + "@parcel/watcher-linux-arm-musl": "2.5.4", + "@parcel/watcher-linux-arm64-glibc": "2.5.4", + "@parcel/watcher-linux-arm64-musl": "2.5.4", + "@parcel/watcher-linux-x64-glibc": "2.5.4", + "@parcel/watcher-linux-x64-musl": "2.5.4", + "@parcel/watcher-win32-arm64": "2.5.4", + "@parcel/watcher-win32-ia32": "2.5.4", + "@parcel/watcher-win32-x64": "2.5.4" } }, "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", - "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.4.tgz", + "integrity": "sha512-hoh0vx4v+b3BNI7Cjoy2/B0ARqcwVNrzN/n7DLq9ZB4I3lrsvhrkCViJyfTj/Qi5xM9YFiH4AmHGK6pgH1ss7g==", "cpu": [ "arm64" ], @@ -2078,9 +2079,9 @@ } }, "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", - "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.4.tgz", + "integrity": "sha512-kphKy377pZiWpAOyTgQYPE5/XEKVMaj6VUjKT5VkNyUJlr2qZAn8gIc7CPzx+kbhvqHDT9d7EqdOqRXT6vk0zw==", "cpu": [ "arm64" ], @@ -2100,9 +2101,9 @@ } }, "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", - "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.4.tgz", + "integrity": "sha512-UKaQFhCtNJW1A9YyVz3Ju7ydf6QgrpNQfRZ35wNKUhTQ3dxJ/3MULXN5JN/0Z80V/KUBDGa3RZaKq1EQT2a2gg==", "cpu": [ "x64" ], @@ -2122,9 +2123,9 @@ } }, "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", - "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.4.tgz", + "integrity": "sha512-Dib0Wv3Ow/m2/ttvLdeI2DBXloO7t3Z0oCp4bAb2aqyqOjKPPGrg10pMJJAQ7tt8P4V2rwYwywkDhUia/FgS+Q==", "cpu": [ "x64" ], @@ -2144,9 +2145,9 @@ } }, "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", - "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.4.tgz", + "integrity": "sha512-I5Vb769pdf7Q7Sf4KNy8Pogl/URRCKu9ImMmnVKYayhynuyGYMzuI4UOWnegQNa2sGpsPSbzDsqbHNMyeyPCgw==", "cpu": [ "arm" ], @@ -2166,9 +2167,9 @@ } }, "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", - "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.4.tgz", + "integrity": "sha512-kGO8RPvVrcAotV4QcWh8kZuHr9bXi9a3bSZw7kFarYR0+fGliU7hd/zevhjw8fnvIKG3J9EO5G6sXNGCSNMYPQ==", "cpu": [ "arm" ], @@ -2188,9 +2189,9 @@ } }, "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", - "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.4.tgz", + "integrity": "sha512-KU75aooXhqGFY2W5/p8DYYHt4hrjHZod8AhcGAmhzPn/etTa+lYCDB2b1sJy3sWJ8ahFVTdy+EbqSBvMx3iFlw==", "cpu": [ "arm64" ], @@ -2210,9 +2211,9 @@ } }, "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", - "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.4.tgz", + "integrity": "sha512-Qx8uNiIekVutnzbVdrgSanM+cbpDD3boB1f8vMtnuG5Zau4/bdDbXyKwIn0ToqFhIuob73bcxV9NwRm04/hzHQ==", "cpu": [ "arm64" ], @@ -2232,9 +2233,9 @@ } }, "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", - "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.4.tgz", + "integrity": "sha512-UYBQvhYmgAv61LNUn24qGQdjtycFBKSK3EXr72DbJqX9aaLbtCOO8+1SkKhD/GNiJ97ExgcHBrukcYhVjrnogA==", "cpu": [ "x64" ], @@ -2254,9 +2255,9 @@ } }, "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", - "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.4.tgz", + "integrity": "sha512-YoRWCVgxv8akZrMhdyVi6/TyoeeMkQ0PGGOf2E4omODrvd1wxniXP+DBynKoHryStks7l+fDAMUBRzqNHrVOpg==", "cpu": [ "x64" ], @@ -2276,9 +2277,9 @@ } }, "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", - "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.4.tgz", + "integrity": "sha512-iby+D/YNXWkiQNYcIhg8P5hSjzXEHaQrk2SLrWOUD7VeC4Ohu0WQvmV+HDJokZVJ2UjJ4AGXW3bx7Lls9Ln4TQ==", "cpu": [ "arm64" ], @@ -2298,9 +2299,9 @@ } }, "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", - "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.4.tgz", + "integrity": "sha512-vQN+KIReG0a2ZDpVv8cgddlf67J8hk1WfZMMP7sMeZmJRSmEax5xNDNWKdgqSe2brOKTQQAs3aCCUal2qBHAyg==", "cpu": [ "ia32" ], @@ -2320,9 +2321,9 @@ } }, "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", - "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.4.tgz", + "integrity": "sha512-3A6efb6BOKwyw7yk9ro2vus2YTt2nvcd56AuzxdMiVOxL9umDyN5PKkKfZ/gZ9row41SjVmTVQNWQhaRRGpOKw==", "cpu": [ "x64" ], @@ -5095,18 +5096,15 @@ } }, "node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", "optional": true, "peer": true, - "bin": { - "detect-libc": "bin/detect-libc.js" - }, "engines": { - "node": ">=0.10" + "node": ">=8" } }, "node_modules/detect-newline": { @@ -5601,8 +5599,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, diff --git a/package.json b/package.json index b16a03d5a..c70de0893 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@nextcloud/vite-config": "^1.7.2", "@playwright/test": "^1.57.0", "@vue/tsconfig": "^0.5.1", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.5", "prettier": "^3.8.0", "vite": "^7.3.1" diff --git a/src/components/Questions/BranchConditionEditor.vue b/src/components/Questions/BranchConditionEditor.vue new file mode 100644 index 000000000..76d2aa6ff --- /dev/null +++ b/src/components/Questions/BranchConditionEditor.vue @@ -0,0 +1,490 @@ + + + + + + + diff --git a/src/components/Questions/QuestionConditional.vue b/src/components/Questions/QuestionConditional.vue new file mode 100644 index 000000000..0ac85d473 --- /dev/null +++ b/src/components/Questions/QuestionConditional.vue @@ -0,0 +1,1091 @@ + + + + + + + diff --git a/src/models/AnswerTypes.js b/src/models/AnswerTypes.js index 548df4db6..01fdfb7e1 100644 --- a/src/models/AnswerTypes.js +++ b/src/models/AnswerTypes.js @@ -1,5 +1,5 @@ /** - * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2020-2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ @@ -9,11 +9,13 @@ import IconCheckboxOutline from 'vue-material-design-icons/CheckboxOutline.vue' import IconClockOutline from 'vue-material-design-icons/ClockOutline.vue' import IconFile from 'vue-material-design-icons/FileOutline.vue' import IconRadioboxMarked from 'vue-material-design-icons/RadioboxMarked.vue' +import IconSourceBranch from 'vue-material-design-icons/SourceBranch.vue' import IconTextLong from 'vue-material-design-icons/TextLong.vue' import IconTextShort from 'vue-material-design-icons/TextShort.vue' import IconLinearScale from '../components/Icons/IconLinearScale.vue' import IconPalette from '../components/Icons/IconPalette.vue' import QuestionColor from '../components/Questions/QuestionColor.vue' +import QuestionConditional from '../components/Questions/QuestionConditional.vue' import QuestionDate from '../components/Questions/QuestionDate.vue' import QuestionDropdown from '../components/Questions/QuestionDropdown.vue' import QuestionFile from '../components/Questions/QuestionFile.vue' @@ -212,4 +214,23 @@ export default { submitPlaceholder: t('forms', 'Pick a color'), warningInvalid: t('forms', 'This question needs a title!'), }, + + conditional: { + component: QuestionConditional, + icon: IconSourceBranch, + label: t('forms', 'Conditional'), + predefined: true, + validate: (question) => { + // Must have a trigger type and at least one branch + const extraSettings = question.extraSettings || {} + return !!extraSettings.triggerType && extraSettings.branches?.length > 0 + }, + + titlePlaceholder: t('forms', 'Conditional question title'), + createPlaceholder: t('forms', 'Different questions based on the answer'), + warningInvalid: t( + 'forms', + 'This question needs a title, trigger type, and at least one branch!', + ), + }, } diff --git a/src/views/Submit.vue b/src/views/Submit.vue index 1e0868896..edc7752b7 100644 --- a/src/views/Submit.vue +++ b/src/views/Submit.vue @@ -1,5 +1,5 @@ @@ -554,10 +554,20 @@ export default { continue } - answers[questionId] = - answer.type === 'QuestionMultiple' - ? answer.value.map(String) - : answer.value + if (answer.type === 'QuestionMultiple') { + answers[questionId] = answer.value.map(String) + } else if (answer.type === 'QuestionConditional') { + // Restore conditional answer structure with proper type conversions + answers[questionId] = { + trigger: Array.isArray(answer.value?.trigger) + ? answer.value.trigger.map(String) + : [], + + subQuestions: answer.value?.subQuestions || {}, + } + } else { + answers[questionId] = answer.value + } } this.answers = answers }, @@ -611,12 +621,52 @@ export default { const answers = {} const loadedAnswers = OcsResponse2Data(response).answers + + // Build a map of subquestion ID → parent conditional question ID + const subQuestionToParent = new Map() + for (const question of this.form.questions) { + if (question.type === 'conditional') { + const branches = question.extraSettings?.branches || [] + for (const branch of branches) { + for (const subQuestion of branch.subQuestions || []) { + subQuestionToParent.set(subQuestion.id, question.id) + } + } + } + } + for (const answer of loadedAnswers) { const questionId = answer.questionId const text = answer.text - answers[questionId] = [] logger.debug(`questionId: ${questionId}, answerId: ${answer.id}`) + + // Check if this answer belongs to a subquestion of a conditional + const parentConditionalId = subQuestionToParent.get(questionId) + if (parentConditionalId !== undefined) { + // Initialize conditional answer structure if needed + if (!answers[parentConditionalId]) { + answers[parentConditionalId] = { + trigger: [], + subQuestions: {}, + } + } + // Add subquestion answer + if (!answers[parentConditionalId].subQuestions[questionId]) { + answers[parentConditionalId].subQuestions[questionId] = + [] + } + answers[parentConditionalId].subQuestions[questionId].push( + text, + ) + continue + } + + // Initialize answer array if not already done + if (!answers[questionId]) { + answers[questionId] = [] + } + // Clean up answers for questions that do not exist anymore if (!this.validQuestionsIds.has(parseInt(questionId))) { this.showClearFormDueToChangeDialog = true @@ -629,7 +679,36 @@ export default { const question = this.form.questions.find( (question) => question.id === questionId, ) - if ( + + if (question.type === 'conditional') { + // Handle conditional trigger answer + if (!answers[questionId].trigger) { + answers[questionId] = { + trigger: [], + subQuestions: answers[questionId].subQuestions || {}, + } + } + // Map trigger answer to option ID for option-based trigger types + const triggerType = question.extraSettings?.triggerType + if ( + ['multiple', 'multiple_unique', 'dropdown'].includes( + triggerType, + ) + ) { + const option = question.options.find( + (opt) => opt.text === text, + ) + if (option) { + answers[questionId].trigger.push(String(option.id)) + } else { + logger.debug( + `Trigger option ${text} could not be mapped for conditional question ${questionId}`, + ) + } + } else { + answers[questionId].trigger.push(text) + } + } else if ( ['multiple', 'multiple_unique', 'dropdown'].includes( question.type, ) diff --git a/tests/Integration/Api/ApiV3Test.php b/tests/Integration/Api/ApiV3Test.php index 46c345d6c..bedc603b9 100644 --- a/tests/Integration/Api/ApiV3Test.php +++ b/tests/Integration/Api/ApiV3Test.php @@ -59,7 +59,9 @@ private function setTestForms() { 'order' => 1, 'options' => [], 'accept' => [], - 'extraSettings' => [] + 'extraSettings' => [], + 'parentQuestionId' => null, + 'branchId' => null, ], [ 'type' => 'multiple_unique', @@ -85,7 +87,9 @@ private function setTestForms() { 'accept' => [], 'extraSettings' => [ 'shuffleOptions' => true - ] + ], + 'parentQuestionId' => null, + 'branchId' => null, ], [ 'type' => 'file', @@ -101,6 +105,8 @@ private function setTestForms() { 'maxAllowedFilesCount' => 1, 'maxFileSize' => 1024, ], + 'parentQuestionId' => null, + 'branchId' => null, ], ], 'shares' => [ @@ -185,7 +191,9 @@ private function setTestForms() { 'order' => 1, 'options' => [], 'accept' => [], - 'extraSettings' => [] + 'extraSettings' => [], + 'parentQuestionId' => null, + 'branchId' => null, ], ], 'shares' => [ @@ -225,7 +233,9 @@ private function setTestForms() { 'order' => 1, 'options' => [], 'accept' => [], - 'extraSettings' => [] + 'extraSettings' => [], + 'parentQuestionId' => null, + 'branchId' => null, ], ], 'shares' => [ @@ -461,7 +471,9 @@ public function dataGetFullForm() { 'options' => [], 'accept' => [], 'description' => 'Please answer this.', - 'extraSettings' => [] + 'extraSettings' => [], + 'parentQuestionId' => null, + 'branchId' => null, ], [ 'type' => 'multiple_unique', @@ -487,7 +499,9 @@ public function dataGetFullForm() { 'description' => '', 'extraSettings' => [ 'shuffleOptions' => true, - ] + ], + 'parentQuestionId' => null, + 'branchId' => null, ], [ 'type' => 'file', @@ -503,6 +517,8 @@ public function dataGetFullForm() { 'maxAllowedFilesCount' => 1, 'maxFileSize' => 1024, ], + 'parentQuestionId' => null, + 'branchId' => null, ], ], 'shares' => [ @@ -711,6 +727,8 @@ public function dataCreateNewQuestion() { 'accept' => [], 'description' => '', 'extraSettings' => [], + 'parentQuestionId' => null, + 'branchId' => null, ] ], 'emptyQuestion' => [ @@ -725,6 +743,8 @@ public function dataCreateNewQuestion() { 'accept' => [], 'description' => '', 'extraSettings' => [], + 'parentQuestionId' => null, + 'branchId' => null, ] ] ]; @@ -890,6 +910,184 @@ public function testCloneQuestion() { } } + /** + * Test creating a conditional question with trigger options + */ + public function testCreateConditionalQuestion(): void { + // First create the conditional question + $resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/questions", [ + 'json' => [ + 'type' => Constants::ANSWER_TYPE_CONDITIONAL, + 'text' => 'Conditional Question' + ] + ]); + $conditionalQuestion = $this->OcsResponse2Data($resp); + $this->testForms[0]['questions'][] = $conditionalQuestion; + + $this->assertEquals(201, $resp->getStatusCode()); + $this->assertEquals(Constants::ANSWER_TYPE_CONDITIONAL, $conditionalQuestion['type']); + $this->assertEquals('Conditional Question', $conditionalQuestion['text']); + $this->assertNull($conditionalQuestion['parentQuestionId']); + $this->assertNull($conditionalQuestion['branchId']); + + // Add options for the trigger + $resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/questions/{$conditionalQuestion['id']}/options", [ + 'json' => [ + 'optionTexts' => ['Option A', 'Option B'] + ] + ]); + $options = $this->OcsResponse2Data($resp); + $this->assertEquals(201, $resp->getStatusCode()); + $this->assertCount(2, $options); + + // Update the conditional question with extraSettings + $resp = $this->http->request('PATCH', "api/v3/forms/{$this->testForms[0]['id']}/questions/{$conditionalQuestion['id']}", [ + 'json' => [ + 'keyValuePairs' => [ + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_DROPDOWN, + 'branches' => [ + [ + 'id' => 'branch-a', + 'conditions' => [['type' => 'option_selected', 'optionId' => $options[0]['id']]] + ], + [ + 'id' => 'branch-b', + 'conditions' => [['type' => 'option_selected', 'optionId' => $options[1]['id']]] + ] + ] + ] + ] + ] + ]); + $this->assertEquals(200, $resp->getStatusCode()); + + // Now create a subquestion in branch-a + $resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/questions", [ + 'json' => [ + 'type' => 'short', + 'text' => 'Subquestion for Option A', + 'parentQuestionId' => $conditionalQuestion['id'], + 'branchId' => 'branch-a' + ] + ]); + $subQuestion = $this->OcsResponse2Data($resp); + $this->testForms[0]['questions'][] = $subQuestion; + + $this->assertEquals(201, $resp->getStatusCode()); + $this->assertEquals('short', $subQuestion['type']); + $this->assertEquals('Subquestion for Option A', $subQuestion['text']); + $this->assertEquals($conditionalQuestion['id'], $subQuestion['parentQuestionId']); + $this->assertEquals('branch-a', $subQuestion['branchId']); + + // Fetch the form and verify the conditional question structure + $resp = $this->http->request('GET', "api/v3/forms/{$this->testForms[0]['id']}"); + $form = $this->OcsResponse2Data($resp); + $this->assertEquals(200, $resp->getStatusCode()); + + // Find the conditional question in the form + $foundConditional = null; + foreach ($form['questions'] as $question) { + if ($question['id'] === $conditionalQuestion['id']) { + $foundConditional = $question; + break; + } + } + + $this->assertNotNull($foundConditional); + $this->assertEquals(Constants::ANSWER_TYPE_CONDITIONAL, $foundConditional['type']); + $this->assertArrayHasKey('extraSettings', $foundConditional); + $this->assertArrayHasKey('branches', $foundConditional['extraSettings']); + + // Verify subquestion is attached to the correct branch + $branchA = null; + foreach ($foundConditional['extraSettings']['branches'] as $branch) { + if ($branch['id'] === 'branch-a') { + $branchA = $branch; + break; + } + } + + $this->assertNotNull($branchA); + $this->assertArrayHasKey('subQuestions', $branchA); + $this->assertCount(1, $branchA['subQuestions']); + $this->assertEquals('Subquestion for Option A', $branchA['subQuestions'][0]['text']); + } + + /** + * Test that nested conditional questions are not allowed + */ + public function testNestedConditionalNotAllowed(): void { + // First create a conditional question + $resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/questions", [ + 'json' => [ + 'type' => Constants::ANSWER_TYPE_CONDITIONAL, + 'text' => 'Parent Conditional' + ] + ]); + $parentConditional = $this->OcsResponse2Data($resp); + $this->testForms[0]['questions'][] = $parentConditional; + $this->assertEquals(201, $resp->getStatusCode()); + + // Add an option for the trigger + $resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/questions/{$parentConditional['id']}/options", [ + 'json' => [ + 'optionTexts' => ['Trigger Option'] + ] + ]); + $options = $this->OcsResponse2Data($resp); + + // Update with extraSettings + $resp = $this->http->request('PATCH', "api/v3/forms/{$this->testForms[0]['id']}/questions/{$parentConditional['id']}", [ + 'json' => [ + 'keyValuePairs' => [ + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_DROPDOWN, + 'branches' => [ + ['id' => 'branch-1', 'conditions' => [['type' => 'option_selected', 'optionId' => $options[0]['id']]]] + ] + ] + ] + ] + ]); + + // Try to create a nested conditional (should fail) + $this->expectException(ClientException::class); + $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/questions", [ + 'json' => [ + 'type' => Constants::ANSWER_TYPE_CONDITIONAL, + 'text' => 'Nested Conditional', + 'parentQuestionId' => $parentConditional['id'], + 'branchId' => 'branch-1' + ] + ]); + } + + /** + * Test that subquestion requires branchId + */ + public function testSubquestionRequiresBranchId(): void { + // First create a conditional question + $resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/questions", [ + 'json' => [ + 'type' => Constants::ANSWER_TYPE_CONDITIONAL, + 'text' => 'Conditional for branch test' + ] + ]); + $conditionalQuestion = $this->OcsResponse2Data($resp); + $this->testForms[0]['questions'][] = $conditionalQuestion; + + // Try to create a subquestion without branchId (should fail) + $this->expectException(ClientException::class); + $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/questions", [ + 'json' => [ + 'type' => 'short', + 'text' => 'Subquestion without branch', + 'parentQuestionId' => $conditionalQuestion['id'] + ] + ]); + } + public function dataCreateNewOption() { return [ 'newOption' => [ diff --git a/tests/Integration/DB/SubmissionMapperTest.php b/tests/Integration/DB/SubmissionMapperTest.php index e204d56c7..5e30f5bbc 100644 --- a/tests/Integration/DB/SubmissionMapperTest.php +++ b/tests/Integration/DB/SubmissionMapperTest.php @@ -53,7 +53,9 @@ private function setTestForms() { 'options' => [], 'accept' => [], 'description' => 'Please answer this.', - 'extraSettings' => [] + 'extraSettings' => [], + 'parentQuestionId' => null, + 'branchId' => null, ] ], 'shares' => [], diff --git a/tests/Unit/Controller/ApiControllerTest.php b/tests/Unit/Controller/ApiControllerTest.php index e1b1d5b00..d7c4c00ce 100644 --- a/tests/Unit/Controller/ApiControllerTest.php +++ b/tests/Unit/Controller/ApiControllerTest.php @@ -736,6 +736,79 @@ public function testNewSubmission_answers() { $this->apiController->newSubmission(1, $answers, ''); } + public function testNewSubmission_conditionalQuestion() { + $form = new Form(); + $form->setId(1); + $form->setHash('hash'); + $form->setOwnerId('admin'); + + $questions = [ + [ + 'id' => 100, + 'type' => Constants::ANSWER_TYPE_CONDITIONAL, + 'text' => 'Conditional Q', + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_DROPDOWN, + 'branches' => [ + [ + 'id' => 'branch-a', + 'conditions' => [['type' => 'option_selected', 'optionId' => 10]], + 'subQuestions' => [ + [ + 'id' => 101, + 'type' => 'short', + 'text' => 'Sub Q', + 'isRequired' => false, + ] + ] + ] + ] + ], + 'options' => [ + ['id' => 10, 'text' => 'Option A'], + ['id' => 11, 'text' => 'Option B'], + ], + ], + ]; + + $answers = [ + 100 => [ + 'trigger' => ['10'], + 'subQuestions' => [ + '101' => ['Sub answer'] + ] + ], + ]; + + $this->formsService->expects($this->once()) + ->method('loadFormForSubmission') + ->with(1) + ->willReturn($form); + + $this->formsService->expects($this->once()) + ->method('getQuestions') + ->with(1) + ->willReturn($questions); + + $this->formAccess(); + + $this->submissionMapper->expects($this->once()) + ->method('insert') + ->with($this->callback(function ($submission) { + $submission->setId(12); + return true; + })); + + // Trigger answer + subquestion answer = 2 inserts + $this->answerMapper->expects($this->exactly(2)) + ->method('insert'); + + $this->formsService->expects($this->once()) + ->method('notifyNewSubmission'); + + $this->apiController->newSubmission(1, $answers, ''); + } + public function testNewSubmission_formNotFound() { $this->formsService->expects($this->once()) ->method('loadFormForSubmission') @@ -1408,4 +1481,337 @@ public function testUpdateSubmission_loadForm_formExpired() { $this->expectExceptionMessage('This form is no longer taking answers'); $this->apiController->updateSubmission($formId, $submissionId, $answers); } + + // Tests for new conditional question functionality + + public function testNewQuestion_conditionalQuestion() { + $formId = 1; + $form = new Form(); + $form->setId($formId); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with($formId, Constants::PERMISSION_EDIT) + ->willReturn($form); + + $this->formsService->expects($this->once()) + ->method('obtainFormLock') + ->with($form); + + $this->questionMapper->expects($this->once()) + ->method('findByForm') + ->with($formId) + ->willReturn([]); + + $this->questionMapper->expects($this->once()) + ->method('insert') + ->willReturnCallback(function ($question) { + $question->setId(100); + return $question; + }); + + $this->formsService->expects($this->once()) + ->method('getQuestion') + ->with(100) + ->willReturn([ + 'id' => 100, + 'formId' => $formId, + 'type' => Constants::ANSWER_TYPE_CONDITIONAL, + 'text' => 'Conditional Q', + 'order' => 1, + 'isRequired' => false, + 'name' => '', + 'options' => [], + 'accept' => [], + 'description' => '', + 'extraSettings' => [], + 'parentQuestionId' => null, + 'branchId' => null, + ]); + + $response = $this->apiController->newQuestion($formId, Constants::ANSWER_TYPE_CONDITIONAL, 'Conditional Q'); + $this->assertInstanceOf(DataResponse::class, $response); + $this->assertEquals(Http::STATUS_CREATED, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals(Constants::ANSWER_TYPE_CONDITIONAL, $data['type']); + } + + public function testNewQuestion_subQuestion() { + $formId = 1; + $parentQuestionId = 100; + $branchId = 'branch-1'; + + $form = new Form(); + $form->setId($formId); + + $parentQuestion = new Question(); + $parentQuestion->setId($parentQuestionId); + $parentQuestion->setFormId($formId); + $parentQuestion->setType(Constants::ANSWER_TYPE_CONDITIONAL); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with($formId, Constants::PERMISSION_EDIT) + ->willReturn($form); + + $this->formsService->expects($this->once()) + ->method('obtainFormLock') + ->with($form); + + $this->questionMapper->expects($this->once()) + ->method('findById') + ->with($parentQuestionId) + ->willReturn($parentQuestion); + + $this->questionMapper->expects($this->once()) + ->method('findByBranch') + ->with($parentQuestionId, $branchId) + ->willReturn([]); + + $this->questionMapper->expects($this->once()) + ->method('insert') + ->willReturnCallback(function ($question) { + $question->setId(101); + return $question; + }); + + $this->formsService->expects($this->once()) + ->method('getQuestion') + ->with(101) + ->willReturn([ + 'id' => 101, + 'formId' => $formId, + 'type' => 'short', + 'text' => 'Sub Q', + 'order' => 1, + 'isRequired' => false, + 'name' => '', + 'options' => [], + 'accept' => [], + 'description' => '', + 'extraSettings' => [], + 'parentQuestionId' => $parentQuestionId, + 'branchId' => $branchId, + ]); + + $response = $this->apiController->newQuestion($formId, 'short', 'Sub Q', null, $parentQuestionId, $branchId); + $this->assertInstanceOf(DataResponse::class, $response); + $this->assertEquals(Http::STATUS_CREATED, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals($parentQuestionId, $data['parentQuestionId']); + $this->assertEquals($branchId, $data['branchId']); + } + + public function testNewQuestion_nestedConditionalNotAllowed() { + $formId = 1; + $parentQuestionId = 100; + $branchId = 'branch-1'; + + $form = new Form(); + $form->setId($formId); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with($formId, Constants::PERMISSION_EDIT) + ->willReturn($form); + + $this->formsService->expects($this->once()) + ->method('obtainFormLock') + ->with($form); + + $this->expectException(OCSBadRequestException::class); + $this->expectExceptionMessage('Nested conditional questions are not supported'); + $this->apiController->newQuestion($formId, Constants::ANSWER_TYPE_CONDITIONAL, 'Nested Conditional', null, $parentQuestionId, $branchId); + } + + public function testNewQuestion_parentNotFound() { + $formId = 1; + $parentQuestionId = 999; + $branchId = 'branch-1'; + + $form = new Form(); + $form->setId($formId); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with($formId, Constants::PERMISSION_EDIT) + ->willReturn($form); + + $this->formsService->expects($this->once()) + ->method('obtainFormLock') + ->with($form); + + $this->questionMapper->expects($this->once()) + ->method('findById') + ->with($parentQuestionId) + ->willThrowException(new DoesNotExistException('Question not found')); + + $this->expectException(OCSNotFoundException::class); + $this->expectExceptionMessage('Could not find parent question'); + $this->apiController->newQuestion($formId, 'short', 'Sub Q', null, $parentQuestionId, $branchId); + } + + public function testNewQuestion_parentNotConditional() { + $formId = 1; + $parentQuestionId = 100; + $branchId = 'branch-1'; + + $form = new Form(); + $form->setId($formId); + + $parentQuestion = new Question(); + $parentQuestion->setId($parentQuestionId); + $parentQuestion->setFormId($formId); + $parentQuestion->setType('short'); // Not conditional + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with($formId, Constants::PERMISSION_EDIT) + ->willReturn($form); + + $this->formsService->expects($this->once()) + ->method('obtainFormLock') + ->with($form); + + $this->questionMapper->expects($this->once()) + ->method('findById') + ->with($parentQuestionId) + ->willReturn($parentQuestion); + + $this->expectException(OCSBadRequestException::class); + $this->expectExceptionMessage('Parent question must be a conditional question'); + $this->apiController->newQuestion($formId, 'short', 'Sub Q', null, $parentQuestionId, $branchId); + } + + public function testNewQuestion_parentDifferentForm() { + $formId = 1; + $parentQuestionId = 100; + $branchId = 'branch-1'; + + $form = new Form(); + $form->setId($formId); + + $parentQuestion = new Question(); + $parentQuestion->setId($parentQuestionId); + $parentQuestion->setFormId(999); // Different form + $parentQuestion->setType(Constants::ANSWER_TYPE_CONDITIONAL); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with($formId, Constants::PERMISSION_EDIT) + ->willReturn($form); + + $this->formsService->expects($this->once()) + ->method('obtainFormLock') + ->with($form); + + $this->questionMapper->expects($this->once()) + ->method('findById') + ->with($parentQuestionId) + ->willReturn($parentQuestion); + + $this->expectException(OCSBadRequestException::class); + $this->expectExceptionMessage('Parent question does not belong to this form'); + $this->apiController->newQuestion($formId, 'short', 'Sub Q', null, $parentQuestionId, $branchId); + } + + public function testNewQuestion_missingBranchId() { + $formId = 1; + $parentQuestionId = 100; + + $form = new Form(); + $form->setId($formId); + + $parentQuestion = new Question(); + $parentQuestion->setId($parentQuestionId); + $parentQuestion->setFormId($formId); + $parentQuestion->setType(Constants::ANSWER_TYPE_CONDITIONAL); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with($formId, Constants::PERMISSION_EDIT) + ->willReturn($form); + + $this->formsService->expects($this->once()) + ->method('obtainFormLock') + ->with($form); + + $this->questionMapper->expects($this->once()) + ->method('findById') + ->with($parentQuestionId) + ->willReturn($parentQuestion); + + $this->expectException(OCSBadRequestException::class); + $this->expectExceptionMessage('branchId is required when creating a subquestion'); + $this->apiController->newQuestion($formId, 'short', 'Sub Q', null, $parentQuestionId, null); + } + + public function testDeleteQuestion_conditionalWithSubquestions(): void { + $formId = 1; + $questionId = 100; + + $form = new Form(); + $form->setId($formId); + + $question = new Question(); + $question->setId($questionId); + $question->setFormId($formId); + $question->setType(Constants::ANSWER_TYPE_CONDITIONAL); + $question->setOrder(1); + + $subQuestion1 = new Question(); + $subQuestion1->setId(101); + $subQuestion1->setFormId($formId); + $subQuestion1->setType('short'); + $subQuestion1->setOrder(1); + $subQuestion1->setParentQuestionId($questionId); + + $subQuestion2 = new Question(); + $subQuestion2->setId(102); + $subQuestion2->setFormId($formId); + $subQuestion2->setType('long'); + $subQuestion2->setOrder(2); + $subQuestion2->setParentQuestionId($questionId); + + $this->formsService->expects($this->once()) + ->method('getFormIfAllowed') + ->with($formId, Constants::PERMISSION_EDIT) + ->willReturn($form); + + $this->formsService->expects($this->once()) + ->method('obtainFormLock') + ->with($form); + + $this->formsService->expects($this->once()) + ->method('isFormArchived') + ->with($form) + ->willReturn(false); + + $this->questionMapper->expects($this->once()) + ->method('findById') + ->with($questionId) + ->willReturn($question); + + $this->questionMapper->expects($this->once()) + ->method('findByParentQuestion') + ->with($questionId) + ->willReturn([$subQuestion1, $subQuestion2]); + + $this->questionMapper->expects($this->once()) + ->method('findByForm') + ->with($formId) + ->willReturn([]); + + // Expect updates for: subQuestion1, subQuestion2, main question + $this->questionMapper->expects($this->exactly(3)) + ->method('update'); + + $this->formMapper->expects($this->once()) + ->method('update') + ->with($form); + + $response = $this->apiController->deleteQuestion($formId, $questionId); + $this->assertInstanceOf(DataResponse::class, $response); + $this->assertEquals($questionId, $response->getData()); + } } diff --git a/tests/Unit/Service/FormsServiceTest.php b/tests/Unit/Service/FormsServiceTest.php index 8953cdd86..971c1e0d5 100644 --- a/tests/Unit/Service/FormsServiceTest.php +++ b/tests/Unit/Service/FormsServiceTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2021-2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ @@ -221,6 +221,8 @@ public function dataGetForm() { ] ], 'accept' => [], + 'parentQuestionId' => null, + 'branchId' => null, ], [ 'id' => 2, @@ -234,6 +236,8 @@ public function dataGetForm() { 'name' => 'city', 'options' => [], 'accept' => [], + 'parentQuestionId' => null, + 'branchId' => null, ] ], 'shares' => [ @@ -1490,6 +1494,171 @@ public function dataAreExtraSettingsValid() { 'questionType' => Constants::ANSWER_TYPE_LINEARSCALE, 'expected' => false ], + // Conditional question tests + 'valid-conditional-dropdown-trigger' => [ + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_DROPDOWN, + 'branches' => [ + ['id' => 'branch-1', 'conditions' => [['type' => 'option_selected', 'optionId' => 1]]], + ['id' => 'branch-2', 'conditions' => [['type' => 'option_selected', 'optionId' => 2]]] + ] + ], + 'questionType' => Constants::ANSWER_TYPE_CONDITIONAL, + 'expected' => true + ], + 'valid-conditional-multiple-unique-trigger' => [ + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_MULTIPLEUNIQUE, + 'branches' => [ + ['id' => 'branch-1', 'conditions' => [['type' => 'option_selected', 'optionId' => 5]]] + ] + ], + 'questionType' => Constants::ANSWER_TYPE_CONDITIONAL, + 'expected' => true + ], + 'valid-conditional-multiple-trigger' => [ + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_MULTIPLE, + 'branches' => [ + ['id' => 'branch-1', 'conditions' => [['type' => 'options_combination', 'optionIds' => [1, 2]]]] + ] + ], + 'questionType' => Constants::ANSWER_TYPE_CONDITIONAL, + 'expected' => true + ], + 'valid-conditional-short-trigger' => [ + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_SHORT, + 'branches' => [ + ['id' => 'branch-1', 'conditions' => [['type' => 'string_equals', 'value' => 'yes']]] + ] + ], + 'questionType' => Constants::ANSWER_TYPE_CONDITIONAL, + 'expected' => true + ], + 'valid-conditional-linearscale-trigger' => [ + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_LINEARSCALE, + 'branches' => [ + ['id' => 'branch-1', 'conditions' => [['type' => 'value_range', 'min' => 1, 'max' => 3]]] + ] + ], + 'questionType' => Constants::ANSWER_TYPE_CONDITIONAL, + 'expected' => true + ], + 'valid-conditional-date-trigger' => [ + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_DATE, + 'branches' => [ + ['id' => 'branch-1', 'conditions' => [['type' => 'date_range', 'min' => '2024-01-01', 'max' => '2024-12-31']]] + ] + ], + 'questionType' => Constants::ANSWER_TYPE_CONDITIONAL, + 'expected' => true + ], + 'valid-conditional-file-trigger' => [ + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_FILE, + 'branches' => [ + ['id' => 'branch-1', 'conditions' => [['type' => 'file_uploaded', 'value' => true]]] + ] + ], + 'questionType' => Constants::ANSWER_TYPE_CONDITIONAL, + 'expected' => true + ], + 'invalid-conditional-missing-triggerType' => [ + 'extraSettings' => [ + 'branches' => [ + ['id' => 'branch-1', 'conditions' => [['optionId' => 1]]] + ] + ], + 'questionType' => Constants::ANSWER_TYPE_CONDITIONAL, + 'expected' => false + ], + 'invalid-conditional-missing-branches' => [ + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_DROPDOWN + ], + 'questionType' => Constants::ANSWER_TYPE_CONDITIONAL, + 'expected' => false + ], + 'invalid-conditional-invalid-triggerType' => [ + 'extraSettings' => [ + 'triggerType' => 'invalid_type', + 'branches' => [ + ['id' => 'branch-1', 'conditions' => [['optionId' => 1]]] + ] + ], + 'questionType' => Constants::ANSWER_TYPE_CONDITIONAL, + 'expected' => false + ], + 'invalid-conditional-nested-not-allowed' => [ + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_CONDITIONAL, + 'branches' => [ + ['id' => 'branch-1', 'conditions' => [['optionId' => 1]]] + ] + ], + 'questionType' => Constants::ANSWER_TYPE_CONDITIONAL, + 'expected' => false + ], + 'invalid-conditional-branch-missing-id' => [ + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_DROPDOWN, + 'branches' => [ + ['conditions' => [['optionId' => 1]]] + ] + ], + 'questionType' => Constants::ANSWER_TYPE_CONDITIONAL, + 'expected' => false + ], + 'invalid-conditional-branch-missing-conditions' => [ + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_DROPDOWN, + 'branches' => [ + ['id' => 'branch-1'] + ] + ], + 'questionType' => Constants::ANSWER_TYPE_CONDITIONAL, + 'expected' => false + ], + 'invalid-conditional-branches-not-array' => [ + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_DROPDOWN, + 'branches' => 'not-an-array' + ], + 'questionType' => Constants::ANSWER_TYPE_CONDITIONAL, + 'expected' => false + ], + 'invalid-conditional-empty-branches' => [ + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_DROPDOWN, + 'branches' => [] + ], + 'questionType' => Constants::ANSWER_TYPE_CONDITIONAL, + 'expected' => false + ], + 'invalid-conditional-triggerType-not-string' => [ + 'extraSettings' => [ + 'triggerType' => 123, + 'branches' => [ + ['id' => 'branch-1', 'conditions' => [['optionId' => 1]]] + ] + ], + 'questionType' => Constants::ANSWER_TYPE_CONDITIONAL, + 'expected' => false + ], + 'invalid-conditional-extra-settings-key' => [ + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_DROPDOWN, + 'branches' => [ + ['id' => 'branch-1', 'conditions' => [['optionId' => 1]]] + ], + 'invalidKey' => 'value' + ], + 'questionType' => Constants::ANSWER_TYPE_CONDITIONAL, + 'expected' => false + ], ]; } @@ -1593,6 +1762,141 @@ public function testGetQuestionsHandlesDoesNotExistException(): void { $this->assertEmpty($result); } + public function testGetQuestionsWithConditionalQuestion(): void { + $conditionalQuestionData = [ + 'id' => 100, + 'formId' => 1, + 'type' => Constants::ANSWER_TYPE_CONDITIONAL, + 'text' => 'Conditional Q', + 'order' => 1, + 'isRequired' => true, + 'name' => '', + 'description' => '', + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_DROPDOWN, + 'branches' => [ + ['id' => 'branch-a', 'conditions' => [['type' => 'option_selected', 'optionId' => 10]]], + ['id' => 'branch-b', 'conditions' => [['type' => 'option_selected', 'optionId' => 11]]] + ] + ], + 'parentQuestionId' => null, + 'branchId' => null, + ]; + + $subQuestionData = [ + 'id' => 101, + 'formId' => 1, + 'type' => 'short', + 'text' => 'Sub Q in branch A', + 'order' => 1, + 'isRequired' => false, + 'name' => '', + 'description' => '', + 'extraSettings' => [], + 'parentQuestionId' => 100, + 'branchId' => 'branch-a', + ]; + + $conditionalQuestion = $this->createQuestionEntity($conditionalQuestionData); + $subQuestion = $this->createQuestionEntity($subQuestionData); + + $this->questionMapper->method('findByForm')->willReturn([$conditionalQuestion]); + $this->questionMapper->method('findByParentQuestion')->with(100)->willReturn([$subQuestion]); + $this->optionMapper->method('findByQuestion')->willReturn([]); + + $result = $this->formsService->getQuestions(1); + + $this->assertCount(1, $result); + $this->assertEquals(Constants::ANSWER_TYPE_CONDITIONAL, $result[0]['type']); + $this->assertArrayHasKey('extraSettings', $result[0]); + $this->assertArrayHasKey('branches', $result[0]['extraSettings']); + + // Verify subquestion is attached to branch-a + $branchA = null; + foreach ($result[0]['extraSettings']['branches'] as $branch) { + if ($branch['id'] === 'branch-a') { + $branchA = $branch; + break; + } + } + $this->assertNotNull($branchA); + $this->assertArrayHasKey('subQuestions', $branchA); + $this->assertCount(1, $branchA['subQuestions']); + $this->assertEquals('Sub Q in branch A', $branchA['subQuestions'][0]['text']); + + // Branch-b should have empty subQuestions + $branchB = null; + foreach ($result[0]['extraSettings']['branches'] as $branch) { + if ($branch['id'] === 'branch-b') { + $branchB = $branch; + break; + } + } + $this->assertNotNull($branchB); + $this->assertArrayHasKey('subQuestions', $branchB); + $this->assertEmpty($branchB['subQuestions']); + } + + public function testGetQuestionsWithConditionalSubQuestionFile(): void { + $conditionalQuestionData = [ + 'id' => 100, + 'formId' => 1, + 'type' => Constants::ANSWER_TYPE_CONDITIONAL, + 'text' => 'Conditional Q', + 'order' => 1, + 'isRequired' => true, + 'name' => '', + 'description' => '', + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_DROPDOWN, + 'branches' => [ + ['id' => 'branch-a', 'conditions' => [['type' => 'option_selected', 'optionId' => 10]]] + ] + ], + 'parentQuestionId' => null, + 'branchId' => null, + ]; + + $fileSubQuestionData = [ + 'id' => 101, + 'formId' => 1, + 'type' => Constants::ANSWER_TYPE_FILE, + 'text' => 'Upload file', + 'order' => 1, + 'isRequired' => false, + 'name' => '', + 'description' => '', + 'extraSettings' => [ + 'allowedFileTypes' => ['image', 'application/pdf'], + 'allowedFileExtensions' => ['txt', 'doc'], + ], + 'parentQuestionId' => 100, + 'branchId' => 'branch-a', + ]; + + $conditionalQuestion = $this->createQuestionEntity($conditionalQuestionData); + $fileSubQuestion = $this->createQuestionEntity($fileSubQuestionData); + + $this->questionMapper->method('findByForm')->willReturn([$conditionalQuestion]); + $this->questionMapper->method('findByParentQuestion')->with(100)->willReturn([$fileSubQuestion]); + $this->optionMapper->method('findByQuestion')->willReturn([]); + + $result = $this->formsService->getQuestions(1); + + $this->assertCount(1, $result); + $branchA = $result[0]['extraSettings']['branches'][0]; + $this->assertCount(1, $branchA['subQuestions']); + + $fileQuestion = $branchA['subQuestions'][0]; + $this->assertEquals(Constants::ANSWER_TYPE_FILE, $fileQuestion['type']); + $this->assertArrayHasKey('accept', $fileQuestion); + // Should have image/*, application/pdf, .txt, .doc + $this->assertContains('image/*', $fileQuestion['accept']); + $this->assertContains('application/pdf', $fileQuestion['accept']); + $this->assertContains('.txt', $fileQuestion['accept']); + $this->assertContains('.doc', $fileQuestion['accept']); + } + private function createQuestionEntity(array $data): Question { $questionEntity = $this->createMock(Question::class); $questionEntity->method('read')->willReturn($data); diff --git a/tests/Unit/Service/SubmissionServiceTest.php b/tests/Unit/Service/SubmissionServiceTest.php index 23330ee6e..099814324 100644 --- a/tests/Unit/Service/SubmissionServiceTest.php +++ b/tests/Unit/Service/SubmissionServiceTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2021-2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Forms\Tests\Unit\Service; @@ -1098,7 +1098,337 @@ public function dataValidateSubmission() { ], // Expected Result null, - ] + ], + // Conditional question tests + 'valid-conditional-with-trigger-and-subquestions' => [ + // Questions - conditional with dropdown trigger and subquestions in branches + [ + [ + 'id' => 100, + 'type' => Constants::ANSWER_TYPE_CONDITIONAL, + 'text' => 'Conditional Q', + 'isRequired' => true, + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_DROPDOWN, + 'branches' => [ + [ + 'id' => 'branch-1', + 'conditions' => [['type' => 'option_selected', 'optionId' => 10]], + 'subQuestions' => [ + [ + 'id' => 101, + 'type' => 'short', + 'text' => 'Sub Q1', + 'isRequired' => true, + ] + ] + ], + [ + 'id' => 'branch-2', + 'conditions' => [['type' => 'option_selected', 'optionId' => 11]], + 'subQuestions' => [ + [ + 'id' => 102, + 'type' => 'short', + 'text' => 'Sub Q2', + 'isRequired' => true, + ] + ] + ] + ] + ], + 'options' => [ + ['id' => 10], + ['id' => 11] + ] + ] + ], + // Answers - trigger selects branch-1, so only subquestion 101 needs to be answered + [ + '100' => [ + 'trigger' => ['10'], + 'subQuestions' => [ + '101' => ['Sub answer 1'] + ] + ] + ], + // Expected Result - valid + null, + ], + 'valid-conditional-optional-not-answered' => [ + // Questions - optional conditional question + [ + [ + 'id' => 100, + 'type' => Constants::ANSWER_TYPE_CONDITIONAL, + 'text' => 'Conditional Q', + 'isRequired' => false, + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_DROPDOWN, + 'branches' => [ + [ + 'id' => 'branch-1', + 'conditions' => [['type' => 'option_selected', 'optionId' => 10]], + 'subQuestions' => [] + ] + ] + ], + 'options' => [ + ['id' => 10] + ] + ] + ], + // Answers - not answered at all + [], + // Expected Result - valid since optional + null, + ], + 'invalid-conditional-required-trigger-not-answered' => [ + // Questions - required conditional question + [ + [ + 'id' => 100, + 'type' => Constants::ANSWER_TYPE_CONDITIONAL, + 'text' => 'Conditional Q', + 'isRequired' => true, + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_DROPDOWN, + 'branches' => [ + [ + 'id' => 'branch-1', + 'conditions' => [['type' => 'option_selected', 'optionId' => 10]], + 'subQuestions' => [] + ] + ] + ], + 'options' => [ + ['id' => 10] + ] + ] + ], + // Answers - not answered + [], + // Expected Result + 'Question "Conditional Q" is required.', + ], + 'invalid-conditional-required-subquestion-not-answered' => [ + // Questions - conditional with required subquestion in the branch + [ + [ + 'id' => 100, + 'type' => Constants::ANSWER_TYPE_CONDITIONAL, + 'text' => 'Conditional Q', + 'isRequired' => true, + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_DROPDOWN, + 'branches' => [ + [ + 'id' => 'branch-1', + 'conditions' => [['type' => 'option_selected', 'optionId' => 10]], + 'subQuestions' => [ + [ + 'id' => 101, + 'type' => 'short', + 'text' => 'Sub Q1', + 'isRequired' => true, + ] + ] + ] + ] + ], + 'options' => [ + ['id' => 10] + ] + ] + ], + // Answers - trigger answered but subquestion not + [ + '100' => [ + 'trigger' => ['10'], + 'subQuestions' => [] + ] + ], + // Expected Result + 'Subquestion "Sub Q1" in conditional question "Conditional Q" is required.', + ], + 'valid-conditional-linearscale-trigger' => [ + // Questions - conditional with linearscale trigger + [ + [ + 'id' => 100, + 'type' => Constants::ANSWER_TYPE_CONDITIONAL, + 'text' => 'Scale Conditional', + 'isRequired' => true, + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_LINEARSCALE, + 'branches' => [ + [ + 'id' => 'branch-low', + 'conditions' => [['type' => 'value_range', 'min' => 1, 'max' => 3]], + 'subQuestions' => [ + [ + 'id' => 101, + 'type' => 'long', + 'text' => 'Why low?', + 'isRequired' => true, + ] + ] + ], + [ + 'id' => 'branch-high', + 'conditions' => [['type' => 'value_range', 'min' => 4, 'max' => 5]], + 'subQuestions' => [ + [ + 'id' => 102, + 'type' => 'long', + 'text' => 'Why high?', + 'isRequired' => true, + ] + ] + ] + ] + ] + ] + ], + // Answers - trigger value 2 activates branch-low + [ + '100' => [ + 'trigger' => ['2'], + 'subQuestions' => [ + '101' => ['Because reasons'] + ] + ] + ], + // Expected Result - valid + null, + ], + 'valid-conditional-short-text-trigger' => [ + // Questions - conditional with short text trigger using string_equals + [ + [ + 'id' => 100, + 'type' => Constants::ANSWER_TYPE_CONDITIONAL, + 'text' => 'Text Conditional', + 'isRequired' => true, + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_SHORT, + 'branches' => [ + [ + 'id' => 'branch-yes', + 'conditions' => [['type' => 'string_equals', 'value' => 'yes']], + 'subQuestions' => [ + [ + 'id' => 101, + 'type' => 'short', + 'text' => 'Please elaborate', + 'isRequired' => true, + ] + ] + ], + [ + 'id' => 'branch-no', + 'conditions' => [['type' => 'string_equals', 'value' => 'no']], + 'subQuestions' => [] + ] + ] + ] + ] + ], + // Answers - trigger "yes" activates branch-yes + [ + '100' => [ + 'trigger' => ['yes'], + 'subQuestions' => [ + '101' => ['More details here'] + ] + ] + ], + // Expected Result - valid + null, + ], + 'valid-conditional-checkbox-combination-trigger' => [ + // Questions - conditional with multiple (checkbox) trigger + [ + [ + 'id' => 100, + 'type' => Constants::ANSWER_TYPE_CONDITIONAL, + 'text' => 'Checkbox Conditional', + 'isRequired' => true, + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_MULTIPLE, + 'branches' => [ + [ + 'id' => 'branch-combo1', + 'conditions' => [['type' => 'options_combination', 'optionIds' => [10, 11]]], + 'subQuestions' => [ + [ + 'id' => 101, + 'type' => 'short', + 'text' => 'Combo 1 question', + 'isRequired' => true, + ] + ] + ], + [ + 'id' => 'branch-combo2', + 'conditions' => [['type' => 'options_combination', 'optionIds' => [10]]], + 'subQuestions' => [] + ] + ] + ], + 'options' => [ + ['id' => 10], + ['id' => 11], + ['id' => 12] + ] + ] + ], + // Answers - selecting options 10 and 11 activates branch-combo1 + [ + '100' => [ + 'trigger' => ['10', '11'], + 'subQuestions' => [ + '101' => ['Combo answer'] + ] + ] + ], + // Expected Result - valid + null, + ], + 'valid-conditional-no-branch-matched' => [ + // Questions - conditional where trigger value doesn't match any branch + [ + [ + 'id' => 100, + 'type' => Constants::ANSWER_TYPE_CONDITIONAL, + 'text' => 'Conditional Q', + 'isRequired' => true, + 'extraSettings' => [ + 'triggerType' => Constants::ANSWER_TYPE_DROPDOWN, + 'branches' => [ + [ + 'id' => 'branch-1', + 'conditions' => [['type' => 'option_selected', 'optionId' => 10]], + 'subQuestions' => [] + ] + ] + ], + 'options' => [ + ['id' => 10], + ['id' => 11] + ] + ] + ], + // Answers - trigger value 11 doesn't match any branch (only branch-1 matches 10) + [ + '100' => [ + 'trigger' => ['11'], + 'subQuestions' => [] + ] + ], + // Expected Result - valid (no branch matched, but that's okay) + null, + ], ]; }