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