From b5af87c406c57f5de99c33960a57a9fb03a115bc Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Sun, 14 Sep 2025 00:28:20 +0200 Subject: [PATCH] feat: introduce Grid as a new question type Signed-off-by: Kostiantyn Miakshyn --- lib/Constants.php | 16 + lib/Controller/ApiController.php | 9 +- lib/Db/Option.php | 12 +- lib/Db/OptionMapper.php | 13 +- .../Version050200Date20250914000000.php | 40 ++ lib/ResponseDefinitions.php | 2 +- lib/Service/FormsService.php | 3 + openapi.json | 9 +- src/components/Questions/AnswerInput.vue | 30 +- src/components/Questions/QuestionGrid.vue | 512 ++++++++++++++++++ src/mixins/QuestionMixin.js | 1 + src/mixins/QuestionMultipleMixin.ts | 254 +++++---- src/models/AnswerTypes.js | 50 ++ src/models/Constants.ts | 6 + src/models/Entities.d.ts | 3 +- src/views/Create.vue | 1 + 16 files changed, 849 insertions(+), 112 deletions(-) create mode 100644 lib/Migration/Version050200Date20250914000000.php create mode 100644 src/components/Questions/QuestionGrid.vue diff --git a/lib/Constants.php b/lib/Constants.php index 7b09bda65..a51b0f3b8 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -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'; @@ -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, @@ -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", '/', diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 6b1282c69..3ebfe07b1 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -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 $optionTexts the new option text + * @param string|null $optionType the new option type (e.g. 'row') * @return DataResponse, 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 @@ -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); @@ -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; @@ -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); diff --git a/lib/Db/Option.php b/lib/Db/Option.php index 481a977a6..421e10188 100644 --- a/lib/Db/Option.php +++ b/lib/Db/Option.php @@ -20,6 +20,8 @@ * @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 { @@ -27,6 +29,10 @@ class Option extends Entity { 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. @@ -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(), ]; } } diff --git a/lib/Db/OptionMapper.php b/lib/Db/OptionMapper.php index 822f3d625..42b195688 100644 --- a/lib/Db/OptionMapper.php +++ b/lib/Db/OptionMapper.php @@ -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); diff --git a/lib/Migration/Version050200Date20250914000000.php b/lib/Migration/Version050200Date20250914000000.php new file mode 100644 index 000000000..012e813d3 --- /dev/null +++ b/lib/Migration/Version050200Date20250914000000.php @@ -0,0 +1,40 @@ +getTable('forms_v2_options'); + + if (!$table->hascolumn('option_type')) { + $table->addColumn('option_type', Types::STRING, [ + 'notnull' => false, + 'default' => null, + ]); + } + + return $schema; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index f4833aa50..eeda28556 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -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, diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index 9a4c2d546..83e68d90b 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -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; diff --git a/openapi.json b/openapi.json index 772186baa..d7c8c5dd0 100644 --- a/openapi.json +++ b/openapi.json @@ -536,7 +536,8 @@ "short", "long", "file", - "datetime" + "datetime", + "grid" ] }, "Share": { @@ -2622,6 +2623,12 @@ "items": { "type": "string" } + }, + "optionType": { + "type": "string", + "nullable": true, + "default": null, + "description": "the new option type (e.g. 'row')" } } } diff --git a/src/components/Questions/AnswerInput.vue b/src/components/Questions/AnswerInput.vue index f0933a7d6..a9e6d8e23 100644 --- a/src/components/Questions/AnswerInput.vue +++ b/src/components/Questions/AnswerInput.vue @@ -80,12 +80,14 @@ import IconCheckboxBlankOutline from 'vue-material-design-icons/CheckboxBlankOut 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' @@ -99,6 +101,8 @@ export default { IconDelete, IconDragIndicator, IconRadioboxBlank, + IconTableColumn, + IconTableRow, NcActions, NcActionButton, NcButton, @@ -133,6 +137,10 @@ export default { type: Number, required: true, }, + optionType: { + type: String, + required: true, + } }, data() { @@ -154,7 +162,7 @@ export default { }, 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() { @@ -165,6 +173,14 @@ export default { }, pseudoIcon() { + if (this.optionType === OptionType.Column) { + return IconTableColumn; + } + + if (this.optionType === OptionType.Row) { + return IconTableRow; + } + return this.isUnique ? IconRadioboxBlank : IconCheckboxBlankOutline }, }, @@ -180,7 +196,7 @@ export default { methods: { handleTabbing() { - this.$emit('tabbed-out') + this.$emit('tabbed-out', this.optionType) }, /** @@ -227,7 +243,7 @@ export default { */ focusNextInput() { if (this.index <= this.maxIndex) { - this.$emit('focus-next', this.index) + this.$emit('focus-next', this.index, this.optionType) } }, @@ -251,7 +267,8 @@ export default { // 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) // Prevent any patch requests this.queue.pause() this.queue.clear() @@ -265,6 +282,8 @@ export default { * @return {object} answer */ async createAnswer(answer) { + console.log('debug: createAnswer', {optionType: this.optionType}) + try { const response = await axios.post( generateOcsUrl( @@ -276,6 +295,7 @@ export default { ), { optionTexts: [answer.text], + optionType: answer.optionType, }, ) logger.debug('Created answer', { answer }) diff --git a/src/components/Questions/QuestionGrid.vue b/src/components/Questions/QuestionGrid.vue new file mode 100644 index 000000000..dfd381302 --- /dev/null +++ b/src/components/Questions/QuestionGrid.vue @@ -0,0 +1,512 @@ + + + + + + + diff --git a/src/mixins/QuestionMixin.js b/src/mixins/QuestionMixin.js index 587ed3bb7..9156242cc 100644 --- a/src/mixins/QuestionMixin.js +++ b/src/mixins/QuestionMixin.js @@ -387,6 +387,7 @@ export default { id: option.id, // Use the ID from the server questionId: this.id, text: option.text, + optionType: option.optionType, local: false, }) }) diff --git a/src/mixins/QuestionMultipleMixin.ts b/src/mixins/QuestionMultipleMixin.ts index 68425bad5..4e69107d0 100644 --- a/src/mixins/QuestionMultipleMixin.ts +++ b/src/mixins/QuestionMultipleMixin.ts @@ -35,90 +35,132 @@ export default defineComponent({ return value?.text?.trim?.().length === 0 }, - /** - * Options sorted by order or randomized if configured - */ - sortedOptions: { - get() { - // Only shuffle options if not in editing mode (and shuffling is enabled) - if (this.readOnly && this.extraSettings?.shuffleOptions) { - return this.shuffleArray(this.options) - } - - // Ensure order of options always is the same - const options = [...this.options].sort((a, b) => { - if (a.order === b.order) { - return a.id - b.id - } - return (a.order ?? 0) - (b.order ?? 0) - }) - - if (!this.readOnly) { - // In edit mode append an empty option - return [ - ...options, - { - local: true, - questionId: this.id, - text: '', - order: options.length, - }, - ] - } - return options - }, - set(newOptions: FormsOption[]) { - this.updateOptions( - newOptions - .filter((option) => !option.local) - .map((option, index) => ({ - ...option, - order: index, - })), - ) - }, - }, - - /** - * Debounced function to save options order - */ - onOptionsReordered() { - return debounce(this.saveOptionsOrder, INPUT_DEBOUNCE_MS) - }, - }, + expectedOptionTypes() { + return ['row', 'column'] + }, + + sortedOptionsPerType(): { [key: string]: FormsOption[] } { + let optionsPerType = Object.fromEntries(this.expectedOptionTypes.map(optionType => [optionType, []])) + // console.log('sortedOptionsPerType', optionsPerType) + + this.options.forEach(option => { + optionsPerType[option.optionType].push(option) + }) + + for (const optionType of Object.keys(optionsPerType)) { + // Only shuffle options if not in editing mode (and shuffling is enabled) + if (this.readOnly && this.extraSettings?.shuffleOptions) { + optionsPerType[optionType] = this.shuffleArray(optionsPerType[optionType]) + } else { + // Ensure order of options always is the same + optionsPerType[optionType] = [...optionsPerType[optionType]].sort((a, b) => { + if (a.order === b.order) { + return a.id - b.id + } + return (a.order ?? 0) - (b.order ?? 0) + }) + + if (!this.readOnly) { + // In edit mode append an empty option + optionsPerType[optionType].push({ + local: true, + questionId: this.id, + text: '', + optionType: optionType, + order: optionsPerType[optionType].length, + }) + } + } + } + + return optionsPerType + }, + }, methods: { /** - * Set focus on next AnswerInput - * - * @param {number} index Index of current option - */ - focusNextInput(index: number) { - this.focusIndex(index + 1) + * Set focus on next AnswerInput + * + * @param {number} index Index of current option + * @param {string} optionType Type of current option + */ + focusNextInput(index: number, optionType: string) { + this.focusIndex(index + 1, optionType) }, /** * Focus the input matching the index * * @param {number} index the value index + * @param {string} optionType the option type to focus */ - focusIndex(index: number) { + focusIndex(index: number, optionType: string) { // refs are not guaranteed to be in correct order - we need to find the correct item const item = this.$refs.input.find( - ({ $vnode: vnode }) => - vnode?.componentOptions.propsData.index === index, + ({ $vnode: vnode }) => { + const propsData = vnode?.componentOptions.propsData; + + return propsData.optionType === optionType && propsData?.index === index + } ) if (item) { item.focus() } else { logger.warn('Could not find option to focus', { index, - options: this.sortedOptions, + options: this.sortedOptionsPerType[optionType], }) } }, - /** + sortOptionsOfType(options: FormsOption[], optionType: string): FormsOption[] { + // Only shuffle options if not in editing mode (and shuffling is enabled) + options = options.filter(option => option.optionType === optionType); + if (this.readOnly && this.extraSettings?.shuffleOptions) { + return this.shuffleArray(options) + } + + // Ensure order of options always is the same + options = [...options].sort((a, b) => { + if (a.order === b.order) { + return a.id - b.id + } + return (a.order ?? 0) - (b.order ?? 0) + }) + + if (!this.readOnly) { + // In edit mode append an empty option + return [ + ...options, + { + local: true, + questionId: this.id, + text: '', + optionType: optionType, + order: options.length, + }, + ] + } + return options + }, + + updateOptionsOrder(newOptions: FormsOption[], optionType: string) { + console.log('updateOptionsOrder', newOptions, optionType) + + this.replaceOptionsOfType( + newOptions + .filter((option) => !option.local) + .map((option, index) => { + return ({ + ...option, + order: index, + }); + }), + optionType, + ) + }, + + /** * Handles the creation of a new answer option. * * @param index the index of the answer @@ -127,11 +169,25 @@ export default defineComponent({ */ onCreateAnswer(index: number, answer: FormsOption): void { this.$nextTick(() => { - this.$nextTick(() => this.focusIndex(index)) + this.$nextTick(() => this.focusIndex(index, answer.optionType)) }) this.updateOptions([...this.options, answer]) }, + /** + * Replace all options of a certain type + * + * @param {Array} options options to change + * @param {string} optionType the type of options to update + */ + replaceOptionsOfType(options: FormsOption[], optionType: string) { + const updatedOptions = [ + ...this.options.filter(option => option.optionType !== optionType), + ...options + ] + + this.updateOptions(updatedOptions) + }, /** * Update the options * This will handle updating the form (emitting the changes) and update last changed property @@ -150,53 +206,57 @@ export default defineComponent({ * @param {object} answer the new answer value */ updateAnswer(index: number, answer: FormsOption) { - const options = [...this.sortedOptions] + const options = [...this.sortedOptionsPerType[answer.optionType]] const [oldValue] = options.splice(index, 1, answer) // New value created - we need to set the correct focus if (oldValue.local && !answer.local) { this.$nextTick(() => { - this.$nextTick(() => this.focusIndex(index)) + this.$nextTick(() => this.focusIndex(index, answer.optionType)) }) } - this.updateOptions(options.filter(({ local }) => !local)) + this.replaceOptionsOfType(options.filter(({ local }) => !local), answer.optionType) }, /** * Remove any empty options when leaving an option */ - checkValidOption() { + checkValidOption(optionType: string) { + console.log('checkValidOption', optionType, this.sortedOptionsPerType[optionType]) // When leaving edit mode, filter and delete empty options - this.options.forEach((option) => { + this.sortedOptionsPerType[optionType].forEach((option) => { if (!option.text && !option.local) { - this.deleteOption(option.id) + this.deleteOption(option) } }) }, /** - * Delete an option - * - * @param {number} id the options id - */ - deleteOption(id: number) { - const index = this.sortedOptions.findIndex((option) => option.id === id) - const options = [...this.sortedOptions] + * Delete an option + * + * @param optionToDelete + */ + deleteOption(optionToDelete: FormsOption) { + const optionType = optionToDelete.optionType; + const sortedOptions = this.sortedOptionsPerType[optionType]; + const index = sortedOptions.findIndex((option) => option.id === optionToDelete.id) + const options = [...sortedOptions] const [option] = options.splice(index, 1) // delete from Db this.deleteOptionFromDatabase(option) // Update question - remove option and reorder other - this.updateOptions( + this.replaceOptionsOfType( options .filter(({ local }) => !local) .map((option, order) => ({ ...option, order })), + optionType, ) // Focus the previous option - this.$nextTick(() => this.focusIndex(Math.max(index - 1, 0))) + this.$nextTick(() => this.focusIndex(Math.max(index - 1, 0)), optionType) }, /** @@ -248,12 +308,12 @@ export default defineComponent({ options.splice(index, 0, option) this.updateOptions(options) - this.focusIndex(index) + this.focusIndex(index, option.optionType) }, - async saveOptionsOrder() { - try { - const newOrder = this.sortedOptions + async saveOptionsOrder(optionType: string) { + try { + const newOrder = this.sortedOptionsPerType[optionType] .filter((option) => !option.local) .map((option) => option.id) @@ -267,6 +327,8 @@ export default defineComponent({ ), { newOrder, + // fixme: accept optionType on the server side + optionType, }, ) emit('forms:last-updated:set', this.formId) @@ -279,28 +341,34 @@ export default defineComponent({ /** * Reorder option by moving it upwards the list * @param {number} index Option that should move up + * @param {string} optionType Type of current option */ - onOptionMoveUp(index: number) { + onOptionMoveUp(index: number, optionType: string) { if (index > 0) { - this.onOptionMoveDown(index - 1) + this.onOptionMoveDown(index - 1, optionType) } }, /** - * Reorder option by moving it downwards the list - * @param {number} index Option that should move down - */ - onOptionMoveDown(index: number) { - if (index === this.sortedOptions.length - 1) { + * Reorder option by moving it downwards the list + * @param {number} index Option that should move down + * @param {string} optionType Type of current option + */ + onOptionMoveDown(index: number, optionType: string) { + if (index === this.sortedOptionsPerType[optionType].length - 1) { return } // swap positions - const first = this.sortedOptions[index] - const second = this.sortedOptions[index + 1] + const first = this.sortedOptionsPerType[optionType][index] + const second = this.sortedOptionsPerType[optionType][index + 1] second.order = index first.order = index + 1 - this.onOptionsReordered() - }, + + this.$nextTick(debounce(function() { + // fixme: actually debounce not works + this.saveOptionsOrder(optionType) + }, INPUT_DEBOUNCE_MS)) + }, }, }) diff --git a/src/models/AnswerTypes.js b/src/models/AnswerTypes.js index df8cf7464..2d9033c38 100644 --- a/src/models/AnswerTypes.js +++ b/src/models/AnswerTypes.js @@ -7,6 +7,7 @@ import QuestionColor from '../components/Questions/QuestionColor.vue' import QuestionDate from '../components/Questions/QuestionDate.vue' import QuestionDropdown from '../components/Questions/QuestionDropdown.vue' import QuestionFile from '../components/Questions/QuestionFile.vue' +import QuestionGrid from '../components/Questions/QuestionGrid.vue' import QuestionLinearScale from '../components/Questions/QuestionLinearScale.vue' import QuestionLong from '../components/Questions/QuestionLong.vue' import QuestionMultiple from '../components/Questions/QuestionMultiple.vue' @@ -17,11 +18,14 @@ import IconCalendar from 'vue-material-design-icons/CalendarOutline.vue' import IconCheckboxOutline from 'vue-material-design-icons/CheckboxOutline.vue' import IconClockOutline from 'vue-material-design-icons/ClockOutline.vue' import IconFile from 'vue-material-design-icons/FileOutline.vue' +import IconGrid from 'vue-material-design-icons/Grid.vue' import IconLinearScale from '../components/Icons/IconLinearScale.vue' import IconPalette from '../components/Icons/IconPalette.vue' import IconRadioboxMarked from 'vue-material-design-icons/RadioboxMarked.vue' import IconTextLong from 'vue-material-design-icons/TextLong.vue' import IconTextShort from 'vue-material-design-icons/TextShort.vue' +import IconNumeric from 'vue-material-design-icons/Numeric.vue' +import IconRadioboxBlank from "vue-material-design-icons/RadioboxBlank.vue"; /** * @typedef {object} AnswerTypes @@ -116,6 +120,52 @@ export default { warningInvalid: t('forms', 'This question needs a title!'), }, + grid: { + component: QuestionGrid, + icon: IconGrid, + label: t('forms', 'Grid'), + // fixme: remove non-needed properties + predefined: false, + + subtypes: { + radio: { + label: t('forms', 'Radio'), + icon: IconRadioboxBlank, + extraSettings: { + questionType: 'radio', + }, + }, + checkbox: { + label: t('forms', 'Checkbox'), + icon: IconCheckboxOutline, + extraSettings: { + questionType: 'checkbox', + }, + }, + number: { + label: t('forms', 'Number'), + icon: IconNumeric, + extraSettings: { + questionType: 'number', + }, + }, + text: { + label: t('forms', 'Text'), + icon: IconTextShort, + extraSettings: { + questionType: 'text', + }, + } + }, + + validate: (question) => question.options.length > 0, + + titlePlaceholder: t('forms', 'Grid question title'), + warningInvalid: t('forms', 'This question needs a title!'), + createPlaceholder: t('forms', 'People can submit a different answer'), + submitPlaceholder: t('forms', 'Enter your answer'), + }, + short: { component: QuestionShort, icon: IconTextShort, diff --git a/src/models/Constants.ts b/src/models/Constants.ts index 684e149fa..dc77a9a73 100644 --- a/src/models/Constants.ts +++ b/src/models/Constants.ts @@ -31,3 +31,9 @@ export const INPUT_DEBOUNCE_MS = 400 * A constant representing the prefix used for identifying "other" answers */ export const QUESTION_EXTRASETTINGS_OTHER_PREFIX = 'system-other-answer:' + +export enum OptionType { + Row = 'row', + Column = 'column', + Choice = 'choice', +} diff --git a/src/models/Entities.d.ts b/src/models/Entities.d.ts index 1a251b1ef..09ead714f 100644 --- a/src/models/Entities.d.ts +++ b/src/models/Entities.d.ts @@ -7,5 +7,6 @@ export interface FormsOption { id: number text: string order?: number - questionId: number + questionId: number, + optionType: string, } diff --git a/src/views/Create.vue b/src/views/Create.vue index 8019e1496..dc11f55ab 100644 --- a/src/views/Create.vue +++ b/src/views/Create.vue @@ -157,6 +157,7 @@ +