From 109bf5b76478e31e67b10ba6e50b3e4c5946f5a5 Mon Sep 17 00:00:00 2001
From: Ron Lucke <lucke@elan-ev.de>
Date: Fri, 1 Dec 2023 10:34:14 +0000
Subject: [PATCH] Lernmaterialien in Courseware sortieren

Closes #3032

Merge request studip/studip!2052
---
 db/migrations/5.5.8_add_pos_to_cw_units.php   |  27 +++
 lib/classes/JsonApi/RouteMap.php              |   1 +
 .../JsonApi/Routes/Courseware/Authority.php   |   5 +
 .../JsonApi/Routes/Courseware/UnitsCreate.php |   2 +
 .../JsonApi/Routes/Courseware/UnitsSort.php   |  67 ++++++
 .../JsonApi/Routes/Courseware/UnitsUpdate.php |   2 +-
 .../JsonApi/Schemas/Courseware/Unit.php       |   1 +
 lib/models/Courseware/Unit.php                |  45 ++++
 .../scss/courseware/content-courses.scss      |   2 +-
 .../scss/courseware/layouts/tile.scss         |  12 ++
 .../stylesheets/scss/courseware/shelf.scss    |  13 +-
 .../courseware/layouts/CoursewareTile.vue     | 110 +++++-----
 .../courseware/unit/CoursewareUnitItem.vue    |  10 +-
 .../courseware/unit/CoursewareUnitItems.vue   | 204 ++++++++++++++++--
 .../courseware/courseware-shelf.module.js     |  14 ++
 15 files changed, 445 insertions(+), 70 deletions(-)
 create mode 100644 db/migrations/5.5.8_add_pos_to_cw_units.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/UnitsSort.php

diff --git a/db/migrations/5.5.8_add_pos_to_cw_units.php b/db/migrations/5.5.8_add_pos_to_cw_units.php
new file mode 100644
index 00000000000..d3e37908aaf
--- /dev/null
+++ b/db/migrations/5.5.8_add_pos_to_cw_units.php
@@ -0,0 +1,27 @@
+<?php
+
+final class AddPosToCwUnits extends Migration
+{
+    public function description()
+    {
+        return 'Add field pos to table cw_units';
+    }
+
+    public function up()
+    {
+        $db = DBManager::get();
+        $db->exec("
+            ALTER TABLE `cw_units`
+            ADD COLUMN `position` INT(11) DEFAULT NULL AFTER `content_type`
+        ");
+    }
+
+    public function down()
+    {
+        $db = DBManager::get();
+        $db->exec("
+            ALTER TABLE `cw_units`
+            DROP COLUMN `position`
+        ");
+    }
+}
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index 2eb33a815e2..5542c31a4cb 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -515,6 +515,7 @@ class RouteMap
         $group->delete('/courseware-units/{id}', Routes\Courseware\UnitsDelete::class);
         // not a JSON route
         $group->post('/courseware-units/{id}/copy', Routes\Courseware\UnitsCopy::class);
+        $group->post('/{type:courses|users}/{id}/courseware-units/sort', Routes\Courseware\UnitsSort::class);
 
         $group->get('/courseware-clipboards', Routes\Courseware\ClipboardsIndex::class);
         $group->get('/users/{id}/courseware-clipboards', Routes\Courseware\UsersClipboardsIndex::class);
diff --git a/lib/classes/JsonApi/Routes/Courseware/Authority.php b/lib/classes/JsonApi/Routes/Courseware/Authority.php
index 0f837dee3da..3df103d81a2 100644
--- a/lib/classes/JsonApi/Routes/Courseware/Authority.php
+++ b/lib/classes/JsonApi/Routes/Courseware/Authority.php
@@ -493,6 +493,11 @@ class Authority
         return $GLOBALS['perm']->have_studip_perm('tutor', $range->id ,$user->id);
     }
 
+    public static function canSortUnit(User $user, \Range $range): bool
+    {
+        return self::canCreateUnit($user, $range);
+    }
+
     public static function canUpdateUnit(User $user, Unit $resource): bool
     {
         return $resource->canEdit($user);
diff --git a/lib/classes/JsonApi/Routes/Courseware/UnitsCreate.php b/lib/classes/JsonApi/Routes/Courseware/UnitsCreate.php
index f8fb17b6b52..6f88bca9721 100644
--- a/lib/classes/JsonApi/Routes/Courseware/UnitsCreate.php
+++ b/lib/classes/JsonApi/Routes/Courseware/UnitsCreate.php
@@ -2,6 +2,7 @@
 
 namespace JsonApi\Routes\Courseware;
 
+use Courseware\Unit;
 use JsonApi\Errors\AuthorizationFailedException;
 use JsonApi\Errors\RecordNotFoundException;
 use JsonApi\JsonApiController;
@@ -103,6 +104,7 @@ class UnitsCreate extends JsonApiController
             'range_type' => $range->getRangeType(),
             'structural_element_id' => $struct->id,
             'content_type' => 'courseware',
+            'position' => Unit::getNewPosition($range->getRangeId()),
             'creator_id' => $user->id,
             'public' => self::arrayGet($json, 'data.attributes.public', '0'),
             'release_date' => self::arrayGet($json, 'data.attributes.release-date'),
diff --git a/lib/classes/JsonApi/Routes/Courseware/UnitsSort.php b/lib/classes/JsonApi/Routes/Courseware/UnitsSort.php
new file mode 100644
index 00000000000..c307910a46f
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/UnitsSort.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace JsonApi\Routes\Courseware;
+
+use Courseware\Unit;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\BadRequestException;
+use JsonApi\JsonApiController;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Update multiple Unit positions.
+ *
+ * @author  Ron Lucke <lucke@elan-ev.de>
+ * @license GPL2 or any later version
+ *
+ * @since   Stud.IP 5.5
+ */
+
+class UnitsSort extends JsonApiController
+{
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $range = $this->getRange($args);
+        $user = $this->getUser($request);
+
+        if (!Authority::canSortUnit($user, $range)) {
+            throw new AuthorizationFailedException();
+        }
+        $data = $request->getParsedBody()['data'];
+        $positions = $data['positions'];
+        $unitCount = Unit::getNewPosition($range->id);
+
+        if (count($positions) !== $unitCount) {
+            throw new BadRequestException('Fehler beim Sortieren der Lernmaterialien.');
+        }
+
+        Unit::updatePositions($range, $positions);
+
+        $response = $response->withHeader('Content-Type', 'application/json');
+
+        return $response;
+    }
+
+    private function getRange($args): ?\Range
+    {
+        try {
+            return \RangeFactory::createRange(
+                $this->getRangeType($args['type']),
+                $args['id']
+            );
+        } catch (\Exception $e) {
+            return null;
+        }
+    }
+    
+    private function getRangeType($type): ?string
+    {
+        $type_map = [
+            'courses' => 'course',
+            'users'   => 'user',
+        ];
+
+        return $type_map[$type] ?? null;
+    }
+}
\ No newline at end of file
diff --git a/lib/classes/JsonApi/Routes/Courseware/UnitsUpdate.php b/lib/classes/JsonApi/Routes/Courseware/UnitsUpdate.php
index 75956c4c2cc..446d61e2872 100644
--- a/lib/classes/JsonApi/Routes/Courseware/UnitsUpdate.php
+++ b/lib/classes/JsonApi/Routes/Courseware/UnitsUpdate.php
@@ -13,7 +13,7 @@ use Psr\Http\Message\ResponseInterface as Response;
 use Psr\Http\Message\ServerRequestInterface as Request;
 
 /**
- * Update one Block.
+ * Update one Unit.
  */
 class UnitsUpdate extends JsonApiController
 {
diff --git a/lib/classes/JsonApi/Schemas/Courseware/Unit.php b/lib/classes/JsonApi/Schemas/Courseware/Unit.php
index 39d20138084..84c6ca21e2d 100644
--- a/lib/classes/JsonApi/Schemas/Courseware/Unit.php
+++ b/lib/classes/JsonApi/Schemas/Courseware/Unit.php
@@ -29,6 +29,7 @@ class Unit extends SchemaProvider
     {
         return [
             'content-type' => (string) $resource['content_type'],
+            'position' => (int) $resource['position'],
             'public' => (int) $resource['public'],
             'release-date' => $resource['release_date'] ? date('c', $resource['release_date']) : null,
             'withdraw-date' => $resource['withdraw_date'] ? date('c', $resource['withdraw_date']) : null,
diff --git a/lib/models/Courseware/Unit.php b/lib/models/Courseware/Unit.php
index e89b04dbd62..6f3535d7351 100644
--- a/lib/models/Courseware/Unit.php
+++ b/lib/models/Courseware/Unit.php
@@ -59,6 +59,8 @@ class Unit extends \SimpleORMap implements \PrivacyObject
             'foreign_key' => 'creator_id',
         ];
 
+        $config['registered_callbacks']['after_delete'][] = 'updatePositionsAfterDelete';
+
         parent::configure($config);
     }
 
@@ -127,4 +129,47 @@ class Unit extends \SimpleORMap implements \PrivacyObject
         }
         
     }
+
+    public static function getNewPosition($range_id): int
+    {
+        return static::countBySQL('range_id = ?', [$range_id]);
+    }
+
+    public function updatePositionsAfterDelete(): void
+    {
+        if (is_null($this->position)) {
+            return;
+        }
+
+        $db = \DBManager::get();
+        $stmt = $db->prepare(sprintf(
+            'UPDATE
+              %s
+            SET
+              position = position - 1
+            WHERE
+              range_id = :range_id AND
+              position > :position',
+            'cw_units'
+        ));
+        $stmt->bindValue(':range_id', $this->range_id);
+        $stmt->bindValue(':position', $this->position);
+        $stmt->execute();
+    }
+
+    public static function updatePositions($range, $positions): void
+    {
+        $db = \DBManager::get();
+        $query = sprintf(
+            'UPDATE
+                %s 
+            SET
+                position = FIND_IN_SET(id, ?) - 1
+            WHERE
+                range_id = ?',
+            'cw_units');
+        $args = array(join(',', $positions), $range->id);
+        $stmt = $db->prepare($query);
+        $stmt->execute($args);
+    }
 }
diff --git a/resources/assets/stylesheets/scss/courseware/content-courses.scss b/resources/assets/stylesheets/scss/courseware/content-courses.scss
index 16d712cf05c..0e64af99f6a 100644
--- a/resources/assets/stylesheets/scss/courseware/content-courses.scss
+++ b/resources/assets/stylesheets/scss/courseware/content-courses.scss
@@ -6,7 +6,7 @@
         font-size: 1.4em;
     }
 
-    ul.cw-tiles {
+    .cw-tiles {
         margin-bottom: 20px;
     }
 }
\ No newline at end of file
diff --git a/resources/assets/stylesheets/scss/courseware/layouts/tile.scss b/resources/assets/stylesheets/scss/courseware/layouts/tile.scss
index f4c795f3ec8..d668e696a9a 100644
--- a/resources/assets/stylesheets/scss/courseware/layouts/tile.scss
+++ b/resources/assets/stylesheets/scss/courseware/layouts/tile.scss
@@ -33,6 +33,18 @@
             @include background-icon(courseware, clickable, 128);
         }
 
+        .overlay-handle {
+            @extend .drag-handle;
+            background-color: $white;
+            background-position: center !important;
+            height: 22px;
+            padding: 4px 8px;
+            margin-top: 3px;
+            float: left;
+            border-left: solid thin var(--content-color-20);
+        }
+
+
         .overlay-text {
             padding: 6px 7px;
             margin: 4px;
diff --git a/resources/assets/stylesheets/scss/courseware/shelf.scss b/resources/assets/stylesheets/scss/courseware/shelf.scss
index a7b3eabdf5f..0b6c93b3633 100644
--- a/resources/assets/stylesheets/scss/courseware/shelf.scss
+++ b/resources/assets/stylesheets/scss/courseware/shelf.scss
@@ -50,4 +50,15 @@
     h2 {
         margin-top: 0;
     }
-}
\ No newline at end of file
+}
+
+.cw-unit-items {
+    .unit-ghost {
+        background: var(--white);
+        border: dashed 2px var(--content-color-40);
+    }
+    .unit-ghost .cw-tile {
+        opacity: 0;
+        height: 416px;
+    }
+}
diff --git a/resources/vue/components/courseware/layouts/CoursewareTile.vue b/resources/vue/components/courseware/layouts/CoursewareTile.vue
index f396769eb93..dd7e173bfb1 100644
--- a/resources/vue/components/courseware/layouts/CoursewareTile.vue
+++ b/resources/vue/components/courseware/layouts/CoursewareTile.vue
@@ -1,10 +1,15 @@
 <template>
     <component :is="tag" class="cw-tile" :class="[color]">
-        <div
-            class="preview-image"
-            :class="[hasImage ? '' : 'default-image']"
-            :style="previewImageStyle"
-        >
+        <div class="preview-image" :class="[hasImage ? '' : 'default-image']" :style="previewImageStyle">
+            <div
+                v-if="handle"
+                class="overlay-handle cw-tile-handle"
+                tabindex="0"
+                role="option"
+                aria-describedby="operation"
+                :id="handleId"
+                @keydown="$emit('handle-keydown', $event)"
+            ></div>
             <div class="overlay-text" v-if="hasImageOverlay">
                 <slot name="image-overlay"></slot>
             </div>
@@ -18,15 +23,10 @@
             :title="descriptionTitle"
             class="description"
         >
-            <header
-                :class="[icon ? 'description-icon-' + icon : '']"
-            >
+            <header :class="[icon ? 'description-icon-' + icon : '']">
                 {{ title }}
             </header>
-            <div
-                v-if="displayProgress"
-                :title="progressTitle"
-                class="progress-wrapper" >
+            <div v-if="displayProgress" :title="progressTitle" class="progress-wrapper">
                 <progress :value="progress" max="100">{{ progress }}</progress>
             </div>
             <div class="description-text-wrapper">
@@ -43,65 +43,72 @@
 import { mapGetters } from 'vuex';
 
 export default {
-    name: "courseware-tile",
+    name: 'courseware-tile',
     props: {
         tag: {
             type: String,
-            default: "div",
-            validator: tag => {
-                return ["div", "li"].includes(tag);
-            }
+            default: 'div',
+            validator: (tag) => {
+                return ['div', 'li'].includes(tag);
+            },
         },
         color: {
             type: String,
-            default: "studip-blue",
-            validator: value => {
+            default: 'studip-blue',
+            validator: (value) => {
                 return [
-                    "black",
-                    "charcoal",
-                    "royal-purple",
-                    "iguana-green",
-                    "queen-blue",
-                    "verdigris",
-                    "mulberry",
-                    "pumpkin",
-                    "sunglow",
-                    "apple-green",
-                    "studip-blue",
-                    "studip-lightblue",
-                    "studip-green",
-                    "studip-yellow",
-                    "studip-gray",
+                    'black',
+                    'charcoal',
+                    'royal-purple',
+                    'iguana-green',
+                    'queen-blue',
+                    'verdigris',
+                    'mulberry',
+                    'pumpkin',
+                    'sunglow',
+                    'apple-green',
+                    'studip-blue',
+                    'studip-lightblue',
+                    'studip-green',
+                    'studip-yellow',
+                    'studip-gray',
                 ].includes(value);
-            }
+            },
         },
         title: {
             type: String,
-            default: "–"
+            default: '–',
         },
         icon: {
-            type: String
+            type: String,
         },
         imageUrl: {
-            type: String
+            type: String,
         },
         displayProgress: {
             type: Boolean,
-            default: false
+            default: false,
         },
         progress: {
             type: Number,
-            validator: value => {
+            validator: (value) => {
                 return value >= 0 && value <= 100;
-            }
+            },
         },
         descriptionLink: {
             type: String,
-            default: ""
+            default: '',
         },
         descriptionTitle: {
             type: String,
-            default: ''
+            default: '',
+        },
+        handle: {
+            type: Boolean,
+            default: false,
+        },
+        handleId: {
+            type: String
         }
     },
     computed: {
@@ -109,19 +116,18 @@ export default {
             userIsTeacher: 'userIsTeacher'
         }),
         hasImage() {
-            return this.imageUrl !== "" && this.imageUrl !== undefined;
+            return this.imageUrl !== '' && this.imageUrl !== undefined;
         },
         hasImageOverlay() {
-            return this.$slots["image-overlay"] !== undefined;
+            return this.$slots['image-overlay'] !== undefined;
         },
         hasImageOverlayWithActionMenu() {
-            return this.$slots["image-overlay-with-action-menu"] !== undefined;
+            return this.$slots['image-overlay-with-action-menu'] !== undefined;
         },
         previewImageStyle() {
             if (this.hasImage) {
-                return { "background-image": "url(" + this.imageUrl + ")" };
-            }
-            else {
+                return { 'background-image': 'url(' + this.imageUrl + ')' };
+            } else {
                 return {};
             }
         },
@@ -133,13 +139,13 @@ export default {
         },
         hasDescriptionLink() {
             return this.descriptionLink !== '';
-        }
+        },
     },
     methods: {
         showProgress(e) {
             e.preventDefault();
-            this.$emit("showProgress");
-        }
+            this.$emit('showProgress');
+        },
     },
 }
 </script>
diff --git a/resources/vue/components/courseware/unit/CoursewareUnitItem.vue b/resources/vue/components/courseware/unit/CoursewareUnitItem.vue
index 74366d33806..80bf0a7d2bf 100644
--- a/resources/vue/components/courseware/unit/CoursewareUnitItem.vue
+++ b/resources/vue/components/courseware/unit/CoursewareUnitItem.vue
@@ -9,6 +9,9 @@
             :displayProgress="inCourseContext"
             :progress="progress"
             :imageUrl="imageUrl"
+            :handle="handle"
+            :handleId="'unit-handle-' + unit.id"
+            @handle-keydown="$emit('unit-keydown', $event)"
         >
             <template #image-overlay-with-action-menu>
                 <studip-action-menu
@@ -80,6 +83,10 @@ export default {
     },
     props: {
         unit: Object,
+        handle: {
+            type: Boolean,
+            default: true
+        }
     },
     data() {
         return {
@@ -191,7 +198,8 @@ export default {
         },
         async copy() {
             await this.copyUnit({unitId: this.unit.id, modified: null});
-            this.companionSuccess({ info: this.$gettext('Lernmaterial kopiert.') });        }
+            this.companionSuccess({ info: this.$gettext('Lernmaterial kopiert.') });
+        },
     }
 }
 </script>
diff --git a/resources/vue/components/courseware/unit/CoursewareUnitItems.vue b/resources/vue/components/courseware/unit/CoursewareUnitItems.vue
index 9d07e26ff05..f0973de6869 100644
--- a/resources/vue/components/courseware/unit/CoursewareUnitItems.vue
+++ b/resources/vue/components/courseware/unit/CoursewareUnitItems.vue
@@ -1,22 +1,53 @@
 <template>
     <div class="cw-unit-items">
         <h2 v-if="!inCourseContext && hasUnits">{{ $gettext('Persönliche Lernmaterialien') }}</h2>
-        <ul v-if="hasUnits" class="cw-tiles">
-            <courseware-unit-item v-for="unit in units" :key="unit.id" :unit="unit"/>
-        </ul>
+        <template v-if="hasUnits">
+            <ol v-if="(!userIsTeacher && inCourseContext) || units.length === 1" class="cw-tiles">
+                <courseware-unit-item v-for="unit in units" :key="unit.id" :unit="unit" :handle="false"/>
+            </ol>
+            <template v-else>
+                <span aria-live="assertive" class="assistive-text">{{ assistiveLive }}</span>
+                <span id="operation" class="assistive-text">
+                    {{ $gettext('Drücken Sie die Leertaste oder Entertaste, um neu anzuordnen.') }}
+                </span>
+                <draggable
+                    tag="ol"
+                    role="listbox"
+                    v-model="unitList"
+                    v-bind="dragOptions"
+                    handle=".cw-tile-handle"
+                    group="units"
+                    @start="isDragging = true"
+                    @end="dropUnit"
+                    ref="sortables"
+                    class="cw-tiles"
+                >
+                    <courseware-unit-item
+                        v-for="unit in unitList"
+                        :key="unit.id"
+                        :unit="unit"
+                        @unit-keydown="keyHandler($event, unit.id)"
+                    />
+                </draggable>
+            </template>
+        </template>
         <template v-if="!hasUnits && inCourseContext">
             <div v-if="userIsTeacher" class="cw-contents-overview-teaser">
                 <div class="cw-contents-overview-teaser-content">
                     <header>{{ $gettext('Lernmaterialien') }}</header>
                     <p>
-                        {{ $gettext('Mit Courseware können Sie interaktive, multimediale Lerninhalte erstellen und nutzen. ' +
+                        {{
+                            $gettext(
+                                'Mit Courseware können Sie interaktive, multimediale Lerninhalte erstellen und nutzen. ' +
                                     'Die Lerninhalte lassen sich hierarchisch unterteilen und können aus Texten, Videosequenzen, ' +
                                     'Aufgaben, Kommunikationselementen und einer Vielzahl weiterer Elemente bestehen. ' +
                                     'Fertige Lerninhalte können exportiert und in andere Kurse oder andere Installationen importiert werden. ' +
                                     'Courseware ist nicht nur für digitale Formate geeignet, sondern kann auch genutzt werden, ' +
                                     'um klassische Präsenzveranstaltungen mit Online-Anteilen zu ergänzen. Formate wie integriertes Lernen ' +
                                     '(Blended Learning) lassen sich mit Courseware ideal umsetzen. Kollaboratives Lernen kann dank Schreibrechtevergabe ' +
-                                    'und dem Einsatz von Courseware in Studiengruppen realisiert werden.') }}
+                                    'und dem Einsatz von Courseware in Studiengruppen realisiert werden.'
+                            )
+                        }}
                     </p>
                     <button class="button" @click="setShowUnitAddDialog(true)">
                         {{ $gettext('Neues Lernmaterial anlegen') }}
@@ -32,9 +63,15 @@
         <div v-if="!hasUnits && !inCourseContext" class="cw-contents-overview-teaser">
             <div class="cw-contents-overview-teaser-content">
                 <header>{{ $gettext('Ihre persönlichen Lernmaterialien') }}</header>
-                <p>{{ $gettext('Erstellen und verwalten Sie hier Ihre eigenen persönlichen Lernmaterialien in Form von ePorfolios, ' +
-                               'Vorlagen für Veranstaltungen oder einfach nur persönliche Inhalte für das Studium. ' +
-                               'Entwickeln Sie Ihre eigenen (Lehr-)Materialien für Studium oder die Lehre und teilen diese mit anderen Nutzenden.') }}</p>
+                <p>
+                    {{
+                        $gettext(
+                            'Erstellen und verwalten Sie hier Ihre eigenen persönlichen Lernmaterialien in Form von ePorfolios, ' +
+                                'Vorlagen für Veranstaltungen oder einfach nur persönliche Inhalte für das Studium. ' +
+                                'Entwickeln Sie Ihre eigenen (Lehr-)Materialien für Studium oder die Lehre und teilen diese mit anderen Nutzenden.'
+                        )
+                    }}
+                </p>
                 <button class="button" @click="setShowUnitAddDialog(true)">
                     {{ $gettext('Neues Lernmaterial anlegen') }}
                 </button>
@@ -46,7 +83,7 @@
 <script>
 import CoursewareCompanionBox from '../layouts/CoursewareCompanionBox.vue';
 import CoursewareUnitItem from './CoursewareUnitItem.vue';
-
+import draggable from 'vuedraggable';
 import { mapActions, mapGetters } from 'vuex';
 
 export default {
@@ -54,27 +91,166 @@ export default {
     components: {
         CoursewareCompanionBox,
         CoursewareUnitItem,
+        draggable,
+    },
+    data() {
+        return {
+            isDragging: false,
+            dragOptions: {
+                animation: 0,
+                disabled: false,
+                ghostClass: 'unit-ghost',
+            },
+            unitList: [],
+            assistiveLive: '',
+            keyboardSelected: null,
+        };
     },
     computed: {
         ...mapGetters({
             context: 'context',
             coursewareUnits: 'courseware-units/all',
-            userIsTeacher: 'userIsTeacher'
+            coursewareUnitById: 'courseware-units/byId',
+            structuralElementById: 'courseware-structural-elements/byId',
+            userIsTeacher: 'userIsTeacher',
         }),
         units() {
-            return this.coursewareUnits.filter(unit => unit.relationships.range.data.id === this.context.id) ?? [];
+            return (
+                this.coursewareUnits
+                    .filter((unit) => unit.relationships.range.data.id === this.context.id)
+                    .sort((a, b) => a.attributes.position - b.attributes.position) ?? []
+            );
         },
         hasUnits() {
             return this.units.length > 0;
         },
         inCourseContext() {
             return this.context.type === 'courses';
-        }
+        },
     },
     methods: {
         ...mapActions({
             setShowUnitAddDialog: 'setShowUnitAddDialog',
+            sortUnits: 'sortUnits',
         }),
-    }
-}
+        initCurrentData() {
+            this.unitList = this.units;
+        },
+        dropUnit() {
+            const positions = this.unitList.map((unit) => {
+                return parseInt(unit.id);
+            });
+            this.sortUnits({ positions: positions });
+        },
+        getUnitTitle(unitId) {
+            const unit = this.coursewareUnitById({ id: unitId });
+            const element =
+                this.structuralElementById({ id: unit.relationships['structural-element'].data.id }) ?? null;
+
+            return element?.attributes?.title ?? '';
+        },
+        keyHandler(e, unitId) {
+            switch (e.keyCode) {
+                case 27: // esc
+                    this.abortKeyboardSorting();
+                    break;
+                case 32: //space
+                case 13: //enter
+                    e.preventDefault();
+                    if (this.keyboardSelected) {
+                        this.storeKeyboardSorting();
+                    } else {
+                        this.keyboardSelected = { id: unitId, title: this.getUnitTitle(unitId) };
+                        const index = this.unitList.findIndex((unit) => unit.id === unitId);
+                        this.assistiveLive = this.$gettextInterpolate(
+                            this.$gettext(
+                                'Lernmaterial %{unitTitle} ausgewählt. Aktuelle Position in der Liste: %{pos} von %{listLength}. ' +
+                                    'Drücken Sie die Aufwärts- und Abwärtspfeiltasten, um die Position zu ändern, die Leertaste oder ' +
+                                    'Entertaste zum Ablegen, die Escape-Taste zum Abbrechen.'
+                            ),
+                            { unitTitle: this.keyboardSelected.title, pos: index + 1, listLength: this.unitList.length }
+                        );
+                    }
+                    break;
+            }
+            if (this.keyboardSelected) {
+                switch (e.keyCode) {
+                    case 9: //tab
+                        this.abortKeyboardSorting();
+                        break;
+                    case 37: // left
+                    case 38: // up
+                        e.preventDefault();
+                        this.moveItemUp(unitId);
+                        break;
+                    case 39: // right
+                    case 40: // down
+                        e.preventDefault();
+                        this.moveItemDown(unitId);
+                        break;
+                }
+            }
+        },
+        abortKeyboardSorting() {
+            this.assistiveLive = this.$gettextInterpolate(
+                this.$gettext('Lernmaterial %{unitTitle}, Neuordnung abgebrochen.'),
+                { unitTitle: this.keyboardSelected.title }
+            );
+            this.keyboardSelected = null;
+            this.initCurrentData();
+        },
+        storeKeyboardSorting() {
+            const index = this.unitList.findIndex((unit) => unit.id === this.keyboardSelected.id);
+            this.assistiveLive = this.$gettextInterpolate(
+                this.$gettext(
+                    'Lernmaterial %{unitTitle}, abgelegt. Endgültige Position in der Liste: %{pos} von %{listLength}.'
+                ),
+                { unitTitle: this.keyboardSelected.title, pos: index + 1, listLength: this.unitList.length }
+            );
+            this.keyboardSelected = null;
+            this.dropUnit();
+        },
+        moveItemUp(unitId) {
+            const currentIndex = this.unitList.findIndex((unit) => unit.id === unitId);
+            if (currentIndex !== 0) {
+                const newPos = currentIndex - 1;
+                this.unitList.splice(newPos, 0, this.unitList.splice(currentIndex, 1)[0]);
+                this.focusHandle(unitId);
+                this.assistiveLive = this.$gettextInterpolate(
+                    this.$gettext(
+                        'Lernmaterial %{unitTitle}. Aktuelle Position in der Liste: %{pos} von %{listLength}.'
+                    ),
+                    { unitTitle: this.keyboardSelected.title, pos: newPos + 1, listLength: this.unitList.length }
+                );
+            }
+        },
+        moveItemDown(unitId) {
+            const currentIndex = this.unitList.findIndex((unit) => unit.id === unitId);
+            if (this.unitList.length - 1 > currentIndex) {
+                const newPos = currentIndex + 1;
+                this.unitList.splice(newPos, 0, this.unitList.splice(currentIndex, 1)[0]);
+                this.focusHandle(unitId);
+                this.assistiveLive = this.$gettextInterpolate(
+                    this.$gettext(
+                        'Lernmaterial %{unitTitle}. Aktuelle Position in der Liste: %{pos} von %{listLength}.'
+                    ),
+                    { unitTitle: this.keyboardSelected.title, pos: newPos + 1, listLength: this.unitList.length }
+                );
+            }
+        },
+        focusHandle(unitId) {
+            this.$nextTick(() => {
+                document.getElementById('unit-handle-' + unitId).focus();
+            });
+        },
+    },
+    mounted() {
+        this.initCurrentData();
+    },
+    watch: {
+        units(newState) {
+            this.initCurrentData();
+        },
+    },
+};
 </script>
diff --git a/resources/vue/store/courseware/courseware-shelf.module.js b/resources/vue/store/courseware/courseware-shelf.module.js
index ac5be55bcd1..52cef5f7960 100644
--- a/resources/vue/store/courseware/courseware-shelf.module.js
+++ b/resources/vue/store/courseware/courseware-shelf.module.js
@@ -273,6 +273,20 @@ export const actions = {
 
         return dispatch(loadUnits, state.context.id);
     },
+    
+    async sortUnits({ dispatch, state }, data) {
+        let loadUnits = null;
+        if (state.context.type === 'courses') {
+            loadUnits = 'loadCourseUnits';
+        }
+        if (state.context.type === 'users') {
+            loadUnits = 'loadUserUnits';
+        }
+
+        await state.httpClient.post(`${state.context.type}/${state.context.id}/courseware-units/sort`, {data: data});
+        
+        return dispatch(loadUnits, state.context.id);
+    },
 
     async loadUsersCourses({ dispatch, rootGetters, state }, { userId, withCourseware }) {
         const parent = {
-- 
GitLab