From c8d06fae923e69ff015124975813fdc0ba924a08 Mon Sep 17 00:00:00 2001
From: Jan-Hendrik Willms <tleilax+studip@gmail.com>
Date: Thu, 4 Jul 2024 15:23:16 +0000
Subject: [PATCH] refactor questionnaire editor to sfc, fixes #4303

Closes #4303

Merge request studip/studip!3155
---
 app/views/questionnaire/edit.php              | 195 ++--------
 lib/models/Freetext.php                       |   2 +-
 lib/models/LikertScale.php                    |   2 +-
 lib/models/QuestionnaireInfo.php              |   2 +-
 lib/models/RangeScale.php                     |   2 +-
 lib/models/Vote.php                           |   2 +-
 .../assets/javascripts/bootstrap/dialog.js    |   5 +
 .../javascripts/bootstrap/questionnaire.js    |   5 -
 resources/assets/javascripts/bootstrap/vue.js |  24 +-
 resources/assets/javascripts/lib/dialog.js    |   5 +-
 .../assets/javascripts/lib/questionnaire.js   | 204 +---------
 resources/assets/stylesheets/scss/forms.scss  |  36 +-
 .../questionnaires/QuestionnaireEditor.vue    | 354 ++++++++++++++++++
 13 files changed, 440 insertions(+), 398 deletions(-)
 create mode 100644 resources/vue/components/questionnaires/QuestionnaireEditor.vue

diff --git a/app/views/questionnaire/edit.php b/app/views/questionnaire/edit.php
index c10857b8f2a..e5ef007b62e 100644
--- a/app/views/questionnaire/edit.php
+++ b/app/views/questionnaire/edit.php
@@ -22,176 +22,31 @@ foreach (get_declared_classes() as $class) {
         ];
     }
 }
+
 $questionnaire_data = [
-    'id' => $questionnaire->id,
-    'title' => $questionnaire['title'],
-    'startdate' => $questionnaire->isNew() ? _('sofort') : $questionnaire['startdate'],
-    'stopdate' => $questionnaire['stopdate'],
-    'copyable' => $questionnaire['copyable'],
-    'anonymous' => $questionnaire['anonymous'],
-    'editanswers' => $questionnaire['editanswers'],
-    'resultvisibility' => $questionnaire['resultvisibility'],
+    'anonymous'        => $questionnaire->anonymous,
+    'copyable'         => $questionnaire->copyable,
+    'editanswers'      => $questionnaire->editanswers,
+    'id'               => $questionnaire->id,
+    'questions'        => $questionnaire->questions->map(function ($question) {
+        return [
+            'id'            => $question->id,
+            'questiontype'  => $question->questiontype,
+            'internal_name' => $question->internal_name,
+            'questiondata'  => $question->questiondata->getArrayCopy(),
+        ];
+    }),
+    'resultvisibility' => $questionnaire->resultvisibility,
+    'startdate'        => $questionnaire->isNew() ? _('sofort') : $questionnaire->startdate,
+    'stopdate'         => $questionnaire->stopdate,
+    'title'            => $questionnaire->title,
 ];
-$questions_data = [];
-foreach ($questionnaire->questions as $question) {
-    $questions_data[] = [
-        'id' => $question->id,
-        'questiontype' => $question['questiontype'],
-        'internal_name' => $question['internal_name'],
-        'questiondata' => $question['questiondata']->getArrayCopy()
-    ];
-}
 ?>
-<form action="<?= URLHelper::getLink('dispatch.php/questionnaire/edit/' . (!$questionnaire->isNew() ? $questionnaire->getId() : '')) ?>"
-      method="post"
-      enctype="multipart/form-data"
-      class="questionnaire_edit default"
-      data-questiontypes="<?= htmlReady(json_encode($questiontypes)) ?>"
-      data-questionnaire_data="<?= htmlReady(json_encode($questionnaire_data)) ?>"
-      data-questions_data="<?= htmlReady(json_encode($questions_data)) ?>"
-      data-range_type="<?= htmlReady(Request::get('range_type')) ?>"
-      data-range_id="<?= htmlReady(Request::get('range_id')) ?>"
-      <?= Request::isAjax() ? 'data-dialog' : '' ?>
-      :data-secure="activateFormSecure">
-
-    <div class="editor">
-        <div class="rightside" aria-live="polite" tabindex="0" ref="rightside">
-            <div class="admin" v-if="activeTab === 'admin'">
-
-                <article aria-live="assertive" class="validation_notes studip">
-                    <header>
-                        <h1>
-                            <?= Icon::create('info-circle', Icon::ROLE_INFO)->asImg(['class' => 'text-bottom validation_notes_icon']) ?>
-                            <?= _('Hinweise zum Ausfüllen des Formulars') ?>
-                        </h1>
-                    </header>
-                    <div class="required_note">
-                        <div aria-hidden="true">
-                            <?= _('Pflichtfelder sind mit Sternchen gekennzeichnet.') ?>
-                        </div>
-                        <div class="sr-only">
-                            <?= _('Dieses Formular enthält Pflichtfelder.') ?>
-                        </div>
-                    </div>
-                    <div v-if="validationNotice && !data.title">
-                        <?= _('Folgende Angaben müssen korrigiert werden, um das Formular abschicken zu können:') ?>
-                        <ul>
-                            <li aria-describedby="questionnaire_title"><?= _('Titel des Fragebogens') ?></li>
-                        </ul>
-                    </div>
-                </article>
-
-                <div class="formpart">
-                    <label class="studiprequired" for="questionnaire_title">
-                        <span class="textlabel"><?= _('Titel des Fragebogens') ?></span>
-                        <span title="Dies ist ein Pflichtfeld" aria-hidden="true" class="asterisk">*</span>
-                    </label>
-                    <input type="text" id="questionnaire_title" v-model="data.title" ref="autofocus">
-                </div>
-
-                <div class="hgroup">
-                    <label>
-                        <?= _('Startzeitpunkt') ?>
-                        <datetimepicker v-model="data.startdate"></datetimepicker>
-                    </label>
-                    <label>
-                        <?= _('Endzeitpunkt') ?>
-                        <datetimepicker v-model="data.stopdate"></datetimepicker>
-                    </label>
-                </div>
-                <label>
-                    <input type="checkbox" v-model="data.copyable" true-value="1" false-value="0">
-                    <?= _('Fragebogen zum Kopieren freigeben') ?>
-                </label>
-                <label>
-                    <input type="checkbox" v-model="data.anonymous" true-value="1" false-value="0">
-                    <?= _('Teilnehmende anonymisieren') ?>
-                </label>
-                <label>
-                    <input type="checkbox" v-model="data.editanswers" true-value="1" false-value="0">
-                    <?= _('Teilnehmende dürfen ihre Antworten revidieren') ?>
-                </label>
-                <label>
-                    <?= _('Ergebnisse einsehbar') ?>
-                    <select v-model="data.resultvisibility">
-                        <option value="always"><?= _('Immer') ?></option>
-                        <option value="afterending"><?= _('Nach Ende der Befragung') ?></option>
-                        <option value="afterparticipation"><?= _('Nach der Teilnahme') ?></option>
-                        <option value="never"><?= _('Niemals') ?></option>
-                    </select>
-                </label>
-            </div>
-            <div class="add_question file_select_possibilities" v-else-if="activeTab === 'add_question'">
-                <div>
-                    <button v-for="(questiontype, key) in questiontypes" :key="key"
-                       :ref="key == Object.keys(questiontypes)[0] ? 'autofocus' : ''"
-                       href=""
-                       @click.prevent="addQuestion(questiontype.type)">
-                        <studip-icon :shape="questiontype.icon" :size="40"></studip-icon>
-                        {{questiontype.name}}
-                    </button>
-                </div>
-            </div>
-            <div v-else>
-                <component :is="questiontypes[questions[indexForQuestion].questiontype].component[0]"
-                           v-model="questions[indexForQuestion].questiondata"
-                           :question_id="questions[indexForQuestion].id"
-                           :key="questions[indexForQuestion].id">
-                </component>
-            </div>
-        </div>
-        <aside>
-            <a class="admin"
-               :class="{active: activeTab === 'admin'}"
-               href="#"
-               @click.prevent="switchTab('admin')">
-                <span class="icon"><studip-icon shape="evaluation" :size="30" alt=""></studip-icon></span>
-                <?= _('Einstellungen') ?>
-            </a>
-            <draggable v-if="questions.length > 0" v-model="questions" handle=".drag-handle" group="questions" class="questions_container questions">
-                <div v-for="question in questions"
-                     :key="question.id"
-                     @mouseenter="hoverTab = question.id"
-                     @mouseleave="hoverTab = null"
-                     :class="(activeTab === question.id || activeTab === 'meta_' + question.id ? 'active' : '') + (hoverTab === question.id ? ' hovered' : '')">
-                    <a href="#"
-                       @click.prevent="switchTab(question.id)">
-                        <span class="drag-handle"></span>
-                        <span class="icon type">
-                            <studip-icon :shape="questiontypes[question.questiontype].icon" :size="30" alt=""></studip-icon>
-                        </span>
-
-                        <div v-if="editInternalName !== question.id">{{ question.internal_name || questiontypes[question.questiontype].name}}</div>
-                        <div v-else class="inline_editing">
-                            <input type="text" ref="editInternalName" v-model="tempInternalName" class="inlineediting_internal_name">
-                            <button @click="saveInternalName(question.id)">
-                                <studip-icon shape="accept" :size="20" title="<?= _('Internen Namen speichern') ?>"></studip-icon>
-                            </button>
-                            <button @click="editInternalName = null">
-                                <studip-icon shape="decline" :size="20" title="<?= _('Internen Namen nicht speichern') ?>"></studip-icon>
-                            </button>
-                        </div>
-                    </a>
-
-                    <studip-action-menu :items="[{label: '<?= _('Umbenennen') ?>', icon: 'edit', emit: 'rename'}, {label: '<?= _('Frage kopieren') ?>', icon: 'copy', emit: 'copy'}, {label: '<?= _('Frage nach oben verschieben') ?>', icon: 'arr_1up', emit: 'moveup'}, {label: '<?= _('Frage nach unten verschieben') ?>', icon: 'arr_1down', emit: 'movedown'}, {label: '<?= _('Frage löschen') ?>', icon: 'trash', emit: 'delete'}]"
-                                        @copy="duplicateQuestion(question.id)"
-                                        @rename="renameInternalName(question.id)"
-                                        @moveup="moveQuestionUp(question.id)"
-                                        @movedown="moveQuestionDown(question.id)"
-                                        @delete="deleteQuestion(question.id)"></studip-action-menu>
-                </div>
-            </draggable>
-            <a :class="activeTab === 'add_question' ? 'add_question active' : 'add_question'"
-               href="#"
-               @click.prevent="switchTab('add_question')">
-                <span class="icon"><studip-icon shape="add" :size="30" alt=""></studip-icon></span>
-                <?= _('Element hinzufügen') ?>
-            </a>
-        </aside>
-    </div>
-
-
-    <footer data-dialog-button>
-        <?= Studip\LinkButton::create(_('Speichern'), 'questionnaire_store', ['onclick' => 'STUDIP.Questionnaire.Editor.submit(); return false;']) ?>
-    </footer>
-</form>
+<?= Studip\VueApp::create('questionnaires/QuestionnaireEditor')
+        ->withProps([
+            'as-dialog'      => Request::isAjax(),
+            'question-data'  => $questionnaire_data,
+            'question-types' => $questiontypes,
+            'range-id'       => Request::get('range_id'),
+            'range-type'     => Request::get('range_type'),
+        ]) ?>
diff --git a/lib/models/Freetext.php b/lib/models/Freetext.php
index f241b5a9dfd..3e59512bd29 100644
--- a/lib/models/Freetext.php
+++ b/lib/models/Freetext.php
@@ -52,7 +52,7 @@ class Freetext extends QuestionnaireQuestion implements QuestionType
 
     static public function getEditingComponent()
     {
-        return ['freetext-edit', ''];
+        return ['FreetextEdit', ''];
     }
 
     public function beforeStoringQuestiondata($questiondata)
diff --git a/lib/models/LikertScale.php b/lib/models/LikertScale.php
index 3a055f30e31..a4c0eb81ccc 100644
--- a/lib/models/LikertScale.php
+++ b/lib/models/LikertScale.php
@@ -37,7 +37,7 @@ class LikertScale extends QuestionnaireQuestion implements QuestionType
 
     static public function getEditingComponent()
     {
-        return ['likert-edit', ''];
+        return ['LikertEdit', ''];
     }
 
     public function beforeStoringQuestiondata($questiondata)
diff --git a/lib/models/QuestionnaireInfo.php b/lib/models/QuestionnaireInfo.php
index 2bcf25d6ff6..e820ceede22 100644
--- a/lib/models/QuestionnaireInfo.php
+++ b/lib/models/QuestionnaireInfo.php
@@ -37,7 +37,7 @@ class QuestionnaireInfo extends QuestionnaireQuestion implements QuestionType
 
     static public function getEditingComponent()
     {
-        return ['questionnaire-info-edit', ''];
+        return ['QuestionnaireInfoEdit', ''];
     }
 
     public function beforeStoringQuestiondata($questiondata)
diff --git a/lib/models/RangeScale.php b/lib/models/RangeScale.php
index 20e134a490c..66ea27da5d4 100644
--- a/lib/models/RangeScale.php
+++ b/lib/models/RangeScale.php
@@ -46,7 +46,7 @@ class RangeScale extends QuestionnaireQuestion implements QuestionType
 
     static public function getEditingComponent()
     {
-        return ['rangescale-edit', ''];
+        return ['RangescaleEdit', ''];
     }
 
     public function getDisplayTemplate()
diff --git a/lib/models/Vote.php b/lib/models/Vote.php
index b5cd1428e70..21b3359c211 100644
--- a/lib/models/Vote.php
+++ b/lib/models/Vote.php
@@ -37,7 +37,7 @@ class Vote extends QuestionnaireQuestion implements QuestionType
 
     static public function getEditingComponent()
     {
-        return ['vote-edit', ''];
+        return ['VoteEdit', ''];
     }
 
     public function beforeStoringQuestiondata($questiondata)
diff --git a/resources/assets/javascripts/bootstrap/dialog.js b/resources/assets/javascripts/bootstrap/dialog.js
index f186307248f..58d01fd42a7 100644
--- a/resources/assets/javascripts/bootstrap/dialog.js
+++ b/resources/assets/javascripts/bootstrap/dialog.js
@@ -1,3 +1,8 @@
 STUDIP.domReady(function () {
     STUDIP.Dialog.initialize();
 });
+
+$(document).on('click', '[data-vue-app] [data-dialog-button] .cancel.button', () => {
+    STUDIP.Dialog.close();
+    return false;
+});
diff --git a/resources/assets/javascripts/bootstrap/questionnaire.js b/resources/assets/javascripts/bootstrap/questionnaire.js
index 4970b64293f..3b3176462c8 100644
--- a/resources/assets/javascripts/bootstrap/questionnaire.js
+++ b/resources/assets/javascripts/bootstrap/questionnaire.js
@@ -1,8 +1,3 @@
-import {dialogReady, ready} from "../lib/ready";
-STUDIP.ready(() => {
-    STUDIP.Questionnaire.initEditor();
-});
-
 jQuery(document).on('change', '.show_validation_hints .questionnaire_answer [data-question_type=Vote] input', function() {
     STUDIP.Questionnaire.Vote.validator.call($(this).closest("article")[0]);
 });
diff --git a/resources/assets/javascripts/bootstrap/vue.js b/resources/assets/javascripts/bootstrap/vue.js
index 64d2492acdf..637241aa009 100644
--- a/resources/assets/javascripts/bootstrap/vue.js
+++ b/resources/assets/javascripts/bootstrap/vue.js
@@ -11,7 +11,29 @@ STUDIP.ready(() => {
         let components = {};
         config.components.forEach(component => {
             const name = component.split('/').reverse()[0];
-            components[name] = () => import(`../../../vue/components/${component}.vue`);
+            components[name] = () => {
+                // TODO: I wonder if this works with Vue3
+
+                const temp = import(`../../../vue/components/${component}.vue`);
+                temp.then(({default: c}) => {
+                    const mounted = c.mounted ?? null;
+                    c.mounted = function (...args) {
+                        if (
+                            this.$el instanceof Element
+                            && this.$el.querySelector('[data-dialog-button]')
+                        ) {
+                            this.$el.closest('.studip-dialog')
+                                .querySelector('.ui-dialog-buttonpane')
+                                .remove();
+                        }
+                        if (mounted) {
+                            mounted.call(this, args);
+                        }
+                    };
+                    return c;
+                })
+                return temp;
+            };
         });
 
         STUDIP.Vue.load().then(async ({createApp, store}) => {
diff --git a/resources/assets/javascripts/lib/dialog.js b/resources/assets/javascripts/lib/dialog.js
index c602a29f849..46f9d981d2c 100644
--- a/resources/assets/javascripts/lib/dialog.js
+++ b/resources/assets/javascripts/lib/dialog.js
@@ -420,7 +420,10 @@ Dialog.show = function(content, options = {}) {
     });
 
     // Create buttons
-    if (options.buttons === undefined || (options.buttons && !$.isPlainObject(options.buttons))) {
+    if (
+        options.buttons === undefined
+        || (options.buttons && !$.isPlainObject(options.buttons))
+    ) {
         dialog_options.buttons = extractButtons.call(this, instance.element);
         // Create 'close' button
         if (dialog_options.buttons.cancel === undefined) {
diff --git a/resources/assets/javascripts/lib/questionnaire.js b/resources/assets/javascripts/lib/questionnaire.js
index 9a89348b4b6..5251617e888 100644
--- a/resources/assets/javascripts/lib/questionnaire.js
+++ b/resources/assets/javascripts/lib/questionnaire.js
@@ -1,209 +1,7 @@
-import { $gettext } from '../lib/gettext';
-import md5 from 'md5';
-//import html2canvas from "html2canvas";
-//import {jsPDF} from "jspdf";
+import { $gettext } from './gettext';
 
 const Questionnaire = {
     delayedQueue: [],
-    Editor: null,
-    initEditor () {
-        $('.questionnaire_edit:not(.vueified)').addClass('vueified').each(function () {
-            STUDIP.Vue.load().then(({createApp}) => {
-                let form = this;
-                let components = {};
-                let questiontypes = $(form).data('questiontypes');
-                for (let i in questiontypes) {
-                    if (questiontypes[i].component[0] && questiontypes[i].component[1]) {
-                        //for plugins to be able to import their vue components:
-                        components[questiontypes[i].component[0]] = () => import(/* webpackIgnore: true */ questiontypes[i].component[1]);
-                    }
-                }
-                components.draggable = () => import('vuedraggable');
-                components['vote-edit'] = () => import('../../../vue/components/questionnaires/VoteEdit.vue');
-                components['freetext-edit'] = () => import('../../../vue/components/questionnaires/FreetextEdit.vue');
-                components['likert-edit'] = () => import('../../../vue/components/questionnaires/LikertEdit.vue');
-                components['rangescale-edit'] = () => import('../../../vue/components/questionnaires/RangescaleEdit.vue');
-                components['questionnaire-info-edit'] = () => import('../../../vue/components/questionnaires/QuestionnaireInfoEdit.vue');
-                STUDIP.Questionnaire.Editor = createApp({
-                    el: form,
-                    components,
-                    data() {
-                        return {
-                            questiontypes,
-
-                            questions: $(form).data('questions_data'),
-                            activeTab: 'admin',
-                            hoverTab: null,
-                            data: $(form).data('questionnaire_data'),
-                            form_secured: true,
-                            oldData: {
-                                questions: [],
-                                data: {}
-                            },
-                            range_type: $(form).data('range_type'),
-                            range_id: $(form).data('range_id'),
-                            editInternalName: null,
-                            tempInternalName: '',
-                            validationNotice: false,
-                        };
-                    },
-                    methods: {
-                        addQuestion(questiontype) {
-                            let id = md5(STUDIP.USER_ID + '_QUESTIONTYPE_' + Math.random());
-
-                            this.questions.push({
-                                id: id,
-                                questiontype: questiontype,
-                                internal_name: '',
-                                questiondata: {},
-                            });
-
-                            this.activeTab = id;
-                        },
-                        submit() {
-                            if (!this.data.title) {
-                                this.switchTab('admin');
-                                this.validationNotice = true;
-                                return;
-                            }
-                            let data = {
-                                title: this.data.title,
-                                copyable: this.data.copyable,
-                                anonymous: this.data.anonymous,
-                                editanswers: this.data.editanswers,
-                                startdate: this.data.startdate,
-                                stopdate: this.data.stopdate,
-                                resultvisibility: this.data.resultvisibility
-                            };
-                            let questions = [];
-                            for (let i in this.questions) {
-                                questions.push({
-                                    id: this.questions[i].id,
-                                    questiontype: this.questions[i].questiontype,
-                                    internal_name: this.questions[i].internal_name,
-                                    questiondata: Object.assign({}, this.questions[i].questiondata),
-                                });
-                            }
-                            $.post(STUDIP.URLHelper.getURL('dispatch.php/questionnaire/store/' + (this.data.id || '')), {
-                                questionnaire: data,
-                                questions_data: JSON.stringify(questions),
-                                range_type: this.range_type,
-                                range_id: this.range_id
-                            }).done(() => {
-                                this.form_secured = false;
-                                this.$nextTick(() => {
-                                    location.reload();
-                                });
-                            }).fail(() => {
-                                STUDIP.Report.error('Could not save questionnaire.');
-                            });
-                        },
-                        getIndexForQuestion: function (question_id) {
-                            for (let i in this.questions) {
-                                if (this.questions[i].id === question_id || this.questions[i].id === question_id.substring(5)) {
-                                    return typeof i === "string" ? parseInt(i, 10) : i;
-                                }
-                            }
-                        },
-                        duplicateQuestion: function (question_id) {
-                            let i = this.getIndexForQuestion(question_id);
-                            let id = md5(STUDIP.USER_ID + '_QUESTIONTYPE_' + Math.random());
-                            this.questions.push({
-                                id: id,
-                                questiontype: this.questions[i].questiontype,
-                                internal_name: this.questions[i].internal_name,
-                                questiondata: JSON.parse(JSON.stringify(this.questions[i].questiondata)),
-                            });
-                            this.activeTab = id;
-                        },
-                        deleteQuestion(question_id) {
-                            STUDIP.Dialog.confirm(this.$gettext('Wirklich löschen?')).done(() => {
-                                this.$delete(this.questions, this.getIndexForQuestion(question_id));
-                                this.switchTab('add_question');
-                            })
-                        },
-                        switchTab(tab_id) {
-                            this.activeTab = tab_id;
-                            this.$nextTick(function () {
-                                if (this.$refs.autofocus !== undefined) {
-                                    if (Array.isArray(this.$refs.autofocus)) {
-                                        if (typeof this.$refs.autofocus[0] !== "undefined") {
-                                            this.$refs.autofocus[0].focus();
-                                        }
-                                    } else {
-                                        this.$refs.autofocus.focus();
-                                    }
-                                }
-                            });
-                        },
-                        objectsEqual(obj1, obj2) {
-                            return _.isEqual(obj1, obj2);
-                        },
-                        renameInternalName(question_id) {
-                            this.editInternalName = question_id;
-                            let index = this.getIndexForQuestion(question_id);
-                            this.tempInternalName = this.questions[index].internal_name;
-                            this.$nextTick(() => {
-                                this.$refs.editInternalName[0].focus();
-                            });
-                        },
-                        saveInternalName(question_id) {
-                            let index = this.getIndexForQuestion(question_id);
-                            this.questions[index].internal_name = this.tempInternalName;
-                            this.editInternalName = null;
-                        },
-                        moveQuestionDown(question_id) {
-                            let index = this.getIndexForQuestion(question_id);
-                            if (index < this.questions.length - 1) {
-                                let question = this.questions[index];
-                                this.questions[index] = this.questions[index + 1];
-                                this.questions[index + 1] = question;
-                                this.$forceUpdate();
-                            }
-                        },
-                        moveQuestionUp(question_id) {
-                            let index = this.getIndexForQuestion(question_id);
-                            if (index > 0) {
-                                let question = this.questions[index];
-                                this.questions[index] = this.questions[index - 1];
-                                this.questions[index - 1] = question;
-                                this.$forceUpdate();
-                            }
-                        }
-                    },
-                    computed: {
-                        activateFormSecure() {
-                            let newData = {
-                                questions: this.questions,
-                                data: this.data
-                            };
-                            return this.form_secured && !this.objectsEqual(this.oldData, newData);
-                        },
-                        indexForQuestion() {
-                            for (let i in this.questions) {
-                                if (
-                                    this.questions[i].id === this.activeTab ||
-                                    this.questions[i].id === this.activeTab.substring(5)
-                                ) {
-                                    return typeof i === "string" ? parseInt(i, 10) : i;
-                                }
-                            }
-
-                            return null;
-                        },
-                    },
-                    mounted() {
-                        this.$refs.autofocus.focus();
-                        this.oldData = {
-                            questions: [...this.questions],
-                            data: Object.assign({}, this.data)
-                        };
-                    },
-                });
-
-            });
-        });
-    },
     delayedInterval: null,
     lastUpdate: null,
     filtered: {},
diff --git a/resources/assets/stylesheets/scss/forms.scss b/resources/assets/stylesheets/scss/forms.scss
index 888cf56b9ce..2aa105fad4c 100644
--- a/resources/assets/stylesheets/scss/forms.scss
+++ b/resources/assets/stylesheets/scss/forms.scss
@@ -622,23 +622,33 @@ form.inline {
 }
 
 .studip-dialog {
-    form[data-vue-app] {
-        display: flex;
-        flex-direction: column;
+    [data-vue-app] {
         min-height: 100%;
+        display: flex;
 
-        fieldset {
-            flex: 0;
+        > * {
+            flex: 1;
         }
 
-        footer[data-dialog-button] {
-            background: var(--white);
-            border-top-color: var(--base-color-20);
-            bottom: -0.5em;
-            margin-top: auto;
-            padding: 1.3em 0;
-            position: sticky;
-            text-align: center;
+        form {
+            display: flex;
+            flex-direction: column;
+            min-height: 100%;
+
+            > :not(footer[data-dialog-button]) {
+                flex: 0;
+                margin-bottom: auto;
+            }
+
+            footer[data-dialog-button] {
+                background: var(--white);
+                border-top-color: var(--base-color-20);
+                bottom: -0.5em;
+                margin-top: auto;
+                padding: 1.3em 0;
+                position: sticky;
+                text-align: center;
+            }
         }
     }
 }
diff --git a/resources/vue/components/questionnaires/QuestionnaireEditor.vue b/resources/vue/components/questionnaires/QuestionnaireEditor.vue
new file mode 100644
index 00000000000..d87305a2b7a
--- /dev/null
+++ b/resources/vue/components/questionnaires/QuestionnaireEditor.vue
@@ -0,0 +1,354 @@
+<template>
+    <form action="#"
+          method="post"
+          enctype="multipart/form-data"
+          class="questionnaire_edit default"
+          @submit.prevent="submit()"
+          :data-dialog="asDialog ? true : null"
+          :data-secure="activateFormSecure"
+    >
+        <div class="editor">
+            <div class="rightside" aria-live="polite" tabindex="0" ref="rightside">
+                <div class="admin" v-if="activeTab === 'admin'">
+
+                    <article aria-live="assertive" class="validation_notes studip">
+                        <header>
+                            <h1>
+                                <studip-icon shape="info-circle" role="info" class="text-bottom validation_notes_icon"></studip-icon>
+                                {{ $gettext('Hinweise zum Ausfüllen des Formulars') }}
+                            </h1>
+                        </header>
+                        <div class="required_note">
+                            <div aria-hidden="true">
+                                {{ $gettext('Pflichtfelder sind mit Sternchen gekennzeichnet.') }}
+                            </div>
+                            <div class="sr-only">
+                                {{ $gettext('Dieses Formular enthält Pflichtfelder.') }}
+                            </div>
+                        </div>
+                        <div v-if="validationNotice && !data.title">
+                            {{ $gettext('Folgende Angaben müssen korrigiert werden, um das Formular abschicken zu können:') }}
+                            <ul>
+                                <li aria-describedby="questionnaire_title">{{ $gettext('Titel des Fragebogens') }}</li>
+                            </ul>
+                        </div>
+                    </article>
+
+                    <div class="formpart">
+                        <label class="studiprequired" for="questionnaire_title">
+                            <span class="textlabel">{{ $gettext('Titel des Fragebogens') }}</span>
+                            <span title="Dies ist ein Pflichtfeld" aria-hidden="true" class="asterisk">*</span>
+                        </label>
+                        <input type="text" id="questionnaire_title" v-model="data.title" ref="autofocus">
+                    </div>
+
+                    <div class="hgroup">
+                        <label>
+                            {{ $gettext('Startzeitpunkt') }}
+                            <datetimepicker v-model="data.startdate"></datetimepicker>
+                        </label>
+                        <label>
+                            {{ $gettext('Endzeitpunkt') }}
+                            <datetimepicker v-model="data.stopdate"></datetimepicker>
+                        </label>
+                    </div>
+                    <label>
+                        <input type="checkbox" v-model="data.copyable" true-value="1" false-value="0">
+                        {{ $gettext('Fragebogen zum Kopieren freigeben') }}
+                    </label>
+                    <label>
+                        <input type="checkbox" v-model="data.anonymous" true-value="1" false-value="0">
+                        {{ $gettext('Teilnehmende anonymisieren') }}
+                    </label>
+                    <label>
+                        <input type="checkbox" v-model="data.editanswers" true-value="1" false-value="0">
+                        {{ $gettext('Teilnehmende dürfen ihre Antworten revidieren') }}
+                    </label>
+                    <label>
+                        {{ $gettext('Ergebnisse einsehbar') }}
+                        <select v-model="data.resultvisibility">
+                            <option value="always">{{ $gettext('Immer') }}</option>
+                            <option value="afterending">{{ $gettext('Nach Ende der Befragung') }}</option>
+                            <option value="afterparticipation">{{ $gettext('Nach der Teilnahme') }}</option>
+                            <option value="never">{{ $gettext('Niemals') }}</option>
+                        </select>
+                    </label>
+                </div>
+                <div class="add_question file_select_possibilities" v-else-if="activeTab === 'add_question'">
+                    <div>
+                        <button v-for="(questiontype, key) in questionTypes" :key="key"
+                                :ref="key == Object.keys(questionTypes)[0] ? 'autofocus' : ''"
+                                href=""
+                                @click.prevent="addQuestion(questiontype.type)"
+                        >
+                            <studip-icon :shape="questiontype.icon" :size="40"></studip-icon>
+                            {{questiontype.name}}
+                        </button>
+                    </div>
+                </div>
+                <div v-else>
+                    <component :is="componentForQuestionIndex(indexForQuestion)"
+                               v-model="data.questions[indexForQuestion].questiondata"
+                               :question_id="data.questions[indexForQuestion].id"
+                               :key="data.questions[indexForQuestion].id">
+                    </component>
+                </div>
+            </div>
+            <aside>
+                <a class="admin"
+                   :class="{active: activeTab === 'admin'}"
+                   href="#"
+                   @click.prevent="switchTab('admin')">
+                    <span class="icon"><studip-icon shape="evaluation" :size="30" alt=""></studip-icon></span>
+                    {{ $gettext('Einstellungen') }}
+                </a>
+                <draggable v-if="data.questions.length > 0" v-model="data.questions" handle=".drag-handle" group="questions" class="questions_container questions">
+                    <div v-for="question in data.questions"
+                         :key="question.id"
+                         @mouseenter="hoverTab = question.id"
+                         @mouseleave="hoverTab = null"
+                         :class="(activeTab === question.id || activeTab === 'meta_' + question.id ? 'active' : '') + (hoverTab === question.id ? ' hovered' : '')">
+                        <a href="#"
+                           @click.prevent="switchTab(question.id)">
+                            <span class="drag-handle"></span>
+                            <span class="icon type">
+                                <studip-icon :shape="questionTypes[question.questiontype].icon" :size="30" alt=""></studip-icon>
+                            </span>
+
+                            <div v-if="editInternalName !== question.id">{{ question.internal_name || questionTypes[question.questiontype].name}}</div>
+                            <div v-else class="inline_editing">
+                                <input type="text" ref="editInternalName" v-model="tempInternalName" class="inlineediting_internal_name">
+                                <button @click="saveInternalName(question.id)">
+                                    <studip-icon shape="accept" :size="20" :title="$gettext('Internen Namen speichern')"></studip-icon>
+                                </button>
+                                <button @click="editInternalName = null">
+                                    <studip-icon shape="decline" :size="20" :title="$gettext('Internen Namen nicht speichern')"></studip-icon>
+                                </button>
+                            </div>
+                        </a>
+
+                        <studip-action-menu :items="actionMenuItems"
+                                            @copy="duplicateQuestion(question.id)"
+                                            @rename="renameInternalName(question.id)"
+                                            @moveup="moveQuestionUp(question.id)"
+                                            @movedown="moveQuestionDown(question.id)"
+                                            @delete="deleteQuestion(question.id)"></studip-action-menu>
+                    </div>
+                </draggable>
+                <a :class="activeTab === 'add_question' ? 'add_question active' : 'add_question'"
+                   href="#"
+                   @click.prevent="switchTab('add_question')">
+                    <span class="icon"><studip-icon shape="add" :size="30" alt=""></studip-icon></span>
+                    {{ $gettext('Element hinzufügen') }}
+                </a>
+            </aside>
+        </div>
+
+
+        <footer data-dialog-button>
+            <button class="button" name="questionnaire_store">
+                {{ $gettext('Speichern') }}
+            </button>
+            <a href="#" class="button cancel">
+                {{ $gettext('Abbrechen') }}
+            </a>
+        </footer>
+    </form>
+</template>
+<script>
+import draggable from 'vuedraggable';
+import md5 from 'md5';
+import StudipIcon from '../StudipIcon.vue';
+import StudipActionMenu from '../StudipActionMenu.vue';
+import Datetimepicker from '../Datetimepicker.vue';
+
+const loadedComponents = {};
+
+export default {
+    name: 'questionnaireeditor',
+    components: {
+        Datetimepicker,
+        StudipActionMenu,
+        StudipIcon,
+        draggable,
+    },
+    props: {
+        asDialog: {
+            type: Boolean,
+            default: false,
+        },
+        questionData: Object,
+        questionTypes: Object,
+        rangeId: String,
+        rangeType: String,
+    },
+    data() {
+        return {
+            activeTab: 'admin',
+            data: {...this.questionData},
+            editInternalName: null,
+            form_secured: true,
+            hoverTab: null,
+            oldData: JSON.parse(JSON.stringify(this.questionData)),
+            tempInternalName: '',
+            validationNotice: false,
+        };
+    },
+    methods: {
+        componentForQuestionIndex(index) {
+            const componentInfo = this.questionTypes[this.data.questions[index].questiontype].component;
+            if (loadedComponents[componentInfo[0]] === undefined) {
+                loadedComponents[componentInfo[0]] = componentInfo[1] === ''
+                    ? () => import(`./${componentInfo[0]}.vue`)
+                    : () => import(/* webpackIgnore: true */componentInfo[1]);
+            }
+
+            return loadedComponents[componentInfo[0]];
+        },
+        addQuestion(questiontype) {
+            let id = md5(`${STUDIP.USER_ID}_QUESTIONTYPE_${Math.random()}`);
+
+            this.data.questions.push({
+                id: id,
+                questiontype: questiontype,
+                internal_name: '',
+                questiondata: {},
+            });
+
+            this.activeTab = id;
+        },
+        submit() {
+            if (!this.data.title) {
+                this.switchTab('admin');
+                this.validationNotice = true;
+                return;
+            }
+            const data = {
+                title: this.data.title,
+                copyable: this.data.copyable,
+                anonymous: this.data.anonymous,
+                editanswers: this.data.editanswers,
+                startdate: this.data.startdate,
+                stopdate: this.data.stopdate,
+                resultvisibility: this.data.resultvisibility
+            };
+            const questions = this.data.questions.map(question => ({
+                id: question.id,
+                questiontype: question.questiontype,
+                internal_name: question.internal_name,
+                questiondata: question.questiondata,
+            }));
+            $.post(STUDIP.URLHelper.getURL('dispatch.php/questionnaire/store/' + (this.data.id || '')), {
+                questionnaire: data,
+                questions_data: JSON.stringify(questions),
+                range_type: this.rangeType,
+                range_id: this.rangeId
+            }).done(() => {
+                this.form_secured = false;
+                this.$nextTick(() => {
+                    location.reload();
+                });
+            }).fail(() => {
+                STUDIP.Report.error('Could not save questionnaire.', '');
+            });
+        },
+        getIndexForQuestion(question_id) {
+            for (let i in this.data.questions) {
+                if (
+                    this.data.questions[i].id === question_id
+                    || this.data.questions[i].id === question_id.substring(5)
+                ) {
+                    return parseInt(i, 10);
+                }
+            }
+
+            return null;
+        },
+        duplicateQuestion(question_id) {
+            const i = this.getIndexForQuestion(question_id);
+            const id = md5(`${STUDIP.USER_ID}_QUESTIONTYPE_${Math.random()}`);
+            this.data.questions.push({
+                id: id,
+                questiontype: this.data.questions[i].questiontype,
+                internal_name: this.data.questions[i].internal_name,
+                questiondata: JSON.parse(JSON.stringify(this.data.questions[i].questiondata)),
+            });
+            this.activeTab = id;
+        },
+        deleteQuestion(question_id) {
+            STUDIP.Dialog.confirm(this.$gettext('Wirklich löschen?')).done(() => {
+                this.$delete(this.data.questions, this.getIndexForQuestion(question_id));
+                this.switchTab('add_question');
+            })
+        },
+        switchTab(tab_id) {
+            this.activeTab = tab_id;
+            this.$nextTick(function () {
+                if (this.$refs.autofocus !== undefined) {
+                    if (Array.isArray(this.$refs.autofocus)) {
+                        if (typeof this.$refs.autofocus[0] !== "undefined") {
+                            this.$refs.autofocus[0].focus();
+                        }
+                    } else {
+                        this.$refs.autofocus.focus();
+                    }
+                }
+            });
+        },
+        objectsEqual(obj1, obj2) {
+            return _.isEqual(obj1, obj2);
+        },
+        renameInternalName(question_id) {
+            this.editInternalName = question_id;
+            let index = this.getIndexForQuestion(question_id);
+            this.tempInternalName = this.data.questions[index].internal_name;
+            this.$nextTick(() => {
+                this.$refs.editInternalName[0].focus();
+            });
+        },
+        saveInternalName(question_id) {
+            let index = this.getIndexForQuestion(question_id);
+            this.data.questions[index].internal_name = this.tempInternalName;
+            this.editInternalName = null;
+        },
+        moveQuestionDown(question_id) {
+            let index = this.getIndexForQuestion(question_id);
+            if (index < this.data.questions.length - 1) {
+                let question = this.data.questions[index];
+                this.data.questions[index] = this.data.questions[index + 1];
+                this.data.questions[index + 1] = question;
+                this.$forceUpdate();
+            }
+        },
+        moveQuestionUp(question_id) {
+            let index = this.getIndexForQuestion(question_id);
+            if (index > 0) {
+                let question = this.data.questions[index];
+                this.data.questions[index] = this.data.questions[index - 1];
+                this.data.questions[index - 1] = question;
+                this.$forceUpdate();
+            }
+        }
+    },
+    computed: {
+        actionMenuItems() {
+            return [
+                {label: this.$gettext('Umbenennen'), icon: 'edit', emit: 'rename'},
+                {label: this.$gettext('Frage kopieren'), icon: 'copy', emit: 'copy'},
+                {label: this.$gettext('Frage nach oben verschieben'), icon: 'arr_1up', emit: 'moveup'},
+                {label: this.$gettext('Frage nach unten verschieben'), icon: 'arr_1down', emit: 'movedown'},
+                {label: this.$gettext('Frage löschen'), icon: 'trash', emit: 'delete'},
+            ];
+        },
+        activateFormSecure() {
+            return this.form_secured && !this.objectsEqual(this.oldData, this.data);
+        },
+        indexForQuestion() {
+            return this.getIndexForQuestion(this.activeTab);
+        },
+    },
+    mounted() {
+        this.$refs.autofocus.focus();
+    },
+}
+</script>
-- 
GitLab