Skip to content
Snippets Groups Projects
Commit 109bf5b7 authored by Ron Lucke's avatar Ron Lucke Committed by David Siegfried
Browse files

Lernmaterialien in Courseware sortieren

Closes #3032

Merge request studip/studip!2052
parent 18f82799
No related branches found
No related tags found
No related merge requests found
Showing
with 445 additions and 70 deletions
<?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`
");
}
}
......@@ -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);
......
......@@ -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);
......
......@@ -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'),
......
<?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
......@@ -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
{
......
......@@ -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,
......
......@@ -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);
}
}
......@@ -6,7 +6,7 @@
font-size: 1.4em;
}
ul.cw-tiles {
.cw-tiles {
margin-bottom: 20px;
}
}
\ No newline at end of file
......@@ -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;
......
......@@ -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;
}
}
<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>
......@@ -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>
<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>
......@@ -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 = {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment