From 2ea0955b59409afb3610cd6a533f222409fc45df Mon Sep 17 00:00:00 2001 From: global-prog Date: Wed, 9 Sep 2026 03:33:57 +0300 Subject: [PATCH] feat: copy questions from another form Rebuilding the same question set by hand for each new form is tedious, and the server already knew how to clone a question together with its options - it simply refused to do so across forms. That restriction is lifted for forms the user is allowed to EDIT. Simply dropping the same-form test would not have been safe: any question could then be read out of any form by guessing ids, so the source form is permission-checked. One bug had to be fixed for this to work at all: Question::read() carries the source question's formId, so a clone taken from another form would have been created back in that form rather than in the target one. The target id is now set explicitly. A dialog lists the forms the user can edit, then the questions of the chosen one, with select-all. Copies are made sequentially because each is appended at the end of the form, and issuing them in parallel would give an unpredictable resulting order. A partial failure reports that some questions were copied rather than implying none were. No schema change, and no change to the API surface: the existing fromId parameter and its documented behaviour are unchanged for same-form cloning. Signed-off-by: global-prog --- lib/Controller/ApiController.php | 12 +- src/components/ImportQuestionsDialog.vue | 332 +++++++++++++++++++++++ src/views/Create.vue | 41 +++ 3 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 src/components/ImportQuestionsDialog.vue diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index ebc411fd1..47e7979df 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -570,9 +570,14 @@ public function newQuestion(int $formId, ?string $type = null, ?string $subtype try { $sourceQuestion = $this->questionMapper->findById($fromId); - // Only allow cloning questions that belong to the same form + // A question may be cloned from another form, but only from one the user is + // allowed to edit. Without that check any question could be read out of any + // form by guessing ids. if ($sourceQuestion->getFormId() !== $formId) { - throw new OCSBadRequestException('Question doesn\'t belong to given form'); + $this->formsService->getFormIfAllowed( + $sourceQuestion->getFormId(), + Constants::PERMISSION_EDIT, + ); } $sourceOptions = $this->optionMapper->findByQuestion($fromId); } catch (IMapperException) { @@ -584,6 +589,9 @@ public function newQuestion(int $formId, ?string $type = null, ?string $subtype $questionData = $sourceQuestion->read(); unset($questionData['id']); + // read() carries the source question's formId, so a clone taken from another + // form would otherwise be created back in that form rather than this one. + $questionData['formId'] = $formId; if ($position !== null) { $position = $this->shiftQuestionsForInsert($allQuestions, $position); diff --git a/src/components/ImportQuestionsDialog.vue b/src/components/ImportQuestionsDialog.vue new file mode 100644 index 000000000..c6201eb58 --- /dev/null +++ b/src/components/ImportQuestionsDialog.vue @@ -0,0 +1,332 @@ + + + + + + + diff --git a/src/views/Create.vue b/src/views/Create.vue index 3bdd25f39..5c7d32335 100644 --- a/src/views/Create.vue +++ b/src/views/Create.vue @@ -207,7 +207,21 @@ :hasSubtypes="hasSubtypes" primary @addQuestion="addQuestion" /> + + + {{ t('forms', 'Import questions') }} + + @@ -217,6 +231,7 @@ import type { ComponentPublicInstance, PropType } from 'vue' import type { FormsForm, FormsOption, FormsQuestion } from '../types/Entities.d.ts' +import IconImport from '@material-symbols/svg-400/outlined/library_add.svg?raw' import IconLock from '@material-symbols/svg-400/outlined/lock.svg?raw' import axios from '@nextcloud/axios' import { showError } from '@nextcloud/dialogs' @@ -230,11 +245,13 @@ import debounce from 'debounce' import { computed, defineComponent, nextTick, onMounted, ref, watch } from 'vue' import { VueDraggable as Draggable } from 'vue-draggable-plus' import NcAppContent from '@nextcloud/vue/components/NcAppContent' +import NcButton from '@nextcloud/vue/components/NcButton' import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import AddQuestionMenu from '../components/AddQuestionMenu.vue' +import ImportQuestionsDialog from '../components/ImportQuestionsDialog.vue' import Question from '../components/Questions/Question.vue' import QuestionLong from '../components/Questions/QuestionLong.vue' import QuestionMultiple from '../components/Questions/QuestionMultiple.vue' @@ -260,9 +277,11 @@ export default defineComponent({ name: 'Create', components: { Draggable, + ImportQuestionsDialog, NcIconSvgWrapper, AddQuestionMenu, NcAppContent, + NcButton, NcEmptyContent, NcLoadingIcon, NcNoteCard, @@ -631,6 +650,25 @@ export default defineComponent({ * @param subtype the question subtype, see AnswerTypes.subtypes * @param position where the new question should be added */ + const showImportDialog = ref(false) + + /** + * Append questions copied from another form. + * + * The server has already created them, so this only reflects them in the open + * editor rather than refetching the whole form. + * + * @param created the questions the server returned + */ + const onQuestionsImported = (created: FormsQuestion[]): void => { + const questions = [ + ...props.form.questions, + ...created.map((question) => ({ ...question, answers: [] })), + ] + emit('update:form', { ...props.form, questions }) + emitEvent('forms:last-updated:set', props.form.id) + } + const addQuestion = async ( type: string, subtype: string | null = null, @@ -828,6 +866,9 @@ export default defineComponent({ resizeTitle, resizeDescription, addQuestion, + showImportDialog, + onQuestionsImported, + IconImport, deleteQuestion, insertQuestion, cloneQuestion,