From ee3a3604566b47c30e0c78e3d42f1fc79627a64e 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 | 18 + lib/Controller/ApiController.php | 36 +- lib/Db/Option.php | 12 +- lib/Db/OptionMapper.php | 11 +- .../Version050300Date20250914000000.php | 60 +++ lib/ResponseDefinitions.php | 6 +- lib/Service/FormsService.php | 3 + lib/Service/SubmissionService.php | 117 ++++- openapi.json | 31 +- src/components/Questions/AnswerInput.vue | 70 ++- src/components/Questions/QuestionDropdown.vue | 18 +- src/components/Questions/QuestionGrid.vue | 479 ++++++++++++++++++ src/components/Results/Answer.vue | 103 +++- src/components/Results/ResultsSummary.vue | 212 ++++++++ src/components/Results/Submission.vue | 63 +++ src/mixins/QuestionMixin.js | 3 +- src/mixins/QuestionMultipleMixin.ts | 238 ++++++--- src/models/AnswerTypes.js | 51 ++ src/models/Constants.ts | 12 + src/models/Entities.d.ts | 1 + src/views/Create.vue | 68 ++- tests/Integration/Api/ApiV3Test.php | 4 + tests/Unit/Service/FormsServiceTest.php | 2 + tests/Unit/Service/SubmissionServiceTest.php | 49 ++ 24 files changed, 1536 insertions(+), 131 deletions(-) create mode 100644 lib/Migration/Version050300Date20250914000000.php create mode 100644 src/components/Questions/QuestionGrid.vue diff --git a/lib/Constants.php b/lib/Constants.php index 7b09bda65..3cb470193 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'; @@ -78,6 +79,10 @@ class Constants { public const ANSWER_TYPE_SHORT = 'short'; public const ANSWER_TYPE_TIME = 'time'; + public const ANSWER_GRID_TYPE_CHECKBOX = 'checkbox'; + public const ANSWER_GRID_TYPE_NUMBER = 'number'; + public const ANSWER_GRID_TYPE_RADIO = 'radio'; + // All AnswerTypes public const ANSWER_TYPES = [ self::ANSWER_TYPE_COLOR, @@ -85,6 +90,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 +185,18 @@ class Constants { 'optionsLabelHighest' => ['string', 'NULL'], ]; + public const EXTRA_SETTINGS_GRID = [ + 'columns' => ['array'], + 'questionType' => ['string'], + 'rows' => ['array'], + ]; + + public const EXTRA_SETTINGS_GRID_QUESTION_TYPE = [ + self::ANSWER_GRID_TYPE_CHECKBOX, + self::ANSWER_GRID_TYPE_NUMBER, + self::ANSWER_GRID_TYPE_RADIO, + ]; + public const FILENAME_INVALID_CHARS = [ "\n", '/', diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 6b1282c69..5ee1b3cd5 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -61,6 +61,7 @@ * @psalm-import-type FormsPartialForm from ResponseDefinitions * @psalm-import-type FormsQuestion from ResponseDefinitions * @psalm-import-type FormsQuestionType from ResponseDefinitions + * @psalm-import-type FormsQuestionGridCellType from ResponseDefinitions * @psalm-import-type FormsSubmission from ResponseDefinitions * @psalm-import-type FormsSubmissions from ResponseDefinitions * @psalm-import-type FormsUploadedFile from ResponseDefinitions @@ -445,6 +446,7 @@ public function getQuestion(int $formId, int $questionId): DataResponse { * * @param int $formId the form id * @param FormsQuestionType $type the new question type + * @param FormsQuestionGridCellType $subtype the new question subtype * @param string $text the new question title * @param ?int $fromId (optional) id of the question that should be cloned * @return DataResponse @@ -461,7 +463,7 @@ public function getQuestion(int $formId, int $questionId): DataResponse { #[NoAdminRequired()] #[BruteForceProtection(action: 'form')] #[ApiRoute(verb: 'POST', url: '/api/v3/forms/{formId}/questions')] - public function newQuestion(int $formId, ?string $type = null, string $text = '', ?int $fromId = null): DataResponse { + public function newQuestion(int $formId, ?string $type = null, ?string $subtype = null, string $text = '', ?int $fromId = null): DataResponse { $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_EDIT); $this->formsService->obtainFormLock($form); @@ -505,7 +507,7 @@ public function newQuestion(int $formId, ?string $type = null, string $text = '' $question->setText($text); $question->setDescription(''); $question->setIsRequired(false); - $question->setExtraSettings([]); + $question->setExtraSettings($subtype ? ['questionType' => $subtype] : []); $question = $this->questionMapper->insert($question); @@ -820,6 +822,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 +836,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 +867,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 +882,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); @@ -1034,6 +1039,7 @@ public function deleteOption(int $formId, int $questionId, int $optionId): DataR * @param int $formId id of form * @param int $questionId id of question * @param list $newOrder Array of option ids in new order. + * @param string|null $optionType the new option type (e.g. 'row') * @return DataResponse, array{}> * @throws OCSBadRequestException The given question id doesn't match the form * @throws OCSBadRequestException The given array contains duplicates @@ -1050,7 +1056,7 @@ public function deleteOption(int $formId, int $questionId, int $optionId): DataR #[NoAdminRequired()] #[BruteForceProtection(action: 'form')] #[ApiRoute(verb: 'PATCH', url: '/api/v3/forms/{formId}/questions/{questionId}/options')] - public function reorderOptions(int $formId, int $questionId, array $newOrder) { + public function reorderOptions(int $formId, int $questionId, array $newOrder, ?string $optionType = null): DataResponse { $form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_EDIT); $this->formsService->obtainFormLock($form); @@ -1077,7 +1083,7 @@ public function reorderOptions(int $formId, int $questionId, array $newOrder) { throw new OCSBadRequestException('The given array contains duplicates'); } - $options = $this->optionMapper->findByQuestion($questionId); + $options = $this->optionMapper->findByQuestion($questionId, $optionType); if (sizeof($options) !== sizeof($newOrder)) { $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 = '' * @param string[]|array $answerArray */ private function storeAnswersForQuestion(Form $form, $submissionId, array $question, array $answerArray): void { + if ($question['type'] === Constants::ANSWER_TYPE_GRID) { + if (!$answerArray) { + return; + } + + $answerEntity = new Answer(); + $answerEntity->setSubmissionId($submissionId); + $answerEntity->setQuestionId($question['id']); + + $answerText = json_encode($answerArray); + $answerEntity->setText($answerText); + $this->answerMapper->insert($answerEntity); + + return; + } + foreach ($answerArray as $answer) { $answerEntity = new Answer(); $answerEntity->setSubmissionId($submissionId); diff --git a/lib/Db/Option.php b/lib/Db/Option.php index 481a977a6..c36b04f76 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..2e0140dbe 100644 --- a/lib/Db/OptionMapper.php +++ b/lib/Db/OptionMapper.php @@ -27,16 +27,19 @@ 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)) - ) + ->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'); diff --git a/lib/Migration/Version050300Date20250914000000.php b/lib/Migration/Version050300Date20250914000000.php new file mode 100644 index 000000000..b875b35d2 --- /dev/null +++ b/lib/Migration/Version050300Date20250914000000.php @@ -0,0 +1,60 @@ +getTable('forms_v2_options'); + + if (!$table->hascolumn('option_type')) { + $table->addColumn('option_type', Types::STRING, [ + 'notnull' => false, + 'default' => null, + ]); + } + + return $schema; + } + + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $qbUpdate = $this->db->getQueryBuilder(); + + $qbUpdate->update('forms_v2_options') + ->set('option_type', $qbUpdate->createNamedParameter('choice')) + ->where($qbUpdate->expr()->isNull('option_type')) + ->executeStatement(); + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index f4833aa50..af44f96cb 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -39,10 +39,12 @@ * timeMin?: int, * timeRange?: bool, * validationRegex?: string, - * validationType?: string + * validationType?: string, + * questionType?: 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 FormsQuestionGridCellType = "checkbox"|"number"|"radio" * * @psalm-type FormsQuestion = array{ * id: int, diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index 9a4c2d546..72492d81d 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/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index 7d2b2be7d..6f3d54162 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -15,6 +15,8 @@ use OCA\Forms\Db\AnswerMapper; use OCA\Forms\Db\Form; +use OCA\Forms\Db\Option; +use OCA\Forms\Db\OptionMapper; use OCA\Forms\Db\Question; use OCA\Forms\Db\QuestionMapper; use OCA\Forms\Db\SubmissionMapper; @@ -37,6 +39,7 @@ use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use PhpOffice\PhpSpreadsheet\Writer\Csv; use Psr\Log\LoggerInterface; @@ -63,6 +66,7 @@ public function __construct( private ITempManager $tempManager, private FormsService $formsService, private IUrlGenerator $urlGenerator, + private OptionMapper $optionMapper, ) { $this->currentUser = $userSession->getUser(); } @@ -244,8 +248,42 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file = $header[] = $this->l10n->t('Timestamp'); /** @var array $questionPerQuestionId */ $questionPerQuestionId = []; + /** @var array> $gridRowsPerQuestionId */ + $gridRowsPerQuestionId = []; + /** @var array> $gridColumnsPerQuestionId */ + $gridColumnsPerQuestionId = []; + + $optionPerOptionId = []; foreach ($questions as $question) { - $header[] = $question->getText(); + if ($question->getType() === Constants::ANSWER_TYPE_GRID) { + $gridCellType = $question->getExtraSettings()['questionType']; + $options = $this->optionMapper->findByQuestion($question->getId()); + + foreach ($options as $option) { + $optionPerOptionId[$option->getId()] = $option; + if ($option->getOptionType() === Option::OPTION_TYPE_ROW) { + $gridRowsPerQuestionId[$question->getId()][] = $option->getId(); + } + if ($option->getOptionType() === Option::OPTION_TYPE_COLUMN) { + $gridColumnsPerQuestionId[$question->getId()][] = $option->getId(); + } + } + + foreach ($gridRowsPerQuestionId[$question->getId()] as $rowId) { + if ($gridCellType === Constants::ANSWER_GRID_TYPE_CHECKBOX || $gridCellType === Constants::ANSWER_GRID_TYPE_RADIO) { + $header[] = $question->getText() . ' (' . $optionPerOptionId[$rowId]->getText() . ')'; + } + + if ($gridCellType === Constants::ANSWER_GRID_TYPE_NUMBER) { + foreach ($gridColumnsPerQuestionId[$question->getId()] as $columnId) { + $header[] = $question->getText() . ' (' . $optionPerOptionId[$rowId]->getText() . ' - ' . $optionPerOptionId[$columnId]->getText() . ')'; + } + } + } + } else { + $header[] = $question->getText(); + } + $questionPerQuestionId[$question->getId()] = $question; } @@ -273,11 +311,11 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file = // Answers, make sure we keep the question order $answers = array_reduce($this->answerMapper->findBySubmission($submission->getId()), - function (array $carry, Answer $answer) use ($questionPerQuestionId) { + function (array $carry, Answer $answer) use ($questionPerQuestionId, $gridRowsPerQuestionId, $gridColumnsPerQuestionId, $optionPerOptionId) { $questionId = $answer->getQuestionId(); + $questionType = isset($questionPerQuestionId[$questionId]) ? $questionPerQuestionId[$questionId]->getType() : null; - if (isset($questionPerQuestionId[$questionId]) - && $questionPerQuestionId[$questionId]->getType() === Constants::ANSWER_TYPE_FILE) { + if ($questionType === Constants::ANSWER_TYPE_FILE) { if (array_key_exists($questionId, $carry)) { $carry[$questionId]['label'] .= "; \n" . $answer->getText(); } else { @@ -286,6 +324,36 @@ function (array $carry, Answer $answer) use ($questionPerQuestionId) { 'url' => $this->urlGenerator->linkToRouteAbsolute('files.View.showFile', ['fileid' => $answer->getFileId()]) ]; } + } elseif ($questionType === Constants::ANSWER_TYPE_GRID) { + $gridCellType = isset($questionPerQuestionId[$questionId]) ? $questionPerQuestionId[$questionId]->getExtraSettings()['questionType'] : null; + $answerText = json_decode($answer->getText(), true); + $columns = []; + foreach ($gridRowsPerQuestionId[$questionId] as $row) { + if (empty($answerText[$row])) { + $columns[] = ''; + continue; + } + + if ($gridCellType === Constants::ANSWER_GRID_TYPE_RADIO) { + $columns[] = $optionPerOptionId[$answerText[$row]]->getText(); + } elseif ($gridCellType === Constants::ANSWER_GRID_TYPE_CHECKBOX) { + $columns[] = implode('; ', array_map(function ($optionId) use ($optionPerOptionId) { + ; + return $optionPerOptionId[$optionId]->getText(); + }, $answerText[$row])); + } elseif ($gridCellType === Constants::ANSWER_GRID_TYPE_NUMBER) { + // For number grids, we need to create a header for each cell in the grid + foreach ($gridColumnsPerQuestionId[$questionId] as $column) { + if (empty($answerText[$row][$column])) { + $columns[] = ''; + continue; + } + + $columns[] = $answerText[$row][$column]; + } + } + } + $carry[$questionId] = ['columns' => $columns]; } else { if (array_key_exists($questionId, $carry)) { $carry[$questionId] .= '; ' . $answer->getText(); @@ -309,7 +377,7 @@ function (array $carry, Answer $answer) use ($questionPerQuestionId) { /** * @param array $header - * @param array|non-empty-list> $data + * @param list, label?: string, url?: string}|mixed|null|string>> $data */ private function exportData(array $header, array $data, string $fileFormat, ?File $file = null): string { if ($file && $file->getContent()) { @@ -325,11 +393,11 @@ private function exportData(array $header, array $data, string $fileFormat, ?Fil $activeWorksheet->setCellValue([$columnIndex + 1, 1], $value); } foreach ($data as $rowIndex => $rowData) { - foreach ($rowData as $columnIndex => $value) { - $column = $columnIndex + 1; + $column = 1; + foreach ($rowData as $value) { $row = $rowIndex + 2; - if (is_array($value)) { + if (is_array($value) && isset($value['label'])) { // file question type $activeWorksheet->getCell([$column, $row]) ->setValueExplicit($value['label']) ->getHyperlink() @@ -338,18 +406,17 @@ private function exportData(array $header, array $data, string $fileFormat, ?Fil $activeWorksheet->getStyle([$column, $row]) ->getAlignment() ->setWrapText(true); - } else { - // Explicitly set the type of the value to string for values that start with '=' to prevent it being interpreted as formulas - if (is_string($value)) { - $activeWorksheet->getCell([$column, $row]) - ->setValueExplicit($fileFormat === 'csv' - ? $this->escapeCSV($value) - : $value, - ); - } else { - $activeWorksheet->setCellValue([$column, $row], $value); + } elseif (is_array($value) && isset($value['columns'])) { // grid question type + foreach ($value['columns'] as $nestedValue) { + $this->setCellValue($activeWorksheet, $column, $row, $nestedValue, $fileFormat); + $column++; } + continue; // no need to increment the column one more time + } else { + $this->setCellValue($activeWorksheet, $column, $row, $value, $fileFormat); } + + $column++; } } @@ -432,6 +499,7 @@ public function validateSubmission(array $questions, array $answers, string $for throw new \InvalidArgumentException(sprintf('Question "%s" can only have two answers.', $question['text'])); } elseif ($answersCount > 1 && $question['type'] !== Constants::ANSWER_TYPE_FILE + && $question['type'] !== Constants::ANSWER_TYPE_GRID && !($question['type'] === Constants::ANSWER_TYPE_DATE && isset($question['extraSettings']['dateRange']) || $question['type'] === Constants::ANSWER_TYPE_TIME && isset($question['extraSettings']['timeRange']))) { // Check if non-multiple questions have not more than one answer @@ -576,4 +644,17 @@ private function validateShortQuestion(array $question, string $data): bool { return false; } } + + private function setCellValue(Worksheet $activeWorksheet, int $column, int $row, mixed $value, string $fileFormat): void { + // Explicitly set the type of the value to string for values that start with '=' to prevent it being interpreted as formulas + if (is_string($value)) { + $activeWorksheet->getCell([$column, $row]) + ->setValueExplicit($fileFormat === 'csv' + ? $this->escapeCSV($value) + : $value, + ); + } else { + $activeWorksheet->setCellValue([$column, $row], $value); + } + } } diff --git a/openapi.json b/openapi.json index 772186baa..538458398 100644 --- a/openapi.json +++ b/openapi.json @@ -522,9 +522,20 @@ }, "validationType": { "type": "string" + }, + "questionType": { + "type": "string" } } }, + "QuestionGridCellType": { + "type": "string", + "enum": [ + "checkbox", + "number", + "radio" + ] + }, "QuestionType": { "type": "string", "enum": [ @@ -536,7 +547,8 @@ "short", "long", "file", - "datetime" + "datetime", + "grid" ] }, "Share": { @@ -1610,6 +1622,11 @@ "default": null, "description": "the new question type" }, + "subtype": { + "$ref": "#/components/schemas/QuestionGridCellType", + "default": null, + "description": "the new question subtype" + }, "text": { "type": "string", "default": "", @@ -2622,6 +2639,12 @@ "items": { "type": "string" } + }, + "optionType": { + "type": "string", + "nullable": true, + "default": null, + "description": "the new option type (e.g. 'row')" } } } @@ -2837,6 +2860,12 @@ "type": "integer", "format": "int64" } + }, + "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 9e600a509..240ad226e 100644 --- a/src/components/Questions/AnswerInput.vue +++ b/src/components/Questions/AnswerInput.vue @@ -5,7 +5,7 @@