diff --git a/db/migrations/5.2.10_add_link_to_cw_structural_elements.php b/db/migrations/5.2.10_add_link_to_cw_structural_elements.php new file mode 100644 index 0000000000000000000000000000000000000000..81ffaf7392e1831388e4d9c24b5a680f0d8a5c2f --- /dev/null +++ b/db/migrations/5.2.10_add_link_to_cw_structural_elements.php @@ -0,0 +1,27 @@ +<?php + +final class AddLinkToCwStructuralElements extends Migration +{ + public function description() + { + return 'Adds columns for link funtions to cw_structural_elements table'; + } + + public function up() + { + DBManager::get()->exec(" + ALTER TABLE `cw_structural_elements` + ADD COLUMN `is_link` tinyint(1) NOT NULL AFTER `parent_id`, + ADD COLUMN `target_id` int(11) DEFAULT NULL AFTER `is_link` + "); + } + + public function down() + { + DBManager::get()->exec(" + ALTER TABLE `cw_structural_elements` + DROP COLUMN `is_link`, + DROP COLUMN `target_id` + "); + } +} diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index 408e57286fc0f06b450ba800f44a56bfa8303426..4db0b6999629cbb313f14a20b38755c0103b03f0 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -396,6 +396,7 @@ class RouteMap // not a JSON route $group->post('/courseware-structural-elements/{id}/copy', Routes\Courseware\StructuralElementsCopy::class); + $group->post('/courseware-structural-elements/{id}/link', Routes\Courseware\StructuralElementsLink::class); $group->get('/courseware-structural-elements/{id}/comments', Routes\Courseware\StructuralElementCommentsOfStructuralElementsIndex::class); $group->post('/courseware-structural-element-comments', Routes\Courseware\StructuralElementCommentsCreate::class); diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsLink.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsLink.php new file mode 100644 index 0000000000000000000000000000000000000000..1e83514f33240698c12402a41b035c62291e71ff --- /dev/null +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsLink.php @@ -0,0 +1,60 @@ +<?php + +namespace JsonApi\Routes\Courseware; + +use JsonApi\NonJsonApiController; +use Courseware\StructuralElement; +use JsonApi\Errors\AuthorizationFailedException; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Link an courseware structural element to another courseware structural element + * + * @author Ron Lucke <lucke@elan-ev.de> + * @license GPL2 or any later version + * + * @since Stud.IP 5.2 + */ + +class StructuralElementsLink extends NonJsonApiController +{ + public function __invoke(Request $request, Response $response, array $args) + { + $data = $request->getParsedBody()['data']; + + $targetElement = StructuralElement::find($args['id']); + $parent = StructuralElement::find($data['parent_id']); + $user = $this->getUser($request); + + if (!Authority::canCreateStructuralElement($user, $parent) || !Authority::canUpdateStructuralElement($user, $targetElement)) { + throw new AuthorizationFailedException(); + } + + $newElement = $this->linkElement($user, $targetElement, $parent); + + return $this->redirectToStructuralElement($response, $newElement); + } + + private function linkElement(\User $user, StructuralElement $targetElement, StructuralElement $parent) + { + $newElement = $targetElement->link($user, $parent); + + return $newElement; + } + + /** + * @SuppressWarnings(PHPMD.Superglobals) + */ + private function redirectToStructuralElement(Response $response, StructuralElement $resource): Response + { + $pathinfo = $this->getSchema($resource) + ->getSelfLink($resource) + ->getStringRepresentation($this->container->get('json-api-integration-urlPrefix')); + $old = \URLHelper::setBaseURL($GLOBALS['ABSOLUTE_URI_STUDIP']); + $url = \URLHelper::getURL($pathinfo, [], true); + \URLHelper::setBaseURL($old); + + return $response->withHeader('Location', $url)->withStatus(303); + } +} \ No newline at end of file diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsShow.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsShow.php index 1c527e45712eaffd93ab39963b4b3250e441fc2d..f4c0766ecbe3b6e11364999b67677d60b112b43c 100755 --- a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsShow.php +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsShow.php @@ -29,6 +29,7 @@ class StructuralElementsShow extends JsonApiController 'editor', 'owner', 'parent', + 'target' ]; /** diff --git a/lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php b/lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php index 49bd4cdc81d9aea73a521dbcb6fda93257ed3643..b50b17979ef4a8b4534f5a83ba47bbe64db741d2 100755 --- a/lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php +++ b/lib/classes/JsonApi/Schemas/Courseware/StructuralElement.php @@ -51,7 +51,8 @@ class StructuralElement extends SchemaProvider 'write-approval' => $resource['write_approval']->getIterator(), 'copy-approval' => $resource['copy_approval']->getIterator(), 'can-edit' => $resource->canEdit($user), - + 'is-link' => (int) $resource['is_link'], + 'target-id' => (int) $resource['target_id'], 'external-relations' => $resource['external_relations']->getIterator(), 'mkdate' => date('c', $resource['mkdate']), 'chdate' => date('c', $resource['chdate']), diff --git a/lib/models/Courseware/StructuralElement.php b/lib/models/Courseware/StructuralElement.php index ac6116f205edd02d9554d7aecd328b4d88a88157..7a127dbe18efe2b3c3bf38845e4b44a68b9b61e2 100755 --- a/lib/models/Courseware/StructuralElement.php +++ b/lib/models/Courseware/StructuralElement.php @@ -16,6 +16,8 @@ use User; * * @property int $id database column * @property int $parent_id database column + * @property int $is_link database column + * @property int $target_id database column * @property string $range_id database column * @property string $range_type database column * @property string $owner_id database column @@ -269,8 +271,16 @@ class StructuralElement extends \SimpleORMap switch ($this->range_type) { case 'user': // Kontext "user": Nutzende können nur ihre eigenen Strukturknoten sehen. - return $this->range_id === $user->id; + if ($this->range_id === $user->id) { + return true; + } + $link = StructuralElement::findOneBySQL('target_id = ?', [$this->id]); + if ($link) { + return true; + } + + return false; case 'course': if (!$GLOBALS['perm']->have_studip_perm('user', $this->range_id, $user->id)) { return false; @@ -776,6 +786,39 @@ SQL; } } + public function link(User $user, StructuralElement $parent): StructuralElement + { + $element = self::build([ + 'parent_id' => $parent->id, + 'is_link' => 1, + 'target_id' => $this->id, + 'range_id' => $parent->range_id, + 'range_type' => $parent->range_type, + 'owner_id' => $user->id, + 'editor_id' => $user->id, + 'edit_blocker_id' => null, + 'title' => $this->title, + 'purpose' => $this->purpose, + 'position' => $parent->countChildren(), + 'payload' => $this->payload, + ]); + + $element->store(); + + $this->linkChildren($user, $element); + + return $element; + } + + private function linkChildren(User $user, StructuralElement $newElement): void + { + $children = self::findBySQL('parent_id = ?', [$this->id]); + + foreach ($children as $child) { + $child->link($user, $newElement); + } + } + public function pdfExport($user, bool $with_children = false) { $doc = new \ExportPDF('P', 'mm', 'A4', true, 'UTF-8', false); diff --git a/resources/assets/stylesheets/scss/courseware.scss b/resources/assets/stylesheets/scss/courseware.scss index 666d2e6208b1688a467d262e935d494c853d310b..4db8ab07d109179734cacb99f44834ef8f726fc1 100755 --- a/resources/assets/stylesheets/scss/courseware.scss +++ b/resources/assets/stylesheets/scss/courseware.scss @@ -2591,10 +2591,9 @@ m a n a g e r .cw-course-manager { display: flex; flex-wrap: wrap; - max-width: 1120px; + max-width: 1600px; .cw-course-manager-tabs { - max-width: 560px; width: calc(50% - 10px); margin-right: 20px; diff --git a/resources/vue/components/courseware/CoursewareCourseManager.vue b/resources/vue/components/courseware/CoursewareCourseManager.vue index 7d6ab4fea56c5752bcbf4819dca0be429309afcd..ad9ae98119c37fe473a2428bdb396f485e50558e 100755 --- a/resources/vue/components/courseware/CoursewareCourseManager.vue +++ b/resources/vue/components/courseware/CoursewareCourseManager.vue @@ -92,7 +92,11 @@ <courseware-manager-copy-selector @loadSelf="reloadElements" @reloadElement="reloadElements" /> </courseware-tab> - <courseware-tab :name="$gettext('Importieren')" :index="3"> + <courseware-tab :name="$gettext('Verknüpfen')" :index="3"> + <courseware-manager-link-selector @loadSelf="reloadElements" @reloadElement="reloadElements" /> + </courseware-tab> + + <courseware-tab :name="$gettext('Importieren')" :index="4"> <courseware-manager-import /> </courseware-tab> <courseware-tab v-if="context.type === 'courses'" :name="$gettext('Aufgabe verteilen')" :index="4"> @@ -109,6 +113,7 @@ import CoursewareTab from './CoursewareTab.vue'; import CoursewareCollapsibleBox from './CoursewareCollapsibleBox.vue'; import CoursewareManagerElement from './CoursewareManagerElement.vue'; import CoursewareManagerCopySelector from './CoursewareManagerCopySelector.vue'; +import CoursewareManagerLinkSelector from './CoursewareManagerLinkSelector.vue'; import CoursewareManagerTaskDistributor from './CoursewareManagerTaskDistributor.vue'; import CoursewareCompanionOverlay from './CoursewareCompanionOverlay.vue'; import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; @@ -124,6 +129,7 @@ export default { CoursewareCollapsibleBox, CoursewareManagerElement, CoursewareManagerCopySelector, + CoursewareManagerLinkSelector, CoursewareCompanionOverlay, CoursewareCompanionBox, CoursewareManagerTaskDistributor, diff --git a/resources/vue/components/courseware/CoursewareManagerElement.vue b/resources/vue/components/courseware/CoursewareManagerElement.vue index 7dfe67b8836fb89667b74932fc8a7b448ba3a169..3e8a460b12755c4a23a29780de156e46c1e645c4 100755 --- a/resources/vue/components/courseware/CoursewareManagerElement.vue +++ b/resources/vue/components/courseware/CoursewareManagerElement.vue @@ -29,6 +29,7 @@ </header> </div> <courseware-collapsible-box + v-if="!elementsOnly" :open="true" :title="$gettext('Abschnitt')" class="cw-manager-element-containers" @@ -137,7 +138,7 @@ export default { props: { type: { validator(value) { - return ['current', 'self', 'remote', 'own','import'].includes(value); + return ['current', 'self', 'remote', 'own', 'import', 'link'].includes(value); }, }, remoteCoursewareRangeId: String, @@ -147,6 +148,9 @@ export default { }, moveSelfChildPossible: { default: true + }, + elementsOnly: { + default: false } }, data() { @@ -162,9 +166,11 @@ export default { discardStateArrayContainers: [], insertingInProgress: false, copyingFailed: false, + linkingFailed: false, text: { inProgress: this.$gettext('Vorgang läuft. Bitte warten Sie einen Moment.'), copyProcessFailed: [], + linkProcessFailed: [], }, }; }, @@ -314,6 +320,7 @@ export default { updateStructuralElement: 'updateStructuralElement', deleteStructuralElement: 'deleteStructuralElement', copyStructuralElement: 'copyStructuralElement', + linkStructuralElement: 'linkStructuralElement', loadStructuralElement: 'loadStructuralElement', loadContainer: 'loadContainer', updateContainer: 'updateContainer', @@ -335,7 +342,7 @@ export default { }, validateSource(source) { - return (source === 'self' || source === 'remote' || source === 'own'); + return ['self', 'remote', 'own', 'link'].includes(source); }, afterInsertCompletion() { @@ -357,6 +364,11 @@ export default { this.insertingInProgress = false; }, + showFailedLinkProcessCompanion() { + this.linkingFailed = true; + this.insertingInProgress = false; + }, + async insertElement(data) { let source = data.source; let element = data.element; @@ -380,7 +392,8 @@ export default { await this.unlockObject({ id: element.id, type: 'courseware-structural-elements' }); this.loadStructuralElement(this.currentElement.id); this.$emit('reloadElement'); - } else if(source === 'remote' || source === 'own') { + } + if (source === 'remote' || source === 'own') { //create Element let parentId = this.filingData.parentItem.id; await this.copyStructuralElement({ @@ -394,6 +407,18 @@ export default { }); this.$emit('loadSelf', parentId); } + if (source === 'link') { + let parentId = this.filingData.parentItem.id; + await this.linkStructuralElement({ + parentId: parentId, + elementId: element.id, + }).catch((error) => { + let message = this.$gettextInterpolate('%{ pageTitle } konnte nicht verknüpft werden.', {pageTitle: element.attributes.title}); + this.text.linkProcessFailed.push(message); + this.showFailedLinkProcessCompanion(); + }); + this.$emit('loadSelf', parentId); + } this.afterInsertCompletion(); } }, @@ -583,6 +608,8 @@ export default { } this.copyingFailed = false; this.text.copyProcessFailed = []; + this.linkingFailed = false; + this.text.linkProcessFailed = []; } else { this.elementInserterActive = false; this.containerInserterActive = false; @@ -595,7 +622,7 @@ export default { }, watch: { filingData(newValue) { - if (!['self', 'remote', 'own', 'import'].includes(this.type)) { + if (!['self', 'remote', 'own', 'import', 'link'].includes(this.type)) { return false; } this.updateFilingData(newValue); diff --git a/resources/vue/components/courseware/CoursewareManagerLinkSelector.vue b/resources/vue/components/courseware/CoursewareManagerLinkSelector.vue new file mode 100644 index 0000000000000000000000000000000000000000..1f4774ff05af1661714595fda43fa83880d4ca89 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareManagerLinkSelector.vue @@ -0,0 +1,78 @@ +<template> + <div class="cw-manager-link-selector"> + <courseware-manager-element + v-if="ownId !== null" + type="link" + :currentElement="ownElement" + :elementsOnly="true" + @selectElement="setOwnId" + @loadSelf="loadSelf" + /> + </div> +</template> + +<script> +import CoursewareManagerElement from './CoursewareManagerElement.vue'; +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-manager-link-selector', + components: { + CoursewareManagerElement, + CoursewareCompanionBox, + }, + + data() { + return { + ownCoursewareInstance: {}, + ownId: null, + ownElement: {}, + } + }, + + computed: { + ...mapGetters({ + courseware: 'courseware', + structuralElementById: 'courseware-structural-elements/byId', + userId: 'userId', + }), + }, + + methods: { + ...mapActions({ + loadAnotherCourseware: 'courseware-structure/loadAnotherCourseware', + loadStructuralElementById: 'courseware-structural-elements/loadById', + }), + async loadOwnCourseware() { + this.ownCoursewareInstance = await this.loadAnotherCourseware({ id: this.userId, type: 'users' }); + if (this.ownCoursewareInstance !== null) { + await this.setOwnId(this.ownCoursewareInstance.relationships.root.data.id); + } else { + this.ownId = ''; + } + }, + async setOwnId(target) { + this.ownId = target; + const options = { + include: 'children' + }; + await this.loadStructuralElementById({ id: this.ownId, options }); + this.initOwn(); + }, + initOwn() { + this.ownElement = this.structuralElementById({ id: this.ownId }); + }, + reloadElement() { + this.$emit('reloadElement'); + }, + loadSelf(data) { + this.$emit('loadSelf', data); + }, + }, + + async mounted() { + await this.loadOwnCourseware(); + }, +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareStructuralElement.vue b/resources/vue/components/courseware/CoursewareStructuralElement.vue index 009c34d12981b016be821e91a2d6a4ee3966c647..36381bf82d45566776298639d52c3cee14e224c0 100755 --- a/resources/vue/components/courseware/CoursewareStructuralElement.vue +++ b/resources/vue/components/courseware/CoursewareStructuralElement.vue @@ -12,11 +12,11 @@ <router-link v-if="prevElement" :to="'/structural_element/' + prevElement.id"> <div class="cw-ribbon-button cw-ribbon-button-prev" :title="textRibbon.perv" /> </router-link> - <div v-else class="cw-ribbon-button cw-ribbon-button-prev-disabled" :title="$gettext('keine vorherige Seite')"/> + <div v-else class="cw-ribbon-button cw-ribbon-button-prev-disabled" :title="$gettext('Keine vorherige Seite')"/> <router-link v-if="nextElement" :to="'/structural_element/' + nextElement.id"> <div class="cw-ribbon-button cw-ribbon-button-next" :title="textRibbon.next" /> </router-link> - <div v-else class="cw-ribbon-button cw-ribbon-button-next-disabled" :title="$gettext('keine nächste Seite')"/> + <div v-else class="cw-ribbon-button cw-ribbon-button-next-disabled" :title="$gettext('Keine nächste Seite')"/> </template> <template #breadcrumbList> <li @@ -66,7 +66,7 @@ </courseware-ribbon> <div - v-if="canVisit && !sortMode" + v-if="canVisit && !sortMode && !isLink" class="cw-container-wrapper" :class="{ 'cw-container-wrapper-consume': consumeMode, @@ -97,6 +97,35 @@ class="cw-container-item" /> </div> + <div + v-if="isLink" + class="container-wrapper" + :class="{ + 'cw-container-wrapper-consume': consumeMode, + 'cw-container-wrapper-discuss': discussView, + }" + > + <courseware-structural-element-discussion + v-if="discussView" + :structuralElement="structuralElement" + :canEdit="canEdit" + /> + <courseware-companion-box + v-if="editView" + :msgCompanion="$gettextInterpolate('Dieser Inhalt ist aus den persönlichen Lerninhalten von %{ ownerName } verlinkt und kann nur dort bearbeitet werden.', { ownerName: ownerName })" + mood="pointing" + /> + <component + v-for="container in linkedContainers" + :key="container.id" + :is="containerComponent(container)" + :container="container" + :canEdit="false" + :canAddElements="false" + :isTeacher="userIsTeacher" + class="cw-container-item" + /> + </div> <div v-if="canVisit && canEdit && sortMode" class="cw-container-wrapper-sort-mode"> <draggable class="cw-structural-element-list-sort-mode" @@ -1058,6 +1087,9 @@ export default { discussView() { return this.viewMode === 'discuss'; }, + editView() { + return this.viewMode === 'edit'; + }, pdfExportURL() { if (this.context.type === 'users') { return STUDIP.URLHelper.getURL( @@ -1130,6 +1162,45 @@ export default { (!this.isRoot && this.canEdit) || !this.canEdit || (!this.noContainers && this.isRoot && this.canEdit) ); }, + + isLink() { + if (this.structuralElement) { + return this.structuralElement.attributes['is-link'] === 1; + } + + return false; + }, + + linkedElement() { + if (this.isLink) { + return this.structuralElementById({ id: this.structuralElement.attributes['target-id']}); + } + + return null; + }, + + linkedContainers() { + let containers = []; + let relatedContainers = this.linkedElement?.relationships?.containers?.data; + + if (relatedContainers) { + for (const container of relatedContainers) { + containers.push(this.containerById({ id: container.id})); + } + } + + return containers; + }, + owner() { + const user = this.$store.getters['users/related']({ + parent: { type: this.structuralElement.type, id: this.structuralElement.id }, + relationship: 'owner' + }); + return user ? user : null; + }, + ownerName() { + return this.owner ? this.owner.attributes['formatted-name'] : '?'; + }, }, methods: { @@ -1157,6 +1228,7 @@ export default { setStructuralElementSortMode: 'setStructuralElementSortMode', sortContainersInStructualElements: 'sortContainersInStructualElements', loadTask: 'loadTask', + loadStructuralElement: 'loadStructuralElement', }), initCurrent() { @@ -1423,6 +1495,10 @@ export default { taskId: this.structuralElement.relationships.task.data.id, }); } + + if (this.isLink) { + this.loadStructuralElement(this.structuralElement.attributes['target-id']); + } }, containers() { if (!this.sortMode) { diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js index bf443700f8c1e47671a1820ca6d51a5391e2fda0..d8ae6d77e03c83f1df2ef95e7e73860faf553d12 100755 --- a/resources/vue/store/courseware/courseware.module.js +++ b/resources/vue/store/courseware/courseware.module.js @@ -400,6 +400,19 @@ export const actions = { return dispatch('courseware-structure/loadDescendants', { root: newElement }); }, + async linkStructuralElement({ dispatch, getters, rootGetters }, { parentId, elementId }) { + const link = { data: { parent_id: parentId } }; + + const result = await state.httpClient.post(`courseware-structural-elements/${elementId}/link`, link); + const id = result.data.data.id; + await dispatch('loadStructuralElement', id); + + const newElement = rootGetters['courseware-structural-elements/byId']({ id }); + + return dispatch('courseware-structure/loadDescendants', { root: newElement }); + + }, + async createBlockInContainer({ dispatch }, { container, blockType }) { const block = { attributes: {