Skip to content
Closed
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
16 changes: 16 additions & 0 deletions lib/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ 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';
Expand All @@ -85,6 +86,7 @@ class Constants {
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 +181,20 @@ 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_TYPE_SHORT,
self::ANSWER_TYPE_SHORT,
self::ANSWER_TYPE_SHORT,
];

public const FILENAME_INVALID_CHARS = [
"\n",
'/',
Expand Down
9 changes: 6 additions & 3 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,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 +834,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 +865,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 +880,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
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(),
];
}
}
13 changes: 8 additions & 5 deletions lib/Db/OptionMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,20 @@ 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))
)
->orderBy('order')
->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');

return $this->findEntities($qb);
Expand Down
40 changes: 40 additions & 0 deletions lib/Migration/Version050200Date20250914000000.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 Version050200Date20250914000000 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;
}
}
2 changes: 1 addition & 1 deletion lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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"|"grid"
*
* @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
9 changes: 8 additions & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,8 @@
"short",
"long",
"file",
"datetime"
"datetime",
"grid"
]
},
"Share": {
Expand Down Expand Up @@ -2622,6 +2623,12 @@
"items": {
"type": "string"
}
},
"optionType": {
"type": "string",
"nullable": true,
"default": null,
"description": "the new option type (e.g. 'row')"
}
}
}
Expand Down
30 changes: 25 additions & 5 deletions src/components/Questions/AnswerInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,14 @@
import IconDelete from 'vue-material-design-icons/TrashCanOutline.vue'
import IconDragIndicator from '../Icons/IconDragIndicator.vue'
import IconRadioboxBlank from 'vue-material-design-icons/RadioboxBlank.vue'
import IconTableColumn from 'vue-material-design-icons/TableColumn.vue'
import IconTableRow from 'vue-material-design-icons/TableRow.vue'

import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcButton from '@nextcloud/vue/components/NcButton'

import { INPUT_DEBOUNCE_MS } from '../../models/Constants.ts'
import { INPUT_DEBOUNCE_MS, OptionType} from '../../models/Constants.ts'
import OcsResponse2Data from '../../utils/OcsResponse2Data.js'
import logger from '../../utils/Logger.js'

Expand All @@ -99,6 +101,8 @@
IconDelete,
IconDragIndicator,
IconRadioboxBlank,
IconTableColumn,
IconTableRow,
NcActions,
NcActionButton,
NcButton,
Expand Down Expand Up @@ -133,6 +137,10 @@
type: Number,
required: true,
},
optionType: {
type: String,
required: true,
}
},

data() {
Expand All @@ -154,7 +162,7 @@
},

optionDragMenuId() {
return `q${this.answer.questionId}o${this.answer.id}__drag_menu`
return `q${this.answer.questionId}o${this.answer.id}o${this.optionType}__drag_menu`
},

placeholder() {
Expand All @@ -165,6 +173,14 @@
},

pseudoIcon() {
if (this.optionType === OptionType.Column) {
return IconTableColumn;
}

if (this.optionType === OptionType.Row) {
return IconTableRow;
}

return this.isUnique ? IconRadioboxBlank : IconCheckboxBlankOutline
},
},
Expand All @@ -180,7 +196,7 @@

methods: {
handleTabbing() {
this.$emit('tabbed-out')
this.$emit('tabbed-out', this.optionType)
},

/**
Expand Down Expand Up @@ -227,7 +243,7 @@
*/
focusNextInput() {
if (this.index <= this.maxIndex) {
this.$emit('focus-next', this.index)
this.$emit('focus-next', this.index, this.optionType)
}
},

Expand All @@ -251,7 +267,8 @@

// do this in queue to prevent race conditions between PATCH and DELETE
this.queue.add(() => {
this.$emit('delete', this.answer.id)
this.$emit('delete', this.answer)
console.log('emit delete', this.answer)

Check failure on line 271 in src/components/Questions/AnswerInput.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Unexpected console statement
// Prevent any patch requests
this.queue.pause()
this.queue.clear()
Expand All @@ -265,6 +282,8 @@
* @return {object} answer
*/
async createAnswer(answer) {
console.log('debug: createAnswer', {optionType: this.optionType})

Check failure on line 285 in src/components/Questions/AnswerInput.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Unexpected console statement

try {
const response = await axios.post(
generateOcsUrl(
Expand All @@ -276,6 +295,7 @@
),
{
optionTexts: [answer.text],
optionType: answer.optionType,
},
)
logger.debug('Created answer', { answer })
Expand Down
Loading
Loading