Skip to content

Commit 0a50988

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

File tree

16 files changed

+803
-103
lines changed

16 files changed

+803
-103
lines changed

lib/Constants.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ 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';
@@ -85,6 +86,7 @@ class Constants {
8586
self::ANSWER_TYPE_DATETIME,
8687
self::ANSWER_TYPE_DROPDOWN,
8788
self::ANSWER_TYPE_FILE,
89+
self::ANSWER_TYPE_GRID,
8890
self::ANSWER_TYPE_LINEARSCALE,
8991
self::ANSWER_TYPE_LONG,
9092
self::ANSWER_TYPE_MULTIPLE,
@@ -179,6 +181,20 @@ class Constants {
179181
'optionsLabelHighest' => ['string', 'NULL'],
180182
];
181183

184+
public const EXTRA_SETTINGS_GRID = [
185+
'columnsTitle' => ['string', 'NULL'],
186+
'rowsTitle' => ['string', 'NULL'],
187+
'columns' => ['array'],
188+
'questionType' => ['string'],
189+
'rows' => ['array'],
190+
];
191+
192+
public const EXTRA_SETTINGS_GRID_QUESTION_TYPE = [
193+
self::ANSWER_TYPE_SHORT,
194+
self::ANSWER_TYPE_SHORT,
195+
self::ANSWER_TYPE_SHORT,
196+
];
197+
182198
public const FILENAME_INVALID_CHARS = [
183199
"\n",
184200
'/',

lib/Controller/ApiController.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,7 @@ public function reorderQuestions(int $formId, array $newOrder): DataResponse {
820820
* @param int $formId id of the form
821821
* @param int $questionId id of the question
822822
* @param list<string> $optionTexts the new option text
823+
* @param string|null $optionType the new option type (e.g. 'row')
823824
* @return DataResponse<Http::STATUS_CREATED, list<FormsOption>, array{}> Returns a DataResponse containing the added options
824825
* @throws OCSBadRequestException This question is not part ot the given form
825826
* @throws OCSForbiddenException This form is archived and can not be modified
@@ -833,11 +834,12 @@ public function reorderQuestions(int $formId, array $newOrder): DataResponse {
833834
#[NoAdminRequired()]
834835
#[BruteForceProtection(action: 'form')]
835836
#[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}', [
837+
public function newOption(int $formId, int $questionId, array $optionTexts, ?string $optionType = null): DataResponse {
838+
$this->logger->debug('Adding new options: formId: {formId}, questionId: {questionId}, text: {text}, optionType: {optionType}', [
838839
'formId' => $formId,
839840
'questionId' => $questionId,
840841
'text' => $optionTexts,
842+
'optionType' => $optionType,
841843
]);
842844

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

865867
// Retrieve all options sorted by 'order'. Takes the order of the last array-element and adds one.
866-
$options = $this->optionMapper->findByQuestion($questionId);
868+
$options = $this->optionMapper->findByQuestion($questionId, $optionType);
867869
$lastOption = array_pop($options);
868870
if ($lastOption) {
869871
$optionOrder = $lastOption->getOrder() + 1;
@@ -878,6 +880,7 @@ public function newOption(int $formId, int $questionId, array $optionTexts): Dat
878880
$option->setQuestionId($questionId);
879881
$option->setText($text);
880882
$option->setOrder($optionOrder++);
883+
$option->setOptionType($optionType);
881884

882885
try {
883886
$option = $this->optionMapper->insert($option);

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: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,20 @@ 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-
)
40-
->orderBy('order')
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
43+
->orderBy('order')
4144
->addOrderBy('id');
4245

4346
return $this->findEntities($qb);
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 Version050200Date20250914000000 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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
* validationType?: string
4343
* }
4444
*
45-
* @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"
45+
* @psalm-type FormsQuestionType = "dropdown"|"multiple"|"multiple_unique"|"date"|"time"|"short"|"long"|"file"|"datetime"|"grid"
4646
*
4747
* @psalm-type FormsQuestion = array{
4848
* 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;

openapi.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -536,7 +536,8 @@
536536
"short",
537537
"long",
538538
"file",
539-
"datetime"
539+
"datetime",
540+
"grid"
540541
]
541542
},
542543
"Share": {
@@ -2622,6 +2623,12 @@
26222623
"items": {
26232624
"type": "string"
26242625
}
2626+
},
2627+
"optionType": {
2628+
"type": "string",
2629+
"nullable": true,
2630+
"default": null,
2631+
"description": "the new option type (e.g. 'row')"
26252632
}
26262633
}
26272634
}

src/components/Questions/AnswerInput.vue

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,14 @@ import IconCheckboxBlankOutline from 'vue-material-design-icons/CheckboxBlankOut
8080
import IconDelete from 'vue-material-design-icons/TrashCanOutline.vue'
8181
import IconDragIndicator from '../Icons/IconDragIndicator.vue'
8282
import IconRadioboxBlank from 'vue-material-design-icons/RadioboxBlank.vue'
83+
import IconTableColumn from 'vue-material-design-icons/TableColumn.vue'
84+
import IconTableRow from 'vue-material-design-icons/TableRow.vue'
8385
8486
import NcActions from '@nextcloud/vue/components/NcActions'
8587
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
8688
import NcButton from '@nextcloud/vue/components/NcButton'
8789
88-
import { INPUT_DEBOUNCE_MS } from '../../models/Constants.ts'
90+
import { INPUT_DEBOUNCE_MS, OptionType} from '../../models/Constants.ts'
8991
import OcsResponse2Data from '../../utils/OcsResponse2Data.js'
9092
import logger from '../../utils/Logger.js'
9193
@@ -99,6 +101,8 @@ export default {
99101
IconDelete,
100102
IconDragIndicator,
101103
IconRadioboxBlank,
104+
IconTableColumn,
105+
IconTableRow,
102106
NcActions,
103107
NcActionButton,
104108
NcButton,
@@ -133,6 +137,10 @@ export default {
133137
type: Number,
134138
required: true,
135139
},
140+
optionType: {
141+
type: String,
142+
required: true,
143+
}
136144
},
137145
138146
data() {
@@ -154,7 +162,7 @@ export default {
154162
},
155163
156164
optionDragMenuId() {
157-
return `q${this.answer.questionId}o${this.answer.id}__drag_menu`
165+
return `q${this.answer.questionId}o${this.answer.id}o${this.optionType}__drag_menu`
158166
},
159167
160168
placeholder() {
@@ -165,6 +173,14 @@ export default {
165173
},
166174
167175
pseudoIcon() {
176+
if (this.optionType === OptionType.Column) {
177+
return IconTableColumn;
178+
}
179+
180+
if (this.optionType === OptionType.Row) {
181+
return IconTableRow;
182+
}
183+
168184
return this.isUnique ? IconRadioboxBlank : IconCheckboxBlankOutline
169185
},
170186
},
@@ -180,7 +196,7 @@ export default {
180196
181197
methods: {
182198
handleTabbing() {
183-
this.$emit('tabbed-out')
199+
this.$emit('tabbed-out', this.optionType)
184200
},
185201
186202
/**
@@ -227,7 +243,7 @@ export default {
227243
*/
228244
focusNextInput() {
229245
if (this.index <= this.maxIndex) {
230-
this.$emit('focus-next', this.index)
246+
this.$emit('focus-next', this.index, this.optionType)
231247
}
232248
},
233249
@@ -251,7 +267,8 @@ export default {
251267
252268
// do this in queue to prevent race conditions between PATCH and DELETE
253269
this.queue.add(() => {
254-
this.$emit('delete', this.answer.id)
270+
this.$emit('delete', this.answer)
271+
console.log('emit delete', this.answer)
255272
// Prevent any patch requests
256273
this.queue.pause()
257274
this.queue.clear()
@@ -265,6 +282,8 @@ export default {
265282
* @return {object} answer
266283
*/
267284
async createAnswer(answer) {
285+
console.log('debug: createAnswer', {optionType: this.optionType})
286+
268287
try {
269288
const response = await axios.post(
270289
generateOcsUrl(
@@ -276,6 +295,7 @@ export default {
276295
),
277296
{
278297
optionTexts: [answer.text],
298+
optionType: answer.optionType,
279299
},
280300
)
281301
logger.debug('Created answer', { answer })

0 commit comments

Comments
 (0)