Skip to content

Commit 40f3f9e

Browse files
committed
feat: introduce Grid as a new question type
Signed-off-by: Kostiantyn Miakshyn <[email protected]>
1 parent 960f34f commit 40f3f9e

20 files changed

+1115
-110
lines changed

lib/Constants.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,20 +71,27 @@ class Constants {
7171
public const ANSWER_TYPE_DATETIME = 'datetime';
7272
public const ANSWER_TYPE_DROPDOWN = 'dropdown';
7373
public const ANSWER_TYPE_FILE = 'file';
74+
public const ANSWER_TYPE_GRID = 'grid';
7475
public const ANSWER_TYPE_LINEARSCALE = 'linearscale';
7576
public const ANSWER_TYPE_LONG = 'long';
7677
public const ANSWER_TYPE_MULTIPLE = 'multiple';
7778
public const ANSWER_TYPE_MULTIPLEUNIQUE = 'multiple_unique';
7879
public const ANSWER_TYPE_SHORT = 'short';
7980
public const ANSWER_TYPE_TIME = 'time';
8081

82+
public const ANSWER_GRID_TYPE_CHECKBOX = 'checkbox';
83+
public const ANSWER_GRID_TYPE_NUMBER = 'number';
84+
public const ANSWER_GRID_TYPE_RADIO = 'radio';
85+
public const ANSWER_GRID_TYPE_TEXT = 'text';
86+
8187
// All AnswerTypes
8288
public const ANSWER_TYPES = [
8389
self::ANSWER_TYPE_COLOR,
8490
self::ANSWER_TYPE_DATE,
8591
self::ANSWER_TYPE_DATETIME,
8692
self::ANSWER_TYPE_DROPDOWN,
8793
self::ANSWER_TYPE_FILE,
94+
self::ANSWER_TYPE_GRID,
8895
self::ANSWER_TYPE_LINEARSCALE,
8996
self::ANSWER_TYPE_LONG,
9097
self::ANSWER_TYPE_MULTIPLE,
@@ -179,6 +186,21 @@ class Constants {
179186
'optionsLabelHighest' => ['string', 'NULL'],
180187
];
181188

189+
public const EXTRA_SETTINGS_GRID = [
190+
'columnsTitle' => ['string', 'NULL'],
191+
'rowsTitle' => ['string', 'NULL'],
192+
'columns' => ['array'],
193+
'questionType' => ['string'],
194+
'rows' => ['array'],
195+
];
196+
197+
public const EXTRA_SETTINGS_GRID_QUESTION_TYPE = [
198+
self::ANSWER_GRID_TYPE_CHECKBOX,
199+
self::ANSWER_GRID_TYPE_NUMBER,
200+
self::ANSWER_GRID_TYPE_RADIO,
201+
self::ANSWER_GRID_TYPE_TEXT,
202+
];
203+
182204
public const FILENAME_INVALID_CHARS = [
183205
"\n",
184206
'/',

lib/Controller/ApiController.php

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
* @psalm-import-type FormsPartialForm from ResponseDefinitions
6262
* @psalm-import-type FormsQuestion from ResponseDefinitions
6363
* @psalm-import-type FormsQuestionType from ResponseDefinitions
64+
* @psalm-import-type FormsQuestionGridSubType from ResponseDefinitions
6465
* @psalm-import-type FormsSubmission from ResponseDefinitions
6566
* @psalm-import-type FormsSubmissions from ResponseDefinitions
6667
* @psalm-import-type FormsUploadedFile from ResponseDefinitions
@@ -445,6 +446,7 @@ public function getQuestion(int $formId, int $questionId): DataResponse {
445446
*
446447
* @param int $formId the form id
447448
* @param FormsQuestionType $type the new question type
449+
* @param FormsQuestionGridSubType $subtype the new question subtype
448450
* @param string $text the new question title
449451
* @param ?int $fromId (optional) id of the question that should be cloned
450452
* @return DataResponse<Http::STATUS_CREATED, FormsQuestion, array{}>
@@ -461,7 +463,7 @@ public function getQuestion(int $formId, int $questionId): DataResponse {
461463
#[NoAdminRequired()]
462464
#[BruteForceProtection(action: 'form')]
463465
#[ApiRoute(verb: 'POST', url: '/api/v3/forms/{formId}/questions')]
464-
public function newQuestion(int $formId, ?string $type = null, string $text = '', ?int $fromId = null): DataResponse {
466+
public function newQuestion(int $formId, ?string $type = null, ?string $subtype = null, string $text = '', ?int $fromId = null): DataResponse {
465467
$form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_EDIT);
466468
$this->formsService->obtainFormLock($form);
467469

@@ -505,7 +507,7 @@ public function newQuestion(int $formId, ?string $type = null, string $text = ''
505507
$question->setText($text);
506508
$question->setDescription('');
507509
$question->setIsRequired(false);
508-
$question->setExtraSettings([]);
510+
$question->setExtraSettings($subtype ? ['questionType' => $subtype] : []);
509511

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

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

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

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

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

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

1080-
$options = $this->optionMapper->findByQuestion($questionId);
1086+
$options = $this->optionMapper->findByQuestion($questionId, $optionType);
10811087

10821088
if (sizeof($options) !== sizeof($newOrder)) {
10831089
$this->logger->debug('The length of the given array does not match the number of stored options');
@@ -1691,7 +1697,23 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = ''
16911697
* @param string[]|array<array{uploadedFileId: string, uploadedFileName: string}> $answerArray
16921698
*/
16931699
private function storeAnswersForQuestion(Form $form, $submissionId, array $question, array $answerArray): void {
1694-
foreach ($answerArray as $answer) {
1700+
if ($question['type'] === Constants::ANSWER_TYPE_GRID) {
1701+
if (!$answerArray) {
1702+
return;
1703+
}
1704+
1705+
$answerEntity = new Answer();
1706+
$answerEntity->setSubmissionId($submissionId);
1707+
$answerEntity->setQuestionId($question['id']);
1708+
1709+
$answerText = json_encode($answerArray);
1710+
$answerEntity->setText($answerText);
1711+
$this->answerMapper->insert($answerEntity);
1712+
1713+
return;
1714+
}
1715+
1716+
foreach ($answerArray as $answer) {
16951717
$answerEntity = new Answer();
16961718
$answerEntity->setSubmissionId($submissionId);
16971719
$answerEntity->setQuestionId($question['id']);

lib/Db/Option.php

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,19 @@
2020
* @method void setText(string $value)
2121
* @method int getOrder();
2222
* @method void setOrder(int $value)
23+
* @method string getOptionType()
24+
* @method void setOptionType(string $value)
2325
*/
2426
class Option extends Entity {
2527

2628
// For 32bit PHP long integers, like IDs, are represented by floats
2729
protected int|float|null $questionId;
2830
protected ?string $text;
2931
protected ?int $order;
32+
protected ?string $optionType;
33+
34+
public const OPTION_TYPE_ROW = 'row';
35+
public const OPTION_TYPE_COLUMN = 'column';
3036

3137
/**
3238
* Option constructor.
@@ -35,20 +41,20 @@ public function __construct() {
3541
$this->questionId = null;
3642
$this->text = null;
3743
$this->order = null;
44+
$this->optionType = null;
3845
$this->addType('questionId', 'integer');
3946
$this->addType('order', 'integer');
4047
$this->addType('text', 'string');
48+
$this->addType('optionType', 'string');
4149
}
4250

43-
/**
44-
* @return FormsOption
45-
*/
4651
public function read(): array {
4752
return [
4853
'id' => $this->getId(),
4954
'questionId' => $this->getQuestionId(),
5055
'order' => $this->getOrder(),
5156
'text' => (string)$this->getText(),
57+
'optionType' => $this->getOptionType(),
5258
];
5359
}
5460
}

lib/Db/OptionMapper.php

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,19 @@ public function __construct(IDBConnection $db) {
2727

2828
/**
2929
* @param int|float $questionId
30+
* @param string|null $optionType
3031
* @return Option[]
3132
*/
32-
public function findByQuestion(int|float $questionId): array {
33+
public function findByQuestion(int|float $questionId, ?string $optionType = null): array {
3334
$qb = $this->db->getQueryBuilder();
3435

3536
$qb->select('*')
3637
->from($this->getTableName())
37-
->where(
38-
$qb->expr()->eq('question_id', $qb->createNamedParameter($questionId))
39-
)
38+
->where($qb->expr()->eq('question_id', $qb->createNamedParameter($questionId)));
39+
if ($optionType) {
40+
$qb->andWhere($qb->expr()->eq('option_type', $qb->createNamedParameter($optionType)));
41+
}
42+
$qb
4043
->orderBy('order')
4144
->addOrderBy('id');
4245

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Forms\Migration;
11+
12+
use Closure;
13+
use OCP\DB\ISchemaWrapper;
14+
use OCP\DB\Types;
15+
use OCP\Migration\IOutput;
16+
use OCP\Migration\SimpleMigrationStep;
17+
18+
class Version050300Date20250914000000 extends SimpleMigrationStep {
19+
20+
/**
21+
* @param IOutput $output
22+
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
23+
* @param array $options
24+
* @return null|ISchemaWrapper
25+
*/
26+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
27+
/** @var ISchemaWrapper $schema */
28+
$schema = $schemaClosure();
29+
$table = $schema->getTable('forms_v2_options');
30+
31+
if (!$table->hascolumn('option_type')) {
32+
$table->addColumn('option_type', Types::STRING, [
33+
'notnull' => false,
34+
'default' => null,
35+
]);
36+
}
37+
38+
return $schema;
39+
}
40+
}

lib/ResponseDefinitions.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,12 @@
3939
* timeMin?: int,
4040
* timeRange?: bool,
4141
* validationRegex?: string,
42-
* validationType?: string
42+
* validationType?: string,
43+
* questionType?: string,
4344
* }
4445
*
45-
* @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"
46+
* @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"|"grid"
47+
* @psalm-type FormsQuestionGridSubType = "checkbox"|"number"|"radio"|"text"
4648
*
4749
* @psalm-type FormsQuestion = array{
4850
* id: int,

lib/Service/FormsService.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,9 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType
805805
case Constants::ANSWER_TYPE_DATE:
806806
$allowed = Constants::EXTRA_SETTINGS_DATE;
807807
break;
808+
case Constants::ANSWER_TYPE_GRID:
809+
$allowed = Constants::EXTRA_SETTINGS_GRID;
810+
break;
808811
case Constants::ANSWER_TYPE_TIME:
809812
$allowed = Constants::EXTRA_SETTINGS_TIME;
810813
break;

lib/Service/SubmissionService.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,7 @@ public function validateSubmission(array $questions, array $answers, string $for
432432
throw new \InvalidArgumentException(sprintf('Question "%s" can only have two answers.', $question['text']));
433433
} elseif ($answersCount > 1
434434
&& $question['type'] !== Constants::ANSWER_TYPE_FILE
435+
&& $question['type'] !== Constants::ANSWER_TYPE_GRID
435436
&& !($question['type'] === Constants::ANSWER_TYPE_DATE && isset($question['extraSettings']['dateRange'])
436437
|| $question['type'] === Constants::ANSWER_TYPE_TIME && isset($question['extraSettings']['timeRange']))) {
437438
// Check if non-multiple questions have not more than one answer

openapi.json

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,15 @@
525525
}
526526
}
527527
},
528+
"QuestionGridSubType": {
529+
"type": "string",
530+
"enum": [
531+
"checkbox",
532+
"number",
533+
"radio",
534+
"text"
535+
]
536+
},
528537
"QuestionType": {
529538
"type": "string",
530539
"enum": [
@@ -536,7 +545,8 @@
536545
"short",
537546
"long",
538547
"file",
539-
"datetime"
548+
"datetime",
549+
"grid"
540550
]
541551
},
542552
"Share": {
@@ -1610,6 +1620,11 @@
16101620
"default": null,
16111621
"description": "the new question type"
16121622
},
1623+
"subtype": {
1624+
"$ref": "#/components/schemas/QuestionGridSubType",
1625+
"default": null,
1626+
"description": "the new question subtype"
1627+
},
16131628
"text": {
16141629
"type": "string",
16151630
"default": "",
@@ -2622,6 +2637,12 @@
26222637
"items": {
26232638
"type": "string"
26242639
}
2640+
},
2641+
"optionType": {
2642+
"type": "string",
2643+
"nullable": true,
2644+
"default": null,
2645+
"description": "the new option type (e.g. 'row')"
26252646
}
26262647
}
26272648
}
@@ -2837,6 +2858,12 @@
28372858
"type": "integer",
28382859
"format": "int64"
28392860
}
2861+
},
2862+
"optionType": {
2863+
"type": "string",
2864+
"nullable": true,
2865+
"default": null,
2866+
"description": "the new option type (e.g. 'row')"
28402867
}
28412868
}
28422869
}

0 commit comments

Comments
 (0)