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