Skip to content
Snippets Groups Projects
Commit a658d710 authored by Viktoria Wiebe's avatar Viktoria Wiebe Committed by Ron Lucke
Browse files

TIC 3265 - option to add links to coursewares in link block and table of contents

Closes #3265

Merge request studip/studip!2646
parent 5df90ba5
No related branches found
No related tags found
No related merge requests found
Showing
with 643 additions and 26 deletions
......@@ -168,6 +168,9 @@ class CoursewareInstancesUpdate extends JsonApiController
$resetProgressSettings = $get('data.attributes.reset-progress-settings');
$instance->setResetProgressSettings($resetProgressSettings);
$linkedUnits = $get('data.attributes.linked-units');
$instance->setLinkedUnits($linkedUnits);
// Store changes in unit configuration.
$instance->getUnit()->store();
......
......@@ -46,7 +46,8 @@ class Instance extends SchemaProvider
'reminder-settings' => $resource->getReminderSettings(),
'reset-progress-settings' => $resource->getResetProgressSettings(),
'root-id' => $resource->getRoot()->id,
'is-teacher' => $GLOBALS['perm']->have_studip_perm($resource->getEditingPermissionLevel(), $resource->getRoot()->range_id)
'is-teacher' => $GLOBALS['perm']->have_studip_perm($resource->getEditingPermissionLevel(), $resource->getRoot()->range_id),
'linked-units' => $resource->getLinkedUnits()
];
}
......
......@@ -8,6 +8,9 @@
"target": {
"type": "string"
},
"unit-target": {
"type": "string"
},
"url": {
"type": "string"
},
......
......@@ -30,8 +30,9 @@ class Link extends BlockType
public function initialPayload(): array
{
return [
'type' => '',
'type' => 'external',
'target' => '',
'unit-target' => '',
'url' => '',
'title' => '',
];
......@@ -61,9 +62,21 @@ class Link extends BlockType
public static function getTags(): array
{
return [
_('URL'), _('Verlinkung'), _('Webseite'), _('extern'), _('weiterleiten'),
_('Material'), _('Zusatz'), _('Weiterleitung'), _('intern'), _('Verweis'),
_('Index'), _('Hyperlink'), _('Quellenangabe'), _('Linkliste'), _('Linksammlung')
_('URL'),
_('Verlinkung'),
_('Webseite'),
_('extern'),
_('weiterleiten'),
_('Material'),
_('Zusatz'),
_('Weiterleitung'),
_('intern'),
_('Verweis'),
_('Index'),
_('Hyperlink'),
_('Quellenangabe'),
_('Linkliste'),
_('Linksammlung')
];
}
}
......@@ -585,4 +585,37 @@ class Instance
return $data;
}
/*
*
* LINKED UNITS
*
*/
public function getLinkedUnits(): array
{
$config = $this->unit->config->getArrayCopy();
if (array_key_exists('linked_units', $config)) {
return $config['linked_units'];
}
return [];
}
public function setLinkedUnits(array $units): void
{
$this->validateLinkedUnits($units);
$this->unit->config['linked_units'] = $units;
}
public function isValidLinkedUnits($units): bool
{
return is_array($units);
}
private function validateLinkedUnits($units): void
{
if (!$this->isValidLinkedUnits($units)) {
throw new \InvalidArgumentException('Invalid linked units for courseware.');
}
}
}
......@@ -32,3 +32,4 @@
@import './courseware/layouts/talk-bubble.scss';
@import './courseware/layouts/tile.scss';
@import './courseware/layouts/tree.scss';
@import './courseware/layouts/tree-units.scss';
......@@ -13,6 +13,11 @@
.cw-link-title {
margin-left: 3em;
&.unit {
header {
font-size: 16px;
}
}
}
&:hover {
......@@ -69,5 +74,35 @@
}
}
}
&.unit {
height: unset;
display: flex;
.cw-unit-link {
background-repeat: no-repeat;
background-size: 270px 180px;
background-position: center;
min-width: 270px;
height: 180px;
}
.cw-link-title {
p {
color: var(--black);
}
}
&:hover {
background-color: unset;
border: solid thin var(--base-color);
.cw-link-title {
header {
color: var(--active-color);
}
}
}
}
}
}
......@@ -306,6 +306,16 @@ $consum_ribbon_width: calc(100% - 58px);
overflow: hidden;
padding: 0;
}
.cw-tools-contents {
display: flex;
flex-direction: column;
height: 100%;
.cw-tree {
flex-grow: 1;
}
}
}
}
}
......
.cw-tree-units {
.cw-tree-unit-title {
border-bottom: solid thin var(--content-color-40);
color: var(--black);
font-size: 16px;
}
ol {
list-style: none;
padding-left: 0;
}
.button.trash {
margin: 0;
min-width: unset;
height: 30px;
width: 30px;
border: none;
}
}
.cw-tree-unit-link {
display: flex;
flex-direction: row;
align-items: start;
justify-content: space-between;
margin: 0.5em 0;
.cw-tree-units-header {
display: flex;
flex-direction: row;
height: 100px;
margin-top: 8px;
.cw-tree-units-header-image {
height: 100px;
width: 150px;
min-width: 150px;
background-size: 100% auto;
background-repeat: no-repeat;
background-position: center;
background-color: var(--content-color-20);
}
.cw-tree-units-header-details {
margin: 0 8px;
display: -webkit-box;
overflow: hidden;
height: 100px;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
header {
margin: 0 0 6px 0;
font-size: 16px;
line-height: 16px;
}
p {
margin: 0;
color: var(--black);
}
}
}
}
.cw-tree-units-adder {
.add-element {
border: none;
cursor: pointer;
background-color: transparent;
height: 34px;
}
.cw-tree-units-adder-form {
display: flex;
label {
flex-grow: 1;
}
.button.cancel {
min-width: unset;
height: 30px;
width: 30px;
margin: 2px 0 0 5px;
border: none;
}
}
}
......@@ -26,20 +26,44 @@
</div>
</router-link>
</div>
<div v-if="currentType === 'unit' && inCourseContext">
<a v-if="currentUnitData" :href="currentUnitData.url">
<studip-ident-image
v-model="identimage"
:baseColor="currentUnitData.color?.hex ?? '#fff'"
:pattern="currentUnitData.title ?? ''"
/>
<div class="cw-link unit">
<div class="cw-unit-link" :style="previewImageStyle"></div>
<div class="cw-link-title unit">
<header>{{ currentUnitData.title }}</header>
<p>{{ currentUnitData.description }}</p>
</div>
</div>
</a>
<courseware-companion-box
v-else
mood="pointing"
:msgCompanion="$gettext('Bitte wählen Sie ein Lernmaterial als Ziel aus.')"
/>
</div>
</template>
<template v-if="canEdit" #edit>
<form class="default" @submit.prevent="">
<label>
{{ $gettext('Titel') }}
<input type="text" v-model="currentTitle" />
</label>
<label>
{{ $gettext('Art des Links') }}
<select v-model="currentType">
<option value="external">{{ $gettext('Extern') }}</option>
<option value="internal">{{ $gettext('Intern') }}</option>
<option v-if="inCourseContext" value="unit">
{{ $gettext('Lernmaterial in der Veranstaltung') }}
</option>
</select>
</label>
<label v-show="currentType !== 'unit'">
{{ $gettext('Titel') }}
<input type="text" v-model="currentTitle" />
</label>
<label v-show="currentType === 'external'">
{{ $gettext('URL') }}
<input type="text" v-model="currentUrl" @change="fixUrl" />
......@@ -47,11 +71,19 @@
<label v-show="currentType === 'internal'">
{{ $gettext('Seite') }}
<select v-model="currentTarget">
<option v-for="(el, index) in courseware" :key="index" :value="el.id">
<option v-for="(el, index) in filteredStructuralElements" :key="index" :value="el.id">
{{ el.attributes.title }}
</option>
</select>
</label>
<label v-show="currentType === 'unit' && inCourseContext">
{{ $gettext('Lernmaterial') }}
<select v-model="currentUnitTarget">
<option v-for="(unit, index) in units" :key="index" :value="unit.id">
{{ unit.title }}
</option>
</select>
</label>
</form>
</template>
<template #info>
......@@ -62,14 +94,16 @@
</template>
<script>
import StudipIdentImage from './../../StudipIdentImage.vue';
import BlockComponents from './block-components.js';
import blockMixin from '@/vue/mixins/courseware/block.js';
import colorMixin from '@/vue/mixins/courseware/colors.js';
import { mapActions, mapGetters } from 'vuex';
export default {
name: 'courseware-link-block',
mixins: [blockMixin],
components: Object.assign(BlockComponents, {}),
mixins: [blockMixin, colorMixin],
components: Object.assign(BlockComponents, { StudipIdentImage }),
props: {
block: Object,
canEdit: Boolean,
......@@ -79,13 +113,19 @@ export default {
return {
currentType: '',
currentTarget: '',
currentUnitTarget: '',
currentUrl: '',
currentTitle: '',
identimage: '',
};
},
computed: {
...mapGetters({
courseware: 'courseware-structural-elements/all',
context: 'context',
courseUnits: 'courseware-units/all',
unitById: 'courseware-units/byId',
allStructuralElements: 'courseware-structural-elements/all',
structuralElementById: 'courseware-structural-elements/byId',
}),
type() {
return this.block?.attributes?.payload?.type;
......@@ -93,28 +133,69 @@ export default {
target() {
return this.block?.attributes?.payload?.target;
},
unitTarget() {
return this.block?.attributes?.payload?.['unit-target'];
},
url() {
return this.block?.attributes?.payload?.url;
},
title() {
return this.block?.attributes?.payload?.title;
},
units() {
const allUnits = this.courseUnits;
const units = allUnits.filter((unit) => unit.id !== this.context.unit);
let unitData = [];
for (const unit of units) {
unitData.push(this.getUnitData(unit));
}
return unitData;
},
currentUnitData() {
return this.currentType === 'unit' ? this.getUnitData(this.unitById({ id: this.currentUnitTarget })) : null;
},
headerImageUrl() {
const headerUrl = this.rootElement(this.unitById({ id: this.currentUnitTarget }))?.relationships?.image?.meta?.[
'download-url'
];
return headerUrl ? headerUrl : null;
},
previewImageStyle() {
if (this.headerImageUrl) {
return { 'background-image': 'url(' + this.headerImageUrl + ')' };
}
return { 'background-image': 'url(' + this.identimage + ')' };
},
inCourseContext() {
return this.context.type === 'courses';
},
filteredStructuralElements() {
return this.allStructuralElements.filter(
(element) => element.relationships.unit.data.id === this.context.unit
);
},
},
mounted() {
this.initCurrentData();
},
methods: {
...mapActions({
loadCourseUnits: 'loadCourseUnits',
updateBlock: 'updateBlockInContainer',
companionWarning: 'companionWarning',
}),
initCurrentData() {
this.loadCourseUnits(this.context.id);
this.currentType = this.type;
this.currentTarget = this.target;
this.currentUnitTarget = this.unitTarget;
this.currentUrl = this.url;
this.currentTitle = this.title;
this.fixUrl();
this.currentTitle = this.title;
},
fixUrl() {
if (
this.currentUrl.indexOf('http://') !== 0 &&
......@@ -125,18 +206,53 @@ export default {
}
},
storeBlock() {
let attributes = {};
attributes.payload = {};
attributes.payload.type = this.currentType;
attributes.payload.target = this.currentTarget;
attributes.payload.url = this.currentUrl;
attributes.payload.title = this.currentTitle;
if (this.currentType === 'internal' && this.currentTarget === '') {
this.companionWarning({
info: this.$gettext('Bitte wählen Sie eine Seite als Ziel aus.'),
});
let empty = false;
let info = '';
let defaultTitle = '';
switch (this.currentType) {
case 'external':
info = this.$gettext('Bitte wählen Sie eine URL als Ziel aus.');
empty = this.currentUrl === '';
this.currentTarget = '';
this.currentUnitTarget = '';
this.currentTitle = this.currentTitle || this.currentUrl;
break;
case 'internal':
info = this.$gettext('Bitte wählen Sie eine Seite als Ziel aus.');
empty = this.currentTarget === '';
if (!empty) {
const element = this.filteredStructuralElements.find((el) => el.id === this.currentTarget);
defaultTitle = element.attributes.title;
}
this.currentUrl = '';
this.currentUnitTarget = '';
this.currentTitle = this.currentTitle || defaultTitle;
break;
case 'unit':
info = this.$gettext('Bitte wählen Sie ein Lernmaterial als Ziel aus.');
empty = this.currentUnitTarget === '';
this.currentTarget = '';
this.currentUrl = '';
this.currentTitle = '';
break;
}
if (empty) {
this.companionWarning({ info: info });
return false;
} else {
const attributes = {
payload: {
type: this.currentType,
target: this.currentTarget,
'unit-target': this.currentUnitTarget,
url: this.currentUrl,
title: this.currentTitle
}
};
this.updateBlock({
attributes: attributes,
blockId: this.block.id,
......@@ -144,7 +260,31 @@ export default {
});
}
},
getUnitData(unit) {
if (unit) {
const url = STUDIP.URLHelper.getURL('dispatch.php/course/courseware/courseware/' + unit.id, {
cid: this.context.id,
});
const element = this.rootElement(unit);
const color = this.mixinColors.find((color) => color.class === element.attributes.payload.color);
return {
id: unit.id,
url: url,
title: element.attributes.title,
description: element.attributes.payload.description,
color: color,
};
}
return null;
},
rootElement(unit) {
if (unit && this.context.type === 'courses') {
return this.structuralElementById({
id: unit.relationships['structural-element'].data.id,
});
}
},
}
};
</script>
<style scoped lang="scss">
......
......@@ -14,11 +14,13 @@
</div>
</component>
<courseware-tree v-if="structuralElements.length" />
<courseware-tree-units v-if="context.type === 'courses'" />
</div>
</template>
<script>
import CoursewareTree from './CoursewareTree.vue';
import CoursewareTreeUnits from './CoursewareTreeUnits.vue';
import colorMixin from '@/vue/mixins/courseware/colors.js';
import StudipIdentImage from './../../StudipIdentImage.vue';
import { mapGetters } from 'vuex';
......@@ -29,6 +31,7 @@ export default {
components: {
CoursewareTree,
StudipIdentImage,
CoursewareTreeUnits,
},
data() {
return {
......@@ -38,6 +41,7 @@ export default {
computed: {
...mapGetters({
courseware: 'courseware',
context: 'context',
relatedStructuralElement: 'courseware-structural-elements/related',
rootLayout: 'rootLayout',
structuralElements: 'courseware-structural-elements/all',
......
<template>
<li>
<div class="cw-tree-unit-link">
<a :href="url">
<div class="cw-tree-units-header">
<studip-ident-image
v-model="identimage"
:baseColor="color.hex ?? '#fff'"
:pattern="rootElement.title ?? '-'"
/>
<div class="cw-tree-units-header-image" :style="style"></div>
<div class="cw-tree-units-header-details">
<header>
{{ title }}
</header>
<p>{{ description }}</p>
</div>
</div>
</a>
<button v-if="canEditRoot" class="button trash" :title="$gettext('Link entfernen')" @click.prevent="removeUnitLink">
</button>
</div>
</li>
</template>
<script>
import StudipIdentImage from './../../StudipIdentImage.vue';
import colorMixin from '@/vue/mixins/courseware/colors.js';
import { mapActions, mapGetters } from 'vuex';
export default {
name: 'CoursewareTreeUnit',
mixins: [colorMixin],
components: {
StudipIdentImage,
},
props: {
unit: {
type: Object,
required: true,
},
canEditRoot: {
type: Boolean,
default: false
}
},
data() {
return {
identimage: '',
};
},
computed: {
...mapGetters({
context: 'context',
structuralElementById: 'courseware-structural-elements/byId',
}),
rootElement() {
return this.structuralElementById({
id: this.unit.relationships['structural-element'].data.id,
});
},
title() {
return this.rootElement.attributes.title;
},
description() {
return this.rootElement.attributes.payload.description;
},
url() {
return STUDIP.URLHelper.getURL('dispatch.php/course/courseware/courseware/' + this.unit.id, {
cid: this.context.id,
});
},
color() {
return this.mixinColors.find((color) => color.class === this.rootElement.attributes.payload.color);
},
style() {
const imageUrl = this.rootElement.relationships?.image?.meta?.['download-url'];
if (imageUrl) {
return { 'background-image': 'url(' + imageUrl + ')' };
}
return { 'background-image': 'url(' + this.identimage + ')' };
},
},
methods: {
removeUnitLink() {
this.$emit('removeUnitLink', this.unit.id);
},
},
};
</script>
<template>
<div v-if="canEditRoot || linkedUnits.length > 0" class="cw-tree-units">
<div class="cw-tree-unit-title">{{ $gettext('Weitere Lernmaterialien') }}</div>
<div v-if="!processing">
<ol v-if="linkedUnits.length > 0">
<courseware-tree-unit
v-for="unit in linkedUnits"
:unit="unit"
:canEditRoot="canEditRoot"
:key="unit.id"
@removeUnitLink="removeUnitLink"
></courseware-tree-unit>
</ol>
<div v-if="canEditRoot && units.length > 0" class="cw-tree-units-adder">
<form v-if="showForm" class="default cw-tree-units-adder-form" @submit.prevent="">
<label>
<span class="sr-only">{{ $gettext('Lernmaterial') }}</span>
<select v-model="selectedUnit" name="addUnit" @change="addUnitLink">
<option v-show="false" value="" disabled>
{{ $gettext('Link zum Lernmaterial auswählen') }}
</option>
<option v-for="(unit, index) in units" :key="index" :value="unit.id">
{{ getUnitTitle(unit) }}
</option>
</select>
</label>
<button
v-if="canEditRoot"
class="button cancel"
:title="$gettext('Auswahl abbrechen')"
@click.prevent="showForm = false"
></button>
</form>
<button
v-else
class="add-element"
:title="$gettext('Link zum Lernmaterial hinzufügen')"
@click="showForm = true"
>
<studip-icon shape="add" />
</button>
</div>
</div>
<studip-progress-indicator v-else :description="$gettext('Vorgang wird bearbeitet...')" />
</div>
</template>
<script>
import CoursewareTreeUnit from './CoursewareTreeUnit.vue';
import StudipProgressIndicator from '../../StudipProgressIndicator.vue';
import colorMixin from '@/vue/mixins/courseware/colors.js';
import { mapActions, mapGetters } from 'vuex';
export default {
name: 'CoursewareTreeUnits',
mixins: [colorMixin],
components: {
CoursewareTreeUnit,
StudipProgressIndicator,
},
data() {
return {
processing: false,
showForm: false,
selectedUnit: '',
currentInstance: null,
identimage: '',
};
},
computed: {
...mapGetters({
context: 'context',
courseUnits: 'courseware-units/all',
currentRootElement: 'currentRootElement',
unitById: 'courseware-units/byId',
instanceById: 'courseware-instances/byId',
structuralElementById: 'courseware-structural-elements/byId',
}),
instance() {
if (this.context.type === 'courses') {
return this.instanceById({ id: 'course_' + this.context.id + '_' + this.context.unit });
} else {
return this.instanceById({ id: 'user_' + this.context.id + '_' + this.context.unit });
}
},
canEditRoot() {
return this.currentRootElement?.attributes['can-edit'];
},
units() {
// returns all course units that are not already linked
const units = this.courseUnits;
const unitsWithoutSelf = units.filter((unit) => unit.id !== this.context.unit);
const linkedUnits = this.currentInstance?.attributes['linked-units'];
if (linkedUnits) {
return unitsWithoutSelf.filter((unit) => !this.instance.attributes['linked-units'].includes(unit.id));
} else {
return unitsWithoutSelf;
}
},
linkedUnits() {
// returns the required unit data of all linked units
const units = this.courseUnits;
const linkedUnitIds = this.currentInstance?.attributes['linked-units'];
if (linkedUnitIds) {
// filter out not linked units
const filteredUnits = units.filter((unit) =>
this.instance.attributes['linked-units'].includes(unit.id)
);
// map units to their unit ids instead of array keys to return the correct order
const mappedUnits = new Map(filteredUnits.map((unit) => [unit.id, unit]));
return linkedUnitIds.map((unit) => mappedUnits.get(unit));
}
return [];
},
},
mounted() {
this.initData();
},
methods: {
...mapActions({
loadCourseUnits: 'loadCourseUnits',
storeCoursewareLinkedUnits: 'storeCoursewareLinkedUnits',
}),
async initData() {
if (this.context.type === 'courses') {
this.currentInstance = this.instance;
const linkedUnits = this.currentInstance?.attributes['linked-units'];
if (this.canEditRoot || linkedUnits.length > 0) {
this.processing = true;
await this.loadCourseUnits(this.context.id);
this.processing = false;
}
}
},
getUnitTitle(unit) {
const rootElement = this.structuralElementById({
id: unit.relationships['structural-element'].data.id,
});
return rootElement.attributes.title;
},
async addUnitLink() {
this.showForm = false;
this.processing = true;
const linkedUnits = this.currentInstance.attributes['linked-units'];
if (!linkedUnits) {
await this.storeCoursewareLinkedUnits({
instance: this.currentInstance,
linkedUnits: [this.selectedUnit],
});
} else if (!linkedUnits.includes(this.selectedUnit)) {
this.currentInstance.attributes['linked-units'].push(this.selectedUnit);
await this.storeCoursewareLinkedUnits({
instance: this.currentInstance,
linkedUnits: linkedUnits,
});
}
this.processing = false;
},
async removeUnitLink(id) {
let linkedUnits = this.currentInstance.attributes['linked-units'].filter((unitId) => unitId !== id);
await this.storeCoursewareLinkedUnits({
instance: this.currentInstance,
linkedUnits: linkedUnits,
});
this.selectedUnit = '';
},
},
};
</script>
<style lang="scss">
.cw-tree-units {
.progress-indicator-wrapper {
margin-top: 15px;
}
}
</style>
\ No newline at end of file
......@@ -156,6 +156,7 @@ const mountApp = async (STUDIP, createApp, element) => {
if (entry_type === 'courses') {
store.dispatch('loadProgresses');
await store.dispatch('setFeedbackSettings', feedbackSettings);
await store.dispatch('courseware-units/loadById', { id: unit_id, options: {include: 'structural-element'} });
}
store.dispatch('coursewareCurrentElement', elem_id);
......
......@@ -96,6 +96,10 @@ const getters = {
rootId(state, getters) {
return getters.courseware?.relationships?.root?.data?.id;
},
currentRootElement(state, getters, rootState, rootGetters) {
const id = getters.rootId;
return rootGetters['courseware-structural-elements/byId']({ id });
},
currentElement(state) {
return state.currentElement;
},
......@@ -725,6 +729,13 @@ export const actions = {
return dispatch('courseware-instances/update', courseware, { root: true });
},
async storeCoursewareLinkedUnits({ dispatch, getters }, { linkedUnits }) {
const courseware = getters.courseware;
courseware.attributes['linked-units'] = linkedUnits;
return dispatch('courseware-instances/update', courseware, { root: true });
},
sortChildrenInStructualElements({ dispatch }, { parent, children }) {
const childrenResourceIdentifiers = children.map(({ type, id }) => ({ type, id }));
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment