Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions lib/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,20 +71,27 @@ class Constants {
public const ANSWER_TYPE_DATETIME = 'datetime';
public const ANSWER_TYPE_DROPDOWN = 'dropdown';
public const ANSWER_TYPE_FILE = 'file';
public const ANSWER_TYPE_GRID = 'grid';
public const ANSWER_TYPE_LINEARSCALE = 'linearscale';
public const ANSWER_TYPE_LONG = 'long';
public const ANSWER_TYPE_MULTIPLE = 'multiple';
public const ANSWER_TYPE_MULTIPLEUNIQUE = 'multiple_unique';
public const ANSWER_TYPE_SHORT = 'short';
public const ANSWER_TYPE_TIME = 'time';

public const ANSWER_GRID_TYPE_CHECKBOX = 'checkbox';
public const ANSWER_GRID_TYPE_NUMBER = 'number';
public const ANSWER_GRID_TYPE_RADIO = 'radio';
public const ANSWER_GRID_TYPE_TEXT = 'text';

// All AnswerTypes
public const ANSWER_TYPES = [
self::ANSWER_TYPE_COLOR,
self::ANSWER_TYPE_DATE,
self::ANSWER_TYPE_DATETIME,
self::ANSWER_TYPE_DROPDOWN,
self::ANSWER_TYPE_FILE,
self::ANSWER_TYPE_GRID,
self::ANSWER_TYPE_LINEARSCALE,
self::ANSWER_TYPE_LONG,
self::ANSWER_TYPE_MULTIPLE,
Expand Down Expand Up @@ -179,6 +186,21 @@ class Constants {
'optionsLabelHighest' => ['string', 'NULL'],
];

public const EXTRA_SETTINGS_GRID = [
'columnsTitle' => ['string', 'NULL'],
'rowsTitle' => ['string', 'NULL'],
'columns' => ['array'],
'questionType' => ['string'],
'rows' => ['array'],
];

public const EXTRA_SETTINGS_GRID_QUESTION_TYPE = [
self::ANSWER_GRID_TYPE_CHECKBOX,
self::ANSWER_GRID_TYPE_NUMBER,
self::ANSWER_GRID_TYPE_RADIO,
self::ANSWER_GRID_TYPE_TEXT,
];

public const FILENAME_INVALID_CHARS = [
"\n",
'/',
Expand Down
36 changes: 29 additions & 7 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
* @psalm-import-type FormsPartialForm from ResponseDefinitions
* @psalm-import-type FormsQuestion from ResponseDefinitions
* @psalm-import-type FormsQuestionType from ResponseDefinitions
* @psalm-import-type FormsQuestionGridSubType from ResponseDefinitions
* @psalm-import-type FormsSubmission from ResponseDefinitions
* @psalm-import-type FormsSubmissions from ResponseDefinitions
* @psalm-import-type FormsUploadedFile from ResponseDefinitions
Expand Down Expand Up @@ -445,6 +446,7 @@ public function getQuestion(int $formId, int $questionId): DataResponse {
*
* @param int $formId the form id
* @param FormsQuestionType $type the new question type
* @param FormsQuestionGridSubType $subtype the new question subtype
* @param string $text the new question title
* @param ?int $fromId (optional) id of the question that should be cloned
* @return DataResponse<Http::STATUS_CREATED, FormsQuestion, array{}>
Expand All @@ -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 $subtype = null, string $text = '', ?int $fromId = null): DataResponse {
$form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_EDIT);
$this->formsService->obtainFormLock($form);

Expand Down Expand Up @@ -505,7 +507,7 @@ public function newQuestion(int $formId, ?string $type = null, string $text = ''
$question->setText($text);
$question->setDescription('');
$question->setIsRequired(false);
$question->setExtraSettings([]);
$question->setExtraSettings($subtype ? ['questionType' => $subtype] : []);

$question = $this->questionMapper->insert($question);

Expand Down Expand Up @@ -820,6 +822,7 @@ public function reorderQuestions(int $formId, array $newOrder): DataResponse {
* @param int $formId id of the form
* @param int $questionId id of the question
* @param list<string> $optionTexts the new option text
* @param string|null $optionType the new option type (e.g. 'row')
* @return DataResponse<Http::STATUS_CREATED, list<FormsOption>, array{}> Returns a DataResponse containing the added options
* @throws OCSBadRequestException This question is not part ot the given form
* @throws OCSForbiddenException This form is archived and can not be modified
Expand All @@ -833,11 +836,12 @@ public function reorderQuestions(int $formId, array $newOrder): DataResponse {
#[NoAdminRequired()]
#[BruteForceProtection(action: 'form')]
#[ApiRoute(verb: 'POST', url: '/api/v3/forms/{formId}/questions/{questionId}/options')]
public function newOption(int $formId, int $questionId, array $optionTexts): DataResponse {
$this->logger->debug('Adding new options: formId: {formId}, questionId: {questionId}, text: {text}', [
public function newOption(int $formId, int $questionId, array $optionTexts, ?string $optionType = null): DataResponse {
$this->logger->debug('Adding new options: formId: {formId}, questionId: {questionId}, text: {text}, optionType: {optionType}', [
'formId' => $formId,
'questionId' => $questionId,
'text' => $optionTexts,
'optionType' => $optionType,
]);

$form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_EDIT);
Expand All @@ -863,7 +867,7 @@ public function newOption(int $formId, int $questionId, array $optionTexts): Dat
}

// Retrieve all options sorted by 'order'. Takes the order of the last array-element and adds one.
$options = $this->optionMapper->findByQuestion($questionId);
$options = $this->optionMapper->findByQuestion($questionId, $optionType);
$lastOption = array_pop($options);
if ($lastOption) {
$optionOrder = $lastOption->getOrder() + 1;
Expand All @@ -878,6 +882,7 @@ public function newOption(int $formId, int $questionId, array $optionTexts): Dat
$option->setQuestionId($questionId);
$option->setText($text);
$option->setOrder($optionOrder++);
$option->setOptionType($optionType);

try {
$option = $this->optionMapper->insert($option);
Expand Down Expand Up @@ -1034,6 +1039,7 @@ public function deleteOption(int $formId, int $questionId, int $optionId): DataR
* @param int $formId id of form
* @param int $questionId id of question
* @param list<int> $newOrder Array of option ids in new order.
* @param string|null $optionType the new option type (e.g. 'row')
* @return DataResponse<Http::STATUS_OK, array<string, FormsOrder>, array{}>
* @throws OCSBadRequestException The given question id doesn't match the form
* @throws OCSBadRequestException The given array contains duplicates
Expand All @@ -1050,7 +1056,7 @@ public function deleteOption(int $formId, int $questionId, int $optionId): DataR
#[NoAdminRequired()]
#[BruteForceProtection(action: 'form')]
#[ApiRoute(verb: 'PATCH', url: '/api/v3/forms/{formId}/questions/{questionId}/options')]
public function reorderOptions(int $formId, int $questionId, array $newOrder) {
public function reorderOptions(int $formId, int $questionId, array $newOrder, ?string $optionType = null): DataResponse {
$form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_EDIT);
$this->formsService->obtainFormLock($form);

Expand All @@ -1077,7 +1083,7 @@ public function reorderOptions(int $formId, int $questionId, array $newOrder) {
throw new OCSBadRequestException('The given array contains duplicates');
}

$options = $this->optionMapper->findByQuestion($questionId);
$options = $this->optionMapper->findByQuestion($questionId, $optionType);

if (sizeof($options) !== sizeof($newOrder)) {
$this->logger->debug('The length of the given array does not match the number of stored options');
Expand Down Expand Up @@ -1691,6 +1697,22 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = ''
* @param string[]|array<array{uploadedFileId: string, uploadedFileName: string}> $answerArray
*/
private function storeAnswersForQuestion(Form $form, $submissionId, array $question, array $answerArray): void {
if ($question['type'] === Constants::ANSWER_TYPE_GRID) {
if (!$answerArray) {
return;
}

$answerEntity = new Answer();
$answerEntity->setSubmissionId($submissionId);
$answerEntity->setQuestionId($question['id']);

$answerText = json_encode($answerArray);
$answerEntity->setText($answerText);
$this->answerMapper->insert($answerEntity);

return;
}

foreach ($answerArray as $answer) {
$answerEntity = new Answer();
$answerEntity->setSubmissionId($submissionId);
Expand Down
12 changes: 9 additions & 3 deletions lib/Db/Option.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,19 @@
* @method void setText(string $value)
* @method int getOrder();
* @method void setOrder(int $value)
* @method string getOptionType()
* @method void setOptionType(string $value)
*/
class Option extends Entity {

// For 32bit PHP long integers, like IDs, are represented by floats
protected int|float|null $questionId;
protected ?string $text;
protected ?int $order;
protected ?string $optionType;

public const OPTION_TYPE_ROW = 'row';
public const OPTION_TYPE_COLUMN = 'column';

/**
* Option constructor.
Expand All @@ -35,20 +41,20 @@ public function __construct() {
$this->questionId = null;
$this->text = null;
$this->order = null;
$this->optionType = null;
$this->addType('questionId', 'integer');
$this->addType('order', 'integer');
$this->addType('text', 'string');
$this->addType('optionType', 'string');
}

/**
* @return FormsOption
*/
public function read(): array {
return [
'id' => $this->getId(),
'questionId' => $this->getQuestionId(),
'order' => $this->getOrder(),
'text' => (string)$this->getText(),
'optionType' => $this->getOptionType(),
];
}
}
11 changes: 7 additions & 4 deletions lib/Db/OptionMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,19 @@ public function __construct(IDBConnection $db) {

/**
* @param int|float $questionId
* @param string|null $optionType
* @return Option[]
*/
public function findByQuestion(int|float $questionId): array {
public function findByQuestion(int|float $questionId, ?string $optionType = null): array {
$qb = $this->db->getQueryBuilder();

$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('question_id', $qb->createNamedParameter($questionId))
)
->where($qb->expr()->eq('question_id', $qb->createNamedParameter($questionId)));
if ($optionType) {
$qb->andWhere($qb->expr()->eq('option_type', $qb->createNamedParameter($optionType)));
}
$qb
->orderBy('order')
->addOrderBy('id');

Expand Down
40 changes: 40 additions & 0 deletions lib/Migration/Version050300Date20250914000000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Forms\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

class Version050300Date20250914000000 extends SimpleMigrationStep {

/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$table = $schema->getTable('forms_v2_options');

if (!$table->hascolumn('option_type')) {
$table->addColumn('option_type', Types::STRING, [
'notnull' => false,
'default' => null,
]);
}

return $schema;
}
}
6 changes: 4 additions & 2 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@
* timeMin?: int,
* timeRange?: bool,
* validationRegex?: string,
* validationType?: string
* validationType?: string,
* questionType?: 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"|"grid"
* @psalm-type FormsQuestionGridSubType = "checkbox"|"number"|"radio"|"text"
*
* @psalm-type FormsQuestion = array{
* id: int,
Expand Down
3 changes: 3 additions & 0 deletions lib/Service/FormsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,9 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType
case Constants::ANSWER_TYPE_DATE:
$allowed = Constants::EXTRA_SETTINGS_DATE;
break;
case Constants::ANSWER_TYPE_GRID:
$allowed = Constants::EXTRA_SETTINGS_GRID;
break;
case Constants::ANSWER_TYPE_TIME:
$allowed = Constants::EXTRA_SETTINGS_TIME;
break;
Expand Down
1 change: 1 addition & 0 deletions lib/Service/SubmissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ public function validateSubmission(array $questions, array $answers, string $for
throw new \InvalidArgumentException(sprintf('Question "%s" can only have two answers.', $question['text']));
} elseif ($answersCount > 1
&& $question['type'] !== Constants::ANSWER_TYPE_FILE
&& $question['type'] !== Constants::ANSWER_TYPE_GRID
&& !($question['type'] === Constants::ANSWER_TYPE_DATE && isset($question['extraSettings']['dateRange'])
|| $question['type'] === Constants::ANSWER_TYPE_TIME && isset($question['extraSettings']['timeRange']))) {
// Check if non-multiple questions have not more than one answer
Expand Down
32 changes: 31 additions & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -522,9 +522,21 @@
},
"validationType": {
"type": "string"
},
"questionType": {
"type": "string"
}
}
},
"QuestionGridSubType": {
"type": "string",
"enum": [
"checkbox",
"number",
"radio",
"text"
]
},
"QuestionType": {
"type": "string",
"enum": [
Expand All @@ -536,7 +548,8 @@
"short",
"long",
"file",
"datetime"
"datetime",
"grid"
]
},
"Share": {
Expand Down Expand Up @@ -1610,6 +1623,11 @@
"default": null,
"description": "the new question type"
},
"subtype": {
"$ref": "#/components/schemas/QuestionGridSubType",
"default": null,
"description": "the new question subtype"
},
"text": {
"type": "string",
"default": "",
Expand Down Expand Up @@ -2622,6 +2640,12 @@
"items": {
"type": "string"
}
},
"optionType": {
"type": "string",
"nullable": true,
"default": null,
"description": "the new option type (e.g. 'row')"
}
}
}
Expand Down Expand Up @@ -2837,6 +2861,12 @@
"type": "integer",
"format": "int64"
}
},
"optionType": {
"type": "string",
"nullable": true,
"default": null,
"description": "the new option type (e.g. 'row')"
}
}
}
Expand Down
Loading
Loading