diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php index 68b0b4d64862f91396cbe33e1713b98378158e71..abcb26d678382d5c9ad67c34a7b5f95e34613469 100755 --- a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsCopy.php @@ -34,16 +34,30 @@ class StructuralElementsCopy extends NonJsonApiController throw new AuthorizationFailedException(); } - $newElement = $this->copyElement($user, $sourceElement, $newParent); + if ($data['migrate']) { + $newElement = $this->mergeElement($user, $sourceElement, $newParent); + } else { + $newElement = $this->copyElement($user, $sourceElement, $newParent); + } + if ($data['remove_purpose']) { + $newElement->purpose = ''; + } return $this->redirectToStructuralElement($response, $newElement); } private function copyElement(\User $user, StructuralElement $sourceElement, StructuralElement $newParent) { - $new_element = $sourceElement->copy($user, $newParent); + $newElement = $sourceElement->copy($user, $newParent); + + return $newElement; + } + + private function mergeElement(\User $user, StructuralElement $sourceElement, StructuralElement $targetElement) + { + $newElement = $sourceElement->merge($user, $targetElement); - return $new_element; + return $newElement; } /** diff --git a/lib/models/Courseware/StructuralElement.php b/lib/models/Courseware/StructuralElement.php index 44451504a56aecefaaf312abfc0eab8a6d5d18f6..add15fbe980746c60c438ec8c76709eba8cedd61 100755 --- a/lib/models/Courseware/StructuralElement.php +++ b/lib/models/Courseware/StructuralElement.php @@ -586,17 +586,7 @@ SQL; */ public function copy(User $user, StructuralElement $parent): StructuralElement { - /** @var ?\FileRef $original_file_ref */ - $original_file_ref = \FileRef::find($this->image_id); - if ($original_file_ref) { - $instance = new Instance($this->getCourseware($parent->range_id, $parent->range_type)); - $folder = \Courseware\Filesystem\PublicFolder::findOrCreateTopFolder($instance); - /** @var \FileRef $file_ref */ - $file_ref = \FileManager::copyFile($original_file_ref->getFileType(), $folder, $user); - $file_ref_id = $file_ref->id; - } else { - $file_ref_id = null; - } + $file_ref_id = self::copyImage($user, $parent); $element = self::build([ 'parent_id' => $parent->id, @@ -621,6 +611,74 @@ SQL; return $element; } + private function copyImage(User $user, StructuralElement $parent) : ?String + { + $file_ref_id = null; + + /** @var ?\FileRef $original_file_ref */ + $original_file_ref = \FileRef::find($this->image_id); + if ($original_file_ref) { + $instance = new Instance($this->getCourseware($parent->range_id, $parent->range_type)); + $folder = \Courseware\Filesystem\PublicFolder::findOrCreateTopFolder($instance); + /** @var \FileRef $file_ref */ + $file_ref = \FileManager::copyFile($original_file_ref->getFileType(), $folder, $user); + $file_ref_id = $file_ref->id; + } + + return $file_ref_id; + } + + public function merge(User $user, StructuralElement $target): StructuralElement + { + // merge with target + if (!$target->image_id) { + $target->image_id = self::copyImage($user, $target); + } + + if ($target->title === 'neue Seite' || $target->title === 'New page') { + $target->title = $this->title; + } + + if (!$target->purpose) { + $target->purpose = $this->purpose; + } + + if (!$target->payload['color']) { + $target->payload['color'] = $this->payload['color']; + } + + if (!$target->payload['description']) { + $target->payload['description'] = $this->payload['description']; + } + + if (!$target->payload['license_type']) { + $target->payload['license_type'] = $this->payload['license_type']; + } + + if (!$target->payload['required_time']) { + $target->payload['required_time'] = $this->payload['required_time']; + } + + if (!$target->payload['difficulty_start']) { + $target->payload['difficulty_start'] = $this->payload['difficulty_start']; + } + + if (!$target->payload['difficulty_end']) { + $target->payload['difficulty_end'] = $this->payload['difficulty_end']; + } + + $target->store(); + + // add Containers to target + self::copyContainers($user, $target); + + // copy Children + + self::copyChildren($user, $target); + + return $this; + } + private function copyContainers(User $user, StructuralElement $newElement): void { $containers = \Courseware\Container::findBySQL('structural_element_id = ?', [$this->id]); diff --git a/resources/assets/stylesheets/scss/courseware.scss b/resources/assets/stylesheets/scss/courseware.scss index c1a8d90b7c66ac2ef608c60d5125c41bb555684b..2d4fb52f6470c51172f97081663f38b922433532 100755 --- a/resources/assets/stylesheets/scss/courseware.scss +++ b/resources/assets/stylesheets/scss/courseware.scss @@ -2704,7 +2704,7 @@ a u d i o b l o c k @include background-icon(file-audio2, clickable, 24); background-repeat: no-repeat; background-position: 1em center; - + margin: 1em 0; padding: 1em; padding-left: 4em; @@ -4323,3 +4323,87 @@ cw tiles end } /* cw manager copy end*/ + +/* courseware template preview */ +.cw-template-preview { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + width: calc(100% - 20px);; + padding: 10px; + .cw-template-preview-container-wrapper { + margin-bottom: 10px; + + &.cw-template-preview-container-full { + width: 100% + } + &.cw-template-preview-container-half { + width: calc(50% - 4px); + } + &.cw-template-preview-container-half-center { + width: 100%; + .cw-template-preview-container-content { + width: 50%; + margin: auto; + } + } + + .cw-template-preview-container-content { + border: solid thin $content-color-40; + } + + .cw-template-preview-container-title { + font-weight: 700; + padding: 4px 4px 4px 8px; + color: $base-color; + background-color: $content-color-20; + } + + .cw-template-preview-blocks { + border: solid thin $content-color-40; + padding: 1em; + margin: 5px; + background-color: $white; + + } + } +} +/* courseware template preview end*/ + +/* contents courseware courses */ +.cw-content-courses { + h2 { + margin-top: 0; + } + ul.cw-tiles { + margin-bottom: 20px; + } +} +/* contents courseware courses end*/ + +/* * * * * * * * * * + i n p u t f i l e +* * * * * * * * * */ + .cw-file-input { + width: stretch; + border: solid thin $base-color; + font-size: 14px; + cursor: pointer; + + &::file-selector-button { + border: none; + border-right: solid thin $base-color; + background-color: $white; + padding: 6px 15px; + margin-right: 10px; + color: $base-color; + + &:hover { + background-color: $base-color; + color: $white; + } + } + } + /* * * * * * * * * * * * * + i n p u t f i l e e n d +* * * * * * * * * * * * * */ diff --git a/resources/vue/components/courseware/CoursewareCourseManager.vue b/resources/vue/components/courseware/CoursewareCourseManager.vue index 66159d17099c584020e1ea6533f82ee5b50d3612..e9ce152e5429f513e1ea4b4757f16d1136aeb712 100755 --- a/resources/vue/components/courseware/CoursewareCourseManager.vue +++ b/resources/vue/components/courseware/CoursewareCourseManager.vue @@ -93,53 +93,7 @@ </courseware-tab> <courseware-tab :name="$gettext('Importieren')" :index="3"> - <courseware-companion-box v-show="!importRunning && importDone && importErrors.length === 0" :msgCompanion="$gettext('Import erfolgreich!')" mood="special"/> - <courseware-companion-box v-show="!importRunning && importDone && importErrors.length > 0" :msgCompanion="$gettext('Import abgeschlossen. Es sind Fehler aufgetreten!')" mood="unsure"/> - <courseware-companion-box v-show="!importRunning && !importDone && importErrors.length > 0" :msgCompanion="$gettext('Import fehlgeschlagen. Es sind Fehler aufgetreten!')" mood="sad"/> - <courseware-companion-box v-show="importRunning" :msgCompanion="$gettext('Import läuft. Bitte verlassen Sie die Seite nicht bis der Import abgeschlossen wurde.')" mood="pointing"/> - <button - v-show="!importRunning" - class="button" - @click.prevent="chooseFile" - > - <translate>Importdatei auswählen</translate> - </button> - - <div v-if="importZip" class="cw-import-zip"> - <header>{{ importZip.name }}</header> - <p><translate>Größe</translate>: {{ getFileSizeText(importZip.size) }}</p> - </div> - - <div v-if="importRunning" class="cw-import-zip"> - <header><translate>Importiere Dateien</translate>:</header> - <div class="progress-bar-wrapper"> - <div class="progress-bar" role="progressbar" :style="{width: importFilesProgress + '%'}" :aria-valuenow="importFilesProgress" aria-valuemin="0" aria-valuemax="100">{{ importFilesProgress }}%</div> - </div> - {{ importFilesState }} - </div> - - <div v-if="fileImportDone && importRunning" class="cw-import-zip"> - <header><translate>Importiere Elemente</translate>:</header> - <div class="progress-bar-wrapper"> - <div class="progress-bar" role="progressbar" :style="{width: importStructuresProgress + '%'}" :aria-valuenow="importStructuresProgress" aria-valuemin="0" aria-valuemax="100">{{ importStructuresProgress }}%</div> - </div> - {{ importStructuresState }} - </div> - - <button - v-show="importZip && !importRunning" - class="button" - @click.prevent="doImportCourseware" - > - <translate>Alles importieren</translate> - </button> - <div v-if="importErrors.length > 0"> - <h3><translate>Fehlermeldungen:</translate></h3> - <ul> - <li v-for="(error, index) in importErrors" :key="index"> {{error}} </li> - </ul> - </div> - <input ref="importFile" type="file" accept=".zip" @change="setImport" style="visibility: hidden" /> + <courseware-manager-import /> </courseware-tab> </courseware-tabs> </div> @@ -153,13 +107,10 @@ import CoursewareCollapsibleBox from './CoursewareCollapsibleBox.vue'; import CoursewareManagerElement from './CoursewareManagerElement.vue'; import CoursewareManagerCopySelector from './CoursewareManagerCopySelector.vue'; import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; -import CoursewareImport from '@/vue/mixins/courseware/import.js'; +import CoursewareManagerImport from './CoursewareManagerImport.vue'; import CoursewareExport from '@/vue/mixins/courseware/export.js'; import { mapActions, mapGetters } from 'vuex'; -import JSZip from 'jszip'; -import FileSaver from 'file-saver'; - export default { name: 'courseware-course-manager', components: { @@ -169,20 +120,18 @@ export default { CoursewareManagerElement, CoursewareManagerCopySelector, CoursewareCompanionBox, + CoursewareManagerImport }, - mixins: [CoursewareImport, CoursewareExport], + mixins: [CoursewareExport], data() { return { exportRunning: false, - importRunning: false, - importZip: null, currentElement: {}, currentId: null, selfElement: {}, selfId: null, - zip: null }; }, @@ -190,11 +139,6 @@ export default { ...mapGetters({ courseware: 'courseware', structuralElementById: 'courseware-structural-elements/byId', - importFilesState: 'importFilesState', - importFilesProgress: 'importFilesProgress', - importStructuresState: 'importStructuresState', - importStructuresProgress: 'importStructuresProgress', - importErrors: 'importErrors', exportState: 'exportState', exportProgress: 'exportProgress' }), @@ -214,12 +158,6 @@ export default { moveSelfChildPossible() { return this.currentId !== this.selfId; }, - fileImportDone() { - return this.importFilesProgress === 100; - }, - importDone() { - return this.importFilesProgress === 100 && this.importStructuresProgress === 100; - } }, methods: { @@ -233,9 +171,6 @@ export default { unlockObject: 'unlockObject', addBookmark: 'addBookmark', companionInfo: 'companionInfo', - setImportFilesProgress: 'setImportFilesProgress', - setImportStructuresProgress: 'setImportStructuresProgress', - setImportErrors: 'setImportErrors', }), async reloadElements() { await this.setCurrentId(this.currentId); @@ -274,88 +209,6 @@ export default { this.exportRunning = false; }, - - setImport(event) { - this.importZip = event.target.files[0]; - this.setImportErrors([]); - }, - - async doImportCourseware() { - if (this.importZip === null) { - return false; - } - - this.importRunning = true; - - let view = this; - - view.zip = new JSZip(); - - await view.zip.loadAsync(this.importZip).then(async function () { - let errors = []; - let missingFiles = false; - if (view.zip.file('courseware.json') === null) { - errors.push(view.$gettext('Das Archiv enthält keine courseware.json Datei.')); - missingFiles = true; - } - if (view.zip.file('files.json') === null) { - errors.push(view.$gettext('Das Archiv enthält keine files.json Datei.')); - missingFiles = true; - } - if (view.zip.file('data.xml') !== null) { - errors.push(view.$gettext('Das Archiv enthält eine data.xml Datei. Möglicherweise handelt es sich um einen Export aus dem Courseware-Plugin. Diese Archive sind nicht kompatibel mit dieser Courseware.')); - } - if (missingFiles) { - view.setImportErrors(errors); - return; - } - - let data = await view.zip.file('courseware.json').async('string'); - let courseware = null; - let data_files = await view.zip.file('files.json').async('string'); - let files = null; - let jsonErrors = false; - try { - courseware = JSON.parse(data); - } catch (error) { - jsonErrors = true; - errors.push(view.$gettext('Die Beschreibung der Courseware-Inhalte ist nicht valide.')); - errors.push(error); - } - try { - files = JSON.parse(data_files); - } catch (error) { - jsonErrors = true; - errors.push(view.$gettext('Die Beschreibung der Dateien ist nicht valide.')); - errors.push(error); - } - if (jsonErrors) { - view.setImportErrors(errors); - return; - } - - await view.loadCoursewareStructure(); - let parent_id = view.courseware.relationships.root.data.id; - - await view.importCourseware(courseware, parent_id, files); - }); - - this.importZip = null; - this.importRunning = false; - }, - - chooseFile() { - this.$refs.importFile.click(); - this.setImportFilesProgress(0); - this.setImportStructuresProgress(0); - }, - getFileSizeText(size) { - if (size / 1024 < 1000) { - return (size / 1024).toFixed(2) + ' kB'; - } else { - return (size / 1048576).toFixed(2) + ' MB'; - } - }, }, watch: { courseware(newValue, oldValue) { @@ -364,12 +217,6 @@ export default { this.setSelfId(currentId); }, }, - mounted() { - let view = this; - window.onbeforeunload = function() { - return view.importRunning ? true : null - } - } }; </script> diff --git a/resources/vue/components/courseware/CoursewareDashboardTasks.vue b/resources/vue/components/courseware/CoursewareDashboardTasks.vue new file mode 100755 index 0000000000000000000000000000000000000000..fe37539825be76f5746b69fce47ec6dd08ae5551 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareDashboardTasks.vue @@ -0,0 +1,265 @@ +<template> + <div class="cw-dashboard-tasks-wrapper"> + <table v-if="tasks.length > 0" class="default"> + <colgroup> + <col /> + </colgroup> + <thead> + <tr> + <th><translate>Status</translate></th> + <th class="responsive-hidden"><translate>Aufgabentitel</translate></th> + <th><translate>Seite</translate></th> + <th><translate>bearbeitet</translate></th> + <th><translate>Abgabefrist</translate></th> + <th><translate>Abgabe</translate></th> + <th class="responsive-hidden"><translate>Verlängerungsanfrage</translate></th> + <th class="responsive-hidden"><translate>Feedback</translate></th> + <th><translate>Aktionen</translate></th> + </tr> + </thead> + <tbody> + <tr v-for="{ task, taskGroup, status, element, feedback } in tasks" :key="task.id"> + <td> + <studip-icon + v-if="status.shape !== undefined" + :shape="status.shape" + :role="status.role" + :title="status.description" + /> + </td> + <td class="responsive-hidden"> + <studip-icon + v-if="task.attributes['solver-type'] === 'group'" + shape="group2" + role="info" + :title="$gettext('Gruppenaufgabe')" + /> + {{ taskGroup.attributes.title }} + </td> + <td> + <a :href="getLinkToElement(element.id)">{{ element.attributes.title }}</a> + </td> + <td>{{ task.attributes.progress }}%</td> + <td>{{ getReadableDate(task.attributes['submission-date']) }}</td> + <td> + <studip-icon v-if="task.attributes.submitted" shape="accept" role="status-green" /> + </td> + <td class="responsive-hidden"> + <span v-show="task.attributes.renewal === 'declined'"> + <studip-icon shape="decline" role="status-red" /> + <translate>Anfrage abgelehnt</translate> + </span> + <span v-show="task.attributes.renewal === 'pending'"> + <studip-icon shape="date" role="status-yellow" /> + <translate>Anfrage wird bearbeitet</translate> + </span> + <span v-show="task.attributes.renewal === 'granted'"> + <translate>verlängert bis</translate>: {{getReadableDate(task.attributes['renewal-date'])}} + </span> + </td> + <td class="responsive-hidden"> + <studip-icon + v-if="feedback" + :title="$gettext('Feedback anzeigen')" + class="display-feedback" + shape="consultation" + role="clickable" + @click="displayFeedback(feedback)" + /> + </td> + <td class="actions"> + <studip-action-menu + :items="getTaskMenuItems(task, status)" + @submitTask="displaySubmitDialog(task)" + @renewalRequest="renewalRequest(task)" + @copyContent="copyContent(element)" + /> + </td> + </tr> + </tbody> + </table> + <div v-else> + <courseware-companion-box + mood="sad" + :msgCompanion="$gettext('Es wurden bisher keine Aufgaben gestellt.')" + /> + </div> + <studip-dialog + v-if="showFeedbackDialog" + :message="currentTaskFeedback" + :title="text.feedbackDialog.title" + @close=" + showFeedbackDialog = false; + currentTaskFeedback = ''; + " + /> + <studip-dialog + v-if="showSubmitDialog" + :title="text.submitDialog.title" + :question="text.submitDialog.question" + height="200" + width="420" + @confirm="submitTask" + @close="closeSubmitDialog" + /> + </div> +</template> +<script> +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; +import StudipIcon from './../StudipIcon.vue'; +import StudipActionMenu from './../StudipActionMenu.vue'; +import StudipDialog from './../StudipDialog.vue'; +import taskHelperMixin from '../../mixins/courseware/task-helper.js'; +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-dashboard-tasks', + mixins: [taskHelperMixin], + components: { + CoursewareCompanionBox, + StudipIcon, + StudipActionMenu, + StudipDialog, + }, + data() { + return { + showFeedbackDialog: false, + showSubmitDialog: false, + currentTask: null, + currentTaskFeedback: '', + text: { + feedbackDialog: { + title: this.$gettext('Feedback'), + }, + submitDialog: { + title: this.$gettext('Aufgabe abgeben'), + question: this.$gettext( + 'Änderungen sind nach Abgabe nicht mehr möglich. Möchten Sie diese Aufgabe jetzt wirklich abgeben?' + ), + }, + }, + }; + }, + computed: { + ...mapGetters({ + context: 'context', + allTasks: 'courseware-tasks/all', + userId: 'userId', + userById: 'users/byId', + statusGroupById: 'status-groups/byId', + getElementById: 'courseware-structural-elements/byId', + getFeedbackById: 'courseware-task-feedback/byId', + getTaskGroupById: 'courseware-task-groups/byId', + }), + tasks() { + return this.allTasks.map((task) => { + const result = { + task, + taskGroup: this.getTaskGroupById({ id: task.relationships['task-group'].data.id }), + status: this.getStatus(task), + element: this.getElementById({ id: task.relationships['structural-element'].data.id }), + feedback: null, + }; + const feedbackId = task.relationships['task-feedback'].data?.id; + if (feedbackId) { + result.feedback = this.getFeedbackById({ id: feedbackId }); + } + + return result; + }); + }, + }, + methods: { + ...mapActions({ + updateTask: 'updateTask', + loadRemoteCoursewareStructure: 'loadRemoteCoursewareStructure', + copyStructuralElement: 'copyStructuralElement', + companionSuccess: 'companionSuccess', + companionError: 'companionError', + }), + getTaskMenuItems(task, status) { + let menuItems = []; + if (!task.attributes.submitted && status.canSubmit) { + menuItems.push({ id: 1, label: this.$gettext('Aufgabe abgeben'), icon: 'service', emit: 'submitTask' }); + } + + if (!task.attributes.submitted && !task.attributes.renewal) { + menuItems.push({ + id: 2, + label: this.$gettext('Verlängerung beantragen'), + icon: 'date', + emit: 'renewalRequest', + }); + } + if (task.attributes.submitted) { + menuItems.push({ id: 3, label: this.$gettext('Inhalt kopieren'), icon: 'export', emit: 'copyContent' }); + } + + return menuItems; + }, + async renewalRequest(task) { + let attributes = {}; + attributes.renewal = 'pending'; + await this.updateTask({ + attributes: attributes, + taskId: task.id, + }); + this.companionSuccess({ + info: this.$gettext('Ihre Anfrage wurde eingereicht.'), + }); + }, + displaySubmitDialog(task) { + this.showSubmitDialog = true; + this.currentTask = task; + }, + closeSubmitDialog() { + this.showSubmitDialog = false; + this.currentTask = null; + }, + async submitTask() { + this.showSubmitDialog = false; + let attributes = {}; + attributes.submitted = true; + await this.updateTask({ + attributes: attributes, + taskId: this.currentTask.id, + }); + this.companionSuccess({ + info: + '"' + + this.currentTask.attributes.title + + '" ' + + this.$gettext('wurde erfolgreich abgegeben.'), + }); + this.currentTask = null; + }, + async copyContent(element) { + let ownCoursewareInstance = await this.loadRemoteCoursewareStructure({ + rangeId: this.userId, + rangeType: 'users', + }); + if (ownCoursewareInstance !== null) { + await this.copyStructuralElement({ + parentId: ownCoursewareInstance.relationships.root.data.id, + elementId: element.id, + removeType: true, + migrate: false + }); + this.companionSuccess({ + info: this.$gettext('Die Inhalte wurden zu Ihren persönlichen Lernmaterialien hinzugefügt.'), + }); + } else { + this.companionError({ + info: this.$gettext( + 'Die Inhalte konnten nicht zu Ihren persönlichen Lernmaterialien hinzugefügt werden.' + ), + }); + } + }, + displayFeedback(feedback) { + this.showFeedbackDialog = true; + this.currentTaskFeedback = feedback.attributes.content; + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/CoursewareManagerCopySelector.vue b/resources/vue/components/courseware/CoursewareManagerCopySelector.vue index 01c2c36b7d73f98d39debdf8adf84cabfa1f3424..3c22dcd9a14ec582c7ab8b0e24d29a670dd0ed88 100755 --- a/resources/vue/components/courseware/CoursewareManagerCopySelector.vue +++ b/resources/vue/components/courseware/CoursewareManagerCopySelector.vue @@ -5,8 +5,10 @@ <button class="button" @click="selectSource('remote')"><translate>Aus Veranstaltung kopieren</translate></button> </div> <div v-else> + <courseware-companion-box v-if="copyAllInProgress" :msgCompanion="copyAllInProgressText" mood="pointing" /> <button class="button" @click="reset"><translate>Quelle auswählen</translate></button> <button v-show="!sourceOwn && hasRemoteCid" class="button" @click="selectNewCourse"><translate>Veranstaltung auswählen</translate></button> + <button v-show="!sourceOwn && hasRemoteCid" class="button" @click="mergeContent"><translate>Alle Inhalte kopieren</translate></button> <div v-if="sourceRemote"> <h2 v-if="!hasRemoteCid"><translate>Veranstaltungen</translate></h2> <ul v-if="!hasRemoteCid && semesterMap.length > 0"> @@ -75,24 +77,28 @@ export default { CoursewareCompanionBox, }, props: {}, - data() {return{ - source: '', - courses: [], - remoteCid: '', - remoteCoursewareInstance: {}, - remoteId: '', - remoteElement: {}, - ownCoursewareInstance: {}, - ownId: '', - ownElement: {}, - semesterMap: [], - - }}, + data() { + return { + source: '', + courses: [], + remoteCid: '', + remoteCoursewareInstance: {}, + remoteId: '', + remoteElement: {}, + ownCoursewareInstance: {}, + ownId: '', + ownElement: {}, + semesterMap: [], + copyAllInProgress: false, + copyAllInProgressText: '' + } + }, computed: { ...mapGetters({ - userId: 'userId', - structuralElementById: 'courseware-structural-elements/byId', + courseware: 'courseware', semesterById: 'semesters/byId', + structuralElementById: 'courseware-structural-elements/byId', + userId: 'userId', }), sourceEmpty() { return this.source === ''; @@ -116,9 +122,11 @@ export default { loadUsersCourses: 'loadUsersCourses', loadStructuralElement: 'loadStructuralElement', loadSemester: 'semesters/loadById', + copyStructuralElement: 'copyStructuralElement', }), selectSource(source) { this.source = source; + this.copyAllInProgress = false; }, async loadRemoteCourseware(cid) { this.remoteCid = cid; @@ -140,10 +148,12 @@ export default { reset() { this.selectSource(''); this.remoteCid = ''; + this.copyAllInProgress = false; }, selectNewCourse() { this.remoteCid = ''; this.remoteId = ''; + this.copyAllInProgress = false; }, async setRemoteId(target) { this.remoteId = target; @@ -196,6 +206,24 @@ export default { }, reloadElement() { this.$emit("reloadElement"); + }, + async mergeContent() { + this.copyAllInProgressText = this.$gettext('Inhalte werden kopiert…'); + this.copyAllInProgress = true; + let parentId = this.courseware.relationships.root.data.id; //current root + let elementId = this.remoteCoursewareInstance.relationships.root.data.id; // remote root + try { + await this.copyStructuralElement({ + parentId: parentId, + elementId: elementId, + migrate: true + }); + } catch(error) { + console.debug(error); + this.copyAllInProgressText = this.$gettext('Beim Kopiervorgang sind Fehler aufgetreten.'); + } + this.copyAllInProgressText = this.$gettext('Kopiervorgang abgeschlossen.'); + this.reloadElement(); } }, async mounted() { diff --git a/resources/vue/components/courseware/CoursewareManagerElement.vue b/resources/vue/components/courseware/CoursewareManagerElement.vue index 01858aea7ad43f6289c6698de37a38634d3cd2d5..7dfe67b8836fb89667b74932fc8a7b448ba3a169 100755 --- a/resources/vue/components/courseware/CoursewareManagerElement.vue +++ b/resources/vue/components/courseware/CoursewareManagerElement.vue @@ -20,12 +20,12 @@ <a v-if="elementInserterActive && moveSelfPossible && canEdit" href="#" - :title="$gettextInterpolate('%{ elementTitle } verschieben', {elementTitle: elementTitle})" + :title="elementTitle" @click="insertElement({element: currentElement, source: type})" > <studip-icon shape="arr_2left" size="24" role="clickable" /> </a> - {{ elementTitle }} + {{ elementName }} </header> </div> <courseware-collapsible-box @@ -227,12 +227,24 @@ export default { return [...visitAncestors(this.currentElement)].reverse() }, - elementTitle() { + elementName() { if (this.currentElement.attributes) { return this.currentElement.attributes.title - } else { - return ''; } + + return ''; + }, + elementTitle() { + let title = this.elementName; + if (this.elementInserterActive && this.moveSelfPossible && this.canEdit) { + if (this.isRemote || this.isOwn) { + title = this.$gettextInterpolate('%{ elementTitle } kopieren', {elementTitle: this.elementName}); + } else { + title = this.$gettextInterpolate('%{ elementTitle } verschieben', {elementTitle: this.elementName}); + } + } + + return title; }, hasChildren() { if (this.children === null) { @@ -373,7 +385,8 @@ export default { let parentId = this.filingData.parentItem.id; await this.copyStructuralElement({ parentId: parentId, - element: element, + elementId: element.id, + migrate: false }).catch((error) => { let message = this.$gettextInterpolate('%{ pageTitle } konnte nicht kopiert werden.', {pageTitle: element.attributes.title}); this.text.copyProcessFailed.push(message); diff --git a/resources/vue/components/courseware/CoursewareManagerElementItem.vue b/resources/vue/components/courseware/CoursewareManagerElementItem.vue index c177127629038f9428b733e3679196f0da737051..5fd7a2c04479bdb0b8101d01814a6084275991e9 100755 --- a/resources/vue/components/courseware/CoursewareManagerElementItem.vue +++ b/resources/vue/components/courseware/CoursewareManagerElementItem.vue @@ -5,11 +5,11 @@ href="#" class="cw-manager-element-item" :class="[inserter ? 'cw-manager-element-item-inserter' : '']" - :title="inserter ? $gettextInterpolate('%{ elementTitle } verschieben', {elementTitle: element.attributes.title}) : element.attributes.title" + :title="elementTitle" @click="clickItem"> {{ element.attributes.title }} </a> - <div + <div v-else class="cw-manager-element-item cw-manager-element-item-sorting" > @@ -37,6 +37,62 @@ export default { canMoveUp: Boolean, canMoveDown: Boolean }, + computed: { + ...mapGetters({ + taskById: 'courseware-tasks/byId', + userById: 'users/byId', + groupById: 'status-groups/byId', + }), + isTask() { + return this.element.attributes.purpose === 'task'; + }, + task() { + if (this.element.relationships.task.data) { + return this.taskById({ + id: this.element.relationships.task.data.id, + }); + } + + return null; + }, + solver() { + if (this.task) { + const solver = this.task.relationships.solver.data; + if (solver.type === 'users') { + return this.userById({ id: solver.id }); + } + if (solver.type === 'status-groups') { + return this.groupById({ id: solver.id }); + } + } + + return null; + }, + solverName() { + if (this.solver) { + if (this.solver.type === 'users') { + return this.solver.attributes['formatted-name']; + } + if (this.solver.type === 'status-groups') { + return this.solver.attributes.name; + } + } + + return ''; + }, + elementTitle() { + let title = this.element.attributes.title; + if (this.inserter) { + if (this.type === 'remote' || this.type === 'own') { + title = this.$gettextInterpolate('%{ elementTitle } kopieren', {elementTitle: this.element.attributes.title}); + } else { + title = this.$gettextInterpolate('%{ elementTitle } verschieben', {elementTitle: this.element.attributes.title}); + } + } + + return title; + } + }, methods: { clickItem() { if (this.sortChapters) { diff --git a/resources/vue/components/courseware/CoursewareManagerImport.vue b/resources/vue/components/courseware/CoursewareManagerImport.vue new file mode 100644 index 0000000000000000000000000000000000000000..6fd3b476be23930c9f6431642e56b90d58d90e24 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareManagerImport.vue @@ -0,0 +1,194 @@ +<template> + <div> + <courseware-companion-box v-show="!importRunning && importDone && importErrors.length === 0" :msgCompanion="$gettext('Import erfolgreich!')" mood="special"/> + <courseware-companion-box v-show="!importRunning && importDone && importErrors.length > 0" :msgCompanion="$gettext('Import abgeschlossen. Es sind Fehler aufgetreten!')" mood="unsure"/> + <courseware-companion-box v-show="!importRunning && !importDone && importErrors.length > 0" :msgCompanion="$gettext('Import fehlgeschlagen. Es sind Fehler aufgetreten!')" mood="sad"/> + <courseware-companion-box v-show="importRunning" :msgCompanion="$gettext('Import läuft. Bitte verlassen Sie die Seite nicht bis der Import abgeschlossen wurde.')" mood="pointing"/> + <form class="default" @submit.prevent=""> + + <fieldset v-show="importRunning"> + <legend><translate>Import läuft...</translate></legend> + <div v-if="importRunning" class="cw-import-zip"> + <header><translate>Importiere Dateien</translate>:</header> + <div class="progress-bar-wrapper"> + <div class="progress-bar" role="progressbar" :style="{width: importFilesProgress + '%'}" :aria-valuenow="importFilesProgress" aria-valuemin="0" aria-valuemax="100">{{ importFilesProgress }}%</div> + </div> + {{ importFilesState }} + </div> + <div v-if="fileImportDone && importRunning" class="cw-import-zip"> + <header><translate>Importiere Elemente</translate>:</header> + <div class="progress-bar-wrapper"> + <div class="progress-bar" role="progressbar" :style="{width: importStructuresProgress + '%'}" :aria-valuenow="importStructuresProgress" aria-valuemin="0" aria-valuemax="100">{{ importStructuresProgress }}%</div> + </div> + {{ importStructuresState }} + </div> + </fieldset> + <fieldset v-show="importErrors.length > 0"> + <legend><translate>Fehlermeldungen</translate></legend> + <ul> + <li v-for="(error, index) in importErrors" :key="index"> {{error}} </li> + </ul> + </fieldset> + <fieldset v-show="!importRunning"> + <legend><translate>Import</translate></legend> + <label> + <translate>Importdatei</translate> + <input class="cw-file-input" ref="importFile" type="file" accept=".zip" @change="setImport" /> + </label> + <label> + <translate>Importverhalten</translate> + <select v-model="importBehavior"> + <option value="default"><translate>Inhalte anhängen</translate></option> + <option value="migrate"><translate>Inhalte zusammenführen</translate></option> + </select> + </label> + </fieldset> + <footer v-show="!importRunning"> + <button + class="button" + @click.prevent="doImportCourseware" + :disabled="!importZip" + > + <translate>Importieren</translate> + </button> + </footer> + </form> + </div> +</template> + +<script> +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; + +import CoursewareImport from '@/vue/mixins/courseware/import.js'; +import { mapActions, mapGetters } from 'vuex'; +import JSZip from 'jszip'; + +export default { + name: 'courseware-manager-import', + components: { + CoursewareCompanionBox, + }, + mixins: [CoursewareImport], + data() { + return { + importBehavior: 'default', + importRunning: false, + importZip: null, + zip: null + } + }, + computed: { + ...mapGetters({ + courseware: 'courseware', + importFilesState: 'importFilesState', + importFilesProgress: 'importFilesProgress', + importStructuresState: 'importStructuresState', + importStructuresProgress: 'importStructuresProgress', + importErrors: 'importErrors', + }), + fileImportDone() { + return this.importFilesProgress === 100; + }, + importDone() { + return this.importFilesProgress === 100 && this.importStructuresProgress === 100; + } + }, + methods: { + ...mapActions({ + loadCoursewareStructure: 'courseware-structure/load', + setImportFilesProgress: 'setImportFilesProgress', + setImportStructuresProgress: 'setImportStructuresProgress', + setImportErrors: 'setImportErrors', + }), + + setImport(event) { + this.importZip = event.target.files[0]; + this.setImportFilesProgress(0); + this.setImportStructuresProgress(0); + this.setImportErrors([]); + }, + + async doImportCourseware() { + if (this.importZip === null) { + return false; + } + + this.importRunning = true; + + let view = this; + + view.zip = new JSZip(); + + await view.zip.loadAsync(this.importZip).then(async function () { + let errors = []; + let missingFiles = false; + if (view.zip.file('courseware.json') === null) { + errors.push(view.$gettext('Das Archiv enthält keine courseware.json Datei.')); + missingFiles = true; + } + if (view.zip.file('files.json') === null) { + errors.push(view.$gettext('Das Archiv enthält keine files.json Datei.')); + missingFiles = true; + } + if (view.zip.file('data.xml') !== null) { + errors.push(view.$gettext( + 'Das Archiv enthält eine data.xml Datei. Möglicherweise handelt es sich um einen Export aus dem Courseware-Plugin. Diese Archive sind nicht kompatibel mit dieser Courseware.' + )); + } + if (missingFiles) { + view.setImportErrors(errors); + return; + } + + let data = await view.zip.file('courseware.json').async('string'); + let courseware = null; + let data_files = await view.zip.file('files.json').async('string'); + let files = null; + let jsonErrors = false; + try { + courseware = JSON.parse(data); + } catch (error) { + jsonErrors = true; + errors.push(view.$gettext('Die Beschreibung der Courseware-Inhalte ist nicht valide.')); + errors.push(error); + } + try { + files = JSON.parse(data_files); + } catch (error) { + jsonErrors = true; + errors.push(view.$gettext('Die Beschreibung der Dateien ist nicht valide.')); + errors.push(error); + } + if (jsonErrors) { + view.setImportErrors(errors); + return; + } + + await view.loadCoursewareStructure(); + const rootId = view.courseware.relationships.root.data.id; + + await view.importCourseware(courseware, rootId, files, view.importBehavior); + }); + + this.importZip = null; + this.importRunning = false; + this.$refs.importFile.value = ''; + }, + + getFileSizeText(size) { + if (size / 1024 < 1000) { + return (size / 1024).toFixed(2) + ' kB'; + } else { + return (size / 1048576).toFixed(2) + ' MB'; + } + }, + }, + mounted() { + let view = this; + + window.onbeforeunload = function() { + return view.importRunning ? true : null + } + } +} +</script> diff --git a/resources/vue/mixins/courseware/import.js b/resources/vue/mixins/courseware/import.js index 9f84905087908e32d89ba9502e57b48c8175c34c..406a3b81299f17f657b49a5fde6c0b1fe1e20eb3 100755 --- a/resources/vue/mixins/courseware/import.js +++ b/resources/vue/mixins/courseware/import.js @@ -14,13 +14,18 @@ export default { computed: { ...mapGetters({ context: 'context', - courseware: 'courseware-instances/all' + courseware: 'courseware-instances/all', + structuralElementById: 'courseware-structural-elements/byId', }), }, methods: { + ...mapActions({ + loadStructuralElementById: 'courseware-structural-elements/loadById', + updateStructuralElement: 'updateStructuralElement' + }), - async importCourseware(element, parent_id, files) + async importCourseware(element, rootId, files, importBehavior) { // import all files await this.uploadAllFiles(files); @@ -30,8 +35,12 @@ export default { this.importElementCounter = 0; this.setImportErrors([]); - await this.importStructuralElement([element], parent_id, files); - + if (importBehavior === 'default') { + await this.importStructuralElement([element], rootId, files); + } + if (importBehavior === 'migrate') { + await this.migrateCourseware(element, rootId, files); + } }, countImportElements(element) { @@ -60,32 +69,94 @@ export default { return counter; }, + async migrateCourseware(element, rootId, files) { + let root = this.structuralElementById({ id: rootId }); + // add containers and blocks + if (element.containers?.length > 0) { + for (let i = 0; i < element.containers.length; i++) { + await this.importContainer(element.containers[i], root, files); + } + } + //compare payload + let changedData = false; + if (root.attributes.title === 'neue Seite') { + root.attributes.title = element.attributes.title; + changedData = true; + } + if (root.attributes.purpose === '') { + root.attributes.purpose = element.attributes.purpose; + changedData = true; + } + if (element.attributes.payload) { + if (!root.attributes.payload.color && element.attributes.payload.color) { + root.attributes.payload.color = element.attributes.payload.color; + changedData = true; + } + if (!root.attributes.payload.description && element.attributes.payload.description) { + root.attributes.payload.description = element.attributes.payload.description; + changedData = true; + } + if (!root.attributes.payload.license_type && element.attributes.payload.license_type) { + root.attributes.payload.license_type = element.attributes.payload.license_type; + changedData = true; + } + if (!root.attributes.payload.required_time && element.attributes.payload.required_time) { + root.attributes.payload.required_time = element.attributes.payload.required_time; + changedData = true; + } + if (!root.attributes.payload.difficulty_start && element.attributes.payload.difficulty_start) { + root.attributes.payload.difficulty_start = element.attributes.payload.difficulty_start; + changedData = true; + } + if (!root.attributes.payload.difficulty_end && element.attributes.payload.difficulty_end) { + root.attributes.payload.difficulty_end = element.attributes.payload.difficulty_end; + changedData = true; + } + } + if (changedData) { + await this.lockObject({ id: root.id, type: 'courseware-structural-elements' }); + await this.updateStructuralElement({ + element: root, + id: root.id, + }); + await this.unlockObject({ id: root.id, type: 'courseware-structural-elements' }); + } + // compare image + if (element.imageId && root.relationships.image.data === null) { + await this.setStructuralElementImage(root, element.imageId, files); + } + + // add children + if (element.children) { + await this.importStructuralElement(element.children, rootId, files); + } + + this.setImportStructuresProgress(100); + }, + async importStructuralElement(element, parent_id, files) { if (element.length) { for (var i = 0; i < element.length; i++) { this.setImportStructuresState(this.$gettext('Lege Seite an:') + ' ' + element[i].attributes.title); - await this.createStructuralElement({ - attributes: element[i].attributes, - parentId: parent_id, - currentId: parent_id, - }); + try { + await this.createStructuralElement({ + attributes: element[i].attributes, + parentId: parent_id, + currentId: parent_id, + }); + } catch(error) { + this.currentImportErrors.push(this.$gettext('Seite konnte nicht erstellt werden') + ': ' + + element.attributes.title); + + continue; + } + this.importElementCounter++; let new_element = this.$store.getters['courseware-structural-elements/lastCreated']; if (element[i].imageId) { - let imageFile = files.find((file) => { return file.id === element[i].imageId}); - let zip_filedata = await this.zip.file(imageFile.id).async('blob'); - // create new blob with correct type - let filedata = zip_filedata.slice(0, zip_filedata.size, imageFile.attributes['mime-type']); - this.setImportStructuresState(this.$gettext('Lade Vorschaubild hoch')); - this.uploadImageForStructuralElement({ - structuralElement: new_element, - file: filedata, - }).catch((error) => { - console.error(error); - this.currentImportErrors.push(this.$gettext('Fehler beim Hochladen des Vorschaubildes.')); - }); + await this.setStructuralElementImage(new_element, element[i].imageId, files); } @@ -96,28 +167,60 @@ export default { if (element[i].containers?.length > 0) { for (var j = 0; j < element[i].containers.length; j++) { let container = element[i].containers[j]; - // TODO: create element on server and fetch new id - this.setImportStructuresState(this.$gettext('Lege Abschnitt an:') + ' ' + container.attributes.title); - await this.createContainer({ - attributes: container.attributes, - structuralElementId: new_element.id, - }); - this.importElementCounter++; + await this.importContainer(container, new_element, files); + } + } + } + } + }, - let new_container = this.$store.getters['courseware-containers/lastCreated']; - await this.unlockObject({ id: new_container.id, type: 'courseware-containers' }); + async setStructuralElementImage(new_element, imageId, files) { + let imageFile = files.find((file) => { return file.id === imageId}); + let zip_filedata = await this.zip.file(imageFile.id).async('blob'); + // create new blob with correct type + let filedata = zip_filedata.slice(0, zip_filedata.size, imageFile.attributes['mime-type']); + this.setImportStructuresState(this.$gettext('Lade Vorschaubild hoch')); + this.uploadImageForStructuralElement({ + structuralElement: new_element, + file: filedata, + }).catch((error) => { + console.error(error); + this.currentImportErrors.push(this.$gettext('Fehler beim Hochladen des Vorschaubildes.')); + }); + }, - if (container.blocks?.length) { - let new_block = null; - for (var k = 0; k < container.blocks.length; k++) { - new_block = await this.importBlock(container.blocks[k], new_container, files, new_element); - if (new_block !== null) { - this.importElementCounter++; - await this.updateContainerPayload(new_container, new_element.id, container.blocks[k].id, new_block.id); - } - } + async importContainer(container, structuralElement, files) { + this.setImportStructuresState(this.$gettext('Lege Abschnitt an:') + ' ' + container.attributes.title); + try { + await this.createContainer({ + attributes: container.attributes, + structuralElementId: structuralElement.id, + }); + + } catch(error) { + this.currentImportErrors.push(this.$gettext('Abschnitt konnte nicht erstellt werden') + ': ' + + structuralElement.attributes.title + '→' + + block_container.attributes.title); - } + return null; + } + + this.importElementCounter++; + let new_container = this.$store.getters['courseware-containers/lastCreated']; + await this.unlockObject({ id: new_container.id, type: 'courseware-containers' }); + + if (container.blocks?.length) { + let new_block = null; + for (var k = 0; k < container.blocks.length; k++) { + new_block = await this.importBlock(container.blocks[k], new_container, files, structuralElement); + if (new_block !== null) { + this.importElementCounter++; + try { + await this.updateContainerPayload(new_container, structuralElement.id, container.blocks[k].id, new_block.id); + } catch(error) { + this.currentImportErrors.push(this.$gettext('Abschnittdaten sind beschädigt. Möglicherweise werden nicht alle Blöcke dargestellt') + ': ' + + structuralElement.attributes.title + '→' + + block_container.attributes.title); } } } @@ -164,7 +267,7 @@ export default { }); } catch(error) { - this.currentImportErrors.push(this.$gettext('Blockdaten sind beschädigt. Es werden die Standardwerte eingesetzt.') + ': ' + this.currentImportErrors.push(this.$gettext('Blockdaten sind beschädigt. Es werden die Standardwerte eingesetzt') + ': ' + element.attributes.title + '→' + block_container.attributes.title + '→' + block.attributes.title); @@ -194,20 +297,37 @@ export default { }, async uploadAllFiles(files) { + let createFolder = false; + for (let i = 0; i < files.length; i++) { + if (files[i].folder !== null) { + createFolder = true; + } + } + if (files.length === 0 || !createFolder) { + this.setImportFilesProgress(100); + this.setImportFilesState(''); + return true; + } // create folder for importing the files into this.setImportFilesProgress(0); this.setImportFilesState(''); let now = new Date(); this.setImportFilesState(this.$gettext('Lege Import Ordner an...')); - let main_folder = await this.createRootFolder({ - context: this.context, - folder: { - type: 'StandardFolder', - name: ' CoursewareImport ' - + now.toLocaleString('de-DE', { timeZone: 'UTC' }) - + ' ' + now.getMilliseconds(), - } - }); + let main_folder = null; + try { + main_folder = await this.createRootFolder({ + context: this.context, + folder: { + type: 'StandardFolder', + name: ' CoursewareImport ' + + now.toLocaleString('de-DE', { timeZone: 'UTC' }) + + ' ' + now.getMilliseconds(), + } + }); + } catch(error) { + this.currentImportErrors.push(this.$gettext('Anlegen des Import-Ordners fehlgeschlagen.')); + } + let folders = {}; diff --git a/resources/vue/mixins/courseware/task-helper.js b/resources/vue/mixins/courseware/task-helper.js new file mode 100755 index 0000000000000000000000000000000000000000..ecf4ff304921c3456d2a117dff14b47b6612ae61 --- /dev/null +++ b/resources/vue/mixins/courseware/task-helper.js @@ -0,0 +1,66 @@ +export default { + methods: { + getStatus(task) { + let status = {}; + const now = new Date(Date.now()); + const submissionDate = new Date(task.attributes['submission-date']); + let limit = new Date(); + limit.setDate(now.getDate() + 3); + status.canSubmit = true; + + if (now < submissionDate) { + status.shape = 'span-empty'; + status.role = 'status-green'; + status.description = this.$gettext('Aufgabe bereit'); + } + if (task.attributes.renewal !== 'granted') { + if (limit > submissionDate) { + status.shape = 'span-3quarter'; + status.role = 'status-yellow'; + status.description = this.$gettext('Aufgabe muss bald abgegeben werden'); + } + + if (now >= submissionDate) { + status.canSubmit = false; + status.shape = 'span-full'; + status.role = 'status-red'; + status.description = this.$gettext('Abgabe ist nicht bis zur Abgabefrist erfolgt'); + } + } else { + const renewalDate = new Date(task.attributes['renewal-date']); + if (limit > renewalDate) { + status.shape = 'span-3quarter'; + status.role = 'status-yellow'; + status.description = this.$gettext('Aufgabe muss bald abgegeben werden'); + } + + if (now >= renewalDate) { + status.canSubmit = false; + status.shape = 'span-full'; + status.role = 'status-red'; + status.description = this.$gettext('Abgabe ist nicht bis zur verlängerten Abgabefrist erfolgt'); + } + } + + if (task.attributes.submitted) { + status.shape = 'span-full'; + status.role = 'status-green'; + status.description = this.$gettext('Aufgabe abgegeben'); + } + + return status; + }, + getLinkToElement(elementId) { + return ( + STUDIP.URLHelper.base_url + + 'dispatch.php/course/courseware/?cid=' + + STUDIP.URLHelper.parameters.cid + + '#/structural_element/' + + elementId + ); + }, + getReadableDate(date) { + return new Date(date).toLocaleDateString(); + }, + }, +}; diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js index 94d537824859dfe4113a8957b8fd6e54ba2ec03f..8b67c758b3058eb69dfd51c82c8685cba91c4354 100755 --- a/resources/vue/store/courseware/courseware.module.js +++ b/resources/vue/store/courseware/courseware.module.js @@ -321,10 +321,10 @@ export const actions = { // console.log(resp); }); }, - async copyStructuralElement({ dispatch, getters, rootGetters }, { parentId, element }) { - const copy = { data: { parent_id: parentId, }, }; + async copyStructuralElement({ dispatch, getters, rootGetters }, { parentId, elementId, removePurpose, migrate }) { + const copy = { data: { parent_id: parentId, remove_purpose: removePurpose, migrate: migrate } }; - const result = await state.httpClient.post(`courseware-structural-elements/${element.id}/copy`, copy); + const result = await state.httpClient.post(`courseware-structural-elements/${elementId}/copy`, copy); const id = result.data.data.id; await dispatch('loadStructuralElement', id);