Skip to content

Commit 370f155

Browse files
committed
feat: introduce Grid as a new question type
Signed-off-by: Kostiantyn Miakshyn <molodchick@gmail.com>
1 parent cc8bbc3 commit 370f155

22 files changed

+1468
-147
lines changed

lib/Constants.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,20 +71,26 @@ 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+
8186
// All AnswerTypes
8287
public const ANSWER_TYPES = [
8388
self::ANSWER_TYPE_COLOR,
8489
self::ANSWER_TYPE_DATE,
8590
self::ANSWER_TYPE_DATETIME,
8691
self::ANSWER_TYPE_DROPDOWN,
8792
self::ANSWER_TYPE_FILE,
93+
self::ANSWER_TYPE_GRID,
8894
self::ANSWER_TYPE_LINEARSCALE,
8995
self::ANSWER_TYPE_LONG,
9096
self::ANSWER_TYPE_MULTIPLE,
@@ -179,6 +185,18 @@ class Constants {
179185
'optionsLabelHighest' => ['string', 'NULL'],
180186
];
181187

188+
public const EXTRA_SETTINGS_GRID = [
189+
'columns' => ['array'],
190+
'questionType' => ['string'],
191+
'rows' => ['array'],
192+
];
193+
194+
public const EXTRA_SETTINGS_GRID_QUESTION_TYPE = [
195+
self::ANSWER_GRID_TYPE_CHECKBOX,
196+
self::ANSWER_GRID_TYPE_NUMBER,
197+
self::ANSWER_GRID_TYPE_RADIO,
198+
];
199+
182200
public const FILENAME_INVALID_CHARS = [
183201
"\n",
184202
'/',

lib/Controller/ApiController.php

Lines changed: 29 additions & 7 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,6 +1697,22 @@ 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 {
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+
16941716
foreach ($answerArray as $answer) {
16951717
$answerEntity = new Answer();
16961718
$answerEntity->setSubmissionId($submissionId);

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;

0 commit comments

Comments
 (0)