Skip to content
Snippets Groups Projects
Commit 5da08f6c authored by Ron Lucke's avatar Ron Lucke
Browse files

Courseware: Übersichtsseite über Feedback und Kommentare

Closes #2689

Merge request studip/studip!1754
parent decf832c
No related branches found
No related tags found
No related merge requests found
Showing
with 1395 additions and 10 deletions
......@@ -91,6 +91,190 @@ class Course_CoursewareController extends CoursewareController
$this->render_pdf($element->pdfExport($user, $with_children), trim($element->title).'.pdf');
}
public function comments_overview_action(): void
{
Navigation::activateItem('course/courseware/comments');
$this->setCommentsOverviewSidebar();
}
public function comments_overview_data_action()
{
$user = User::findCurrent();
$cid = Request::get('cid');
$units = [];
$elements = [];
$containers = [];
$blocks = [];
$block_comments = [];
$block_feedbacks = [];
$element_comments = [];
$element_feedbacks = [];
$statement = DBManager::get()->prepare("
SELECT elem.id AS elem_id, container.id AS container_id, block.id AS block_id, comment.id AS comment_id
FROM `cw_block_comments` AS comment
INNER JOIN `cw_blocks` AS block ON (block.id = comment.block_id)
INNER JOIN `cw_containers` AS container ON (container.id = block.container_id)
INNER JOIN `cw_structural_elements` AS elem ON (elem.id = container.structural_element_id)
WHERE elem.range_type = 'course'
AND elem.range_id = :range_id
");
$statement->execute(['range_id' => $cid]);
$cw_block_comments = $statement->fetchAll();
foreach ($cw_block_comments as $row) {
$element = \Courseware\StructuralElement::find($row['elem_id']);
if (!$element->canRead($user)) {
continue;
}
if (!$this->arrayHasDataForId($elements, $row['elem_id'])) {
$elements[] = $element;
$unit = $element->findUnit();
$unitElement = $unit->structural_element;
if (!$this->arrayHasDataForId($elements, $unitElement->id)) {
$elements[] = $unitElement;
}
if (!$this->arrayHasDataForId($units, $unit->id)) {
$units[] = $unit;
}
}
if (!$this->arrayHasDataForId($containers, $row['container_id'])) {
$containers[] = \Courseware\Container::find($row['container_id']);
}
if (!$this->arrayHasDataForId($blocks, $row['block_id'])) {
$blocks[] = \Courseware\Block::find($row['block_id']);
}
if (!$this->arrayHasDataForId($block_comments, $row['comment_id'])) {
$block_comments[] = \Courseware\BlockComment::find($row['comment_id']);
}
}
$statement = DBManager::get()->prepare("
SELECT elem.id AS elem_id, container.id AS container_id, block.id AS block_id, feedback.id AS feedback_id
FROM `cw_block_feedbacks` AS feedback
INNER JOIN `cw_blocks` AS block ON (block.id = feedback.block_id)
INNER JOIN `cw_containers` AS container ON (container.id = block.container_id)
INNER JOIN `cw_structural_elements` AS elem ON (elem.id = container.structural_element_id)
WHERE elem.range_type = 'course'
AND elem.range_id = :range_id
");
$statement->execute(['range_id' => $cid]);
$cw_block_feedbacks = $statement->fetchAll();
foreach ($cw_block_feedbacks as $row) {
$element = \Courseware\StructuralElement::find($row['elem_id']);
if (!$element->canEdit($user)) {
continue;
}
if (!$this->arrayHasDataForId($elements, $row['elem_id'])) {
$elements[] = $element;
$unit = $element->findUnit();
$unitElement = $unit->structural_element;
if (!$this->arrayHasDataForId($elements, $unitElement->id)) {
$elements[] = $unitElement;
}
if (!$this->arrayHasDataForId($units, $unit->id)) {
$units[] = $unit;
}
}
if (!$this->arrayHasDataForId($containers, $row['container_id'])) {
$containers[] = \Courseware\Container::find($row['container_id']);
}
if (!$this->arrayHasDataForId($blocks, $row['block_id'])) {
$blocks[] = \Courseware\Block::find($row['block_id']);
}
if (!$this->arrayHasDataForId($block_feedbacks, $row['feedback_id'])) {
$block_feedbacks[] = \Courseware\BlockFeedback::find($row['feedback_id']);
}
}
$statement = DBManager::get()->prepare("
SELECT elem.id AS elem_id, comment.id AS comment_id
FROM `cw_structural_element_comments` AS comment
INNER JOIN `cw_structural_elements` AS elem ON (elem.id = comment.structural_element_id)
WHERE elem.range_type = 'course'
AND elem.range_id = :range_id
");
$statement->execute(['range_id' => $cid]);
$cw_structural_element_comments = $statement->fetchAll();
foreach ($cw_structural_element_comments as $row) {
$element = \Courseware\StructuralElement::find($row['elem_id']);
if (!$element->canRead($user)) {
continue;
}
if (!$this->arrayHasDataForId($elements, $row['elem_id'])) {
$elements[] = $element;
$unit = $element->findUnit();
$unitElement = $unit->structural_element;
if (!$this->arrayHasDataForId($elements, $unitElement->id)) {
$elements[] = $unitElement;
}
if (!$this->arrayHasDataForId($units, $unit->id)) {
$units[] = $unit;
}
}
if (!$this->arrayHasDataForId($element_comments, $row['comment_id'])) {
$element_comments[] = \Courseware\StructuralElementComment::find($row['comment_id']);
}
}
$statement = DBManager::get()->prepare("
SELECT elem.id AS elem_id, feedback.id AS feedback_id
FROM `cw_structural_element_feedbacks` AS feedback
INNER JOIN `cw_structural_elements` AS elem ON (elem.id = feedback.structural_element_id)
WHERE elem.range_type = 'course'
AND elem.range_id = :range_id
");
$statement->execute(['range_id' => $cid]);
$cw_structural_element_feedbacks = $statement->fetchAll();
foreach ($cw_structural_element_feedbacks as $row) {
$element = \Courseware\StructuralElement::find($row['elem_id']);
if (!$element->canEdit($user)) {
continue;
}
if (!$this->arrayHasDataForId($elements, $row['elem_id'])) {
$elements[] = $element;
$unit = $element->findUnit();
$unitElement = $unit->structural_element;
if (!$this->arrayHasDataForId($elements, $unitElement->id)) {
$elements[] = $unitElement;
}
if (!$this->arrayHasDataForId($units, $unit->id)) {
$units[] = $unit;
}
}
if (!$this->arrayHasDataForId($element_feedbacks, $row['feedback_id'])) {
$element_feedbacks[] = \Courseware\StructuralElementFeedback::find($row['feedback_id']);
}
}
$encoder = app(\Neomerx\JsonApi\Contracts\Encoder\EncoderInterface::class);
$data = [
'units' => $encoder->encodeData($units),
'elements' => $encoder->encodeData($elements),
'containers' => $encoder->encodeData($containers),
'blocks' => $encoder->encodeData($blocks),
'block_comments' => $encoder->encodeData($block_comments),
'block_feedbacks' => $encoder->encodeData($block_feedbacks),
'element_comments' => $encoder->encodeData($element_comments),
'element_feedbacks' => $encoder->encodeData($element_feedbacks),
];
$this->render_json($data);
}
private function arrayHasDataForId(array $array, $id): bool
{
$ids = array_column($array, null, 'id');
return !empty($ids[$id]);
}
private function setIndexSidebar(): void
{
$sidebar = Sidebar::Get();
......@@ -112,4 +296,12 @@ class Course_CoursewareController extends CoursewareController
$sidebar->addWidget(new VueWidget('courseware-activities-widget-filter-type'));
$sidebar->addWidget(new VueWidget('courseware-activities-widget-filter-unit'));
}
private function setCommentsOverviewSidebar(): void
{
$sidebar = Sidebar::Get();
$sidebar->addWidget(new VueWidget('courseware-comments-overview-widget-filter-type'));
$sidebar->addWidget(new VueWidget('courseware-comments-overview-widget-filter-created'));
$sidebar->addWidget(new VueWidget('courseware-comments-overview-widget-filter-unit'));
}
}
<div
id="courseware-comments-app"
entry-type="courses"
entry-id="<?= htmlReady(Context::getId()) ?>"
></div>
\ No newline at end of file
......@@ -67,6 +67,10 @@ class CoursewareModule extends CorePlugin implements SystemPlugin, StudipModule
'tasks',
new Navigation(_('Aufgaben'), 'dispatch.php/course/courseware/tasks?cid=' . $courseId)
);
$navigation->addSubNavigation(
'comments',
new Navigation(_('Kommentare und Feedback'), 'dispatch.php/course/courseware/comments_overview?cid=' . $courseId)
);
return ['courseware' => $navigation];
}
......
......@@ -86,4 +86,15 @@ STUDIP.domReady(() => {
});
});
}
if (document.getElementById('courseware-comments-app')) {
STUDIP.Vue.load().then(({ createApp }) => {
import(
/* webpackChunkName: "courseware-comments-app" */
'@/vue/courseware-comments-app.js'
).then(({ default: mountApp }) => {
return mountApp(STUDIP, createApp, '#courseware-comments-app');
});
});
}
});
......@@ -2254,15 +2254,21 @@ c o m m e n t s & f e e d b a c k
scroll-behavior: smooth;
}
.studip-dialog-content {
.cw-structural-element-feedback-items,
.cw-structural-element-comments-items,
.cw-block-feedback-items,
.cw-block-comments-items {
max-height: unset;
}
}
.cw-talk-bubble {
margin: 10px 20px;
position: relative;
width: 85%;
height: auto;
background-color: $dark-gray-color-10;
border-radius: 5px;
-webkit-border-radius: 5px;
-moz-border-radius: 5px;
background-color: var(--dark-gray-color-10);
float: left;
.cw-talk-bubble-talktext {
......@@ -2271,7 +2277,7 @@ c o m m e n t s & f e e d b a c k
line-height: 1.5em;
.cw-talk-bubble-talktext-time {
color: $dark-gray-color-45;
color: var(--dark-gray-color-80);
text-align: right;
font-size: 0.8em;
margin-bottom: -0.5em;
......@@ -2290,10 +2296,7 @@ c o m m e n t s & f e e d b a c k
top: 0px;
bottom: auto;
border: 22px solid;
border-color: $dark-gray-color-10 transparent transparent transparent;
border-radius: 5px;
-webkit-border-radius: 5px;
-moz-border-radius: 5px;
border-color: var(--dark-gray-color-10) transparent transparent transparent;
left: -20px;
right: auto;
}
......@@ -2312,7 +2315,7 @@ c o m m e n t s & f e e d b a c k
span {
padding-left: 4px;
color: $dark-gray-color-45;
color: var(--dark-gray-color-45);
font-weight: 600;
vertical-align: top;
}
......@@ -5697,3 +5700,14 @@ r a d i o s e t
/* * * * * * * * * * * *
r a d i o s e t e n d
* * * * * * * * * * * */
/* * * * * * * * * * * * * * * *
c o m m e n t s o v e r v i e w
* * * * * * * * * * * * * * * */
.cw-comments-overview-dialog-comments-context {
margin: 0 0 1.5em 0;
}
/* * * * * * * * * * * * * * * * * * * *
c o m m e n t s o v e r v i e w e n d
* * * * * * * * * * * * * * * * * * * */
<template>
<div class="cw-comments-overview-wrapper">
<courseware-block-comments-overview v-if="showBlocks"/>
<courseware-structural-element-comments-overview v-if="showElements"/>
<MountingPortal mountTo="#courseware-comments-overview-widget-filter-type" name="sidebar-views">
<courseware-comments-overview-widget-filter-type />
</MountingPortal>
<MountingPortal mountTo="#courseware-comments-overview-widget-filter-created" name="sidebar-views">
<courseware-comments-overview-widget-filter-created />
</MountingPortal>
<MountingPortal mountTo="#courseware-comments-overview-widget-filter-unit" name="sidebar-views">
<courseware-comments-overview-widget-filter-unit />
</MountingPortal>
</div>
</template>
<script>
import CoursewareBlockCommentsOverview from './CoursewareBlockCommentsOverview.vue';
import CoursewareStructuralElementCommentsOverview from './CoursewareStructuralElementCommentsOverview.vue';
import CoursewareCommentsOverviewWidgetFilterType from './CoursewareCommentsOverviewWidgetFilterType.vue';
import CoursewareCommentsOverviewWidgetFilterCreated from './CoursewareCommentsOverviewWidgetFilterCreated.vue';
import CoursewareCommentsOverviewWidgetFilterUnit from './CoursewareCommentsOverviewWidgetFilterUnit.vue';
import { mapGetters } from 'vuex';
export default {
components: {
CoursewareBlockCommentsOverview,
CoursewareStructuralElementCommentsOverview,
CoursewareCommentsOverviewWidgetFilterType,
CoursewareCommentsOverviewWidgetFilterCreated,
CoursewareCommentsOverviewWidgetFilterUnit,
},
computed: {
...mapGetters({
typeFilter: 'typeFilter',
}),
showBlocks() {
return this.typeFilter === 'blocks' || this.typeFilter === 'all';
},
showElements() {
return this.typeFilter === 'elements' || this.typeFilter === 'all';
}
}
}
</script>
\ No newline at end of file
<template>
<section class="cw-block-comments" :class="[emptyComments ? 'cw-block-comments-empty' : '']">
<span class="sr-only" aria-live="polite">{{ srMessage }}</span>
<div class="cw-block-features-content">
<div class="cw-block-comments-items" v-show="!emptyComments" ref="commentsRef">
<courseware-talk-bubble
......@@ -32,6 +33,7 @@ export default {
return {
createComment: '',
placeHolder: this.$gettext('Stellen Sie eine Frage oder kommentieren Sie...'),
srMessage: ''
};
},
computed: {
......@@ -75,6 +77,7 @@ export default {
});
},
async postComment() {
this.updateSrMessage(this.$gettext('Kommentar gesendet'));
const data = {
attributes: {
comment: this.createComment
......@@ -112,6 +115,10 @@ export default {
return payload;
},
updateSrMessage(message) {
this.srMessage = '';
this.srMessage = message;
}
},
mounted() {
this.loadComments();
......
<template>
<div class="cw-block-comments-overview-wrapper">
<table class="default">
<caption>
{{ $gettext('Blöcke') }}
</caption>
<colgroup>
<col style="width: 16em">
<col style="width: 16em">
<col style="width: 8em">
<col class="responsive-hidden" style="width: 8em">
<col class="responsive-hidden" style="width: 8em">
<col style="width: 2em">
</colgroup>
<thead>
<tr class="sortable">
<th :class="getSortClass('units')" @click="sort('units')">
<a href="#">{{ $gettext('Lernmaterial') }}</a>
</th>
<th :class="getSortClass('structural-elements')" @click="sort('structural-elements')">
<a href="#">{{ $gettext('Seite') }}</a>
</th>
<th :class="getSortClass('blocks')" @click="sort('blocks')">
<a href="#">{{ $gettext('Blocktyp') }}</a>
</th>
<th class="responsive-hidden" :class="getSortClass('comments')" @click="sort('comments')">
<a href="#">{{ $gettext('Kommentare') }}</a>
</th>
<th class="responsive-hidden" :class="getSortClass('feedback')" @click="sort('feedback')">
<a href="#">{{ $gettext('Feedback') }}</a>
</th>
<th class="actions">
{{ $gettext('Aktionen') }}
</th>
</tr>
</thead>
<tbody v-if="filteredBlocks.length > 0">
<tr v-for="block in filteredBlocks" :key="block.id">
<td>{{ block.unitName }}</td>
<td>
<a :href="block.elementURL">
{{ block.element.attributes.title }}
</a>
</td>
<td>{{ block.attributes.title }}</td>
<td class="responsive-hidden">
<a
href="#"
@click.prevent="enableCommentsDialog(block)">
{{ $gettextInterpolate(
$ngettext('%{length} Kommentar', '%{length} Kommentare', block.comments.length),
{length: block.comments.length}
) }}
</a>
</td>
<td class="responsive-hidden">
<a
v-if="block.element.attributes['can-edit']"
href="#"
@click.prevent="enableFeedbackDialog(block)"
>
{{ $gettextInterpolate(
$ngettext('%{length} Feedback', '%{length} Feedbacks', block.feedbacks.length),
{length: block.feedbacks.length}
) }}
</a>
<template v-else>
-
</template>
</td>
<td class="actions">
<studip-action-menu
:items="getMenuItems(block)"
:context="$gettext('Blöcke')"
@showComments="enableCommentsDialog(block)"
@showFeedback="enableFeedbackDialog(block)"
/>
</td>
</tr>
</tbody>
<tbody v-else>
<tr class="empty">
<td colspan="6">
{{ $gettext('Es wurden keine Kommentare oder Feedback gefunden') }}
</td>
</tr>
</tbody>
</table>
<courseware-comments-overview-dialog
v-if="showCommentsDialog"
item-type="block"
com-type="comment"
:item="currentDialogBlock"
@close="closeCommentsDialog"
/>
<courseware-comments-overview-dialog
v-if="showFeedbackDialog"
item-type="block"
com-type="feedback"
:item="currentDialogBlock"
@close="closeFeedbackDialog"
/>
</div>
</template>
<script>
import CoursewareCommentsOverviewDialog from './CoursewareCommentsOverviewDialog.vue';
import commentsOverviewMixin from '@/vue/mixins/courseware/comments-overview-helper.js';
import { mapGetters } from 'vuex';
export default {
name: 'courseware-block-comments-overview',
components: {
CoursewareCommentsOverviewDialog
},
mixins: [commentsOverviewMixin],
data() {
return {
blocksWithRelations: [],
currentDialogBlock: null,
showCommentsDialog: false,
showFeedbackDialog: false,
sortBy: 'units',
sortASC: true,
}
},
computed: {
...mapGetters({
units: 'courseware-units/all',
elements: 'courseware-structural-elements/all',
containers: 'courseware-containers/all',
blocks: 'courseware-blocks/all',
blockComments: 'courseware-block-comments/all',
blockFeedbacks: 'courseware-block-feedback/all',
elementComments: 'courseware-structural-element-comments/all',
elementFeedbacks: 'courseware-structural-element-feedback/all',
containerById: 'courseware-containers/byId',
elementById: 'courseware-structural-elements/byId',
unitById: 'courseware-units/byId',
context: 'context',
createdFilter: 'createdFilter',
unitFilter: 'unitFilter'
}),
filteredBlocks() {
let filteredBlocks = this.blocksWithRelations;
if (this.unitFilter !== 'all') {
filteredBlocks = filteredBlocks.filter(block => block.unit.id === this.unitFilter);
}
if (this.createdFilter !== 'all') {
filteredBlocks = filteredBlocks.filter(block => block.comments[this.createdFilter] > 0);
}
return this.sortBlocks(filteredBlocks);
},
},
methods: {
collectBlockRelations() {
this.blocksWithRelations = _.cloneDeep(this.blocks);
this.blocksWithRelations.forEach(block => {
block.container = this.containerById({ id:block.relationships.container.data.id });
block.element = this.elementById({ id: block.container.relationships['structural-element'].data.id });
block.unit = this.unitById({ id: block.element.relationships.unit.data.id });
const unitRoot = this.elementById({ id: block.unit.relationships['structural-element'].data.id});
block.unitName = unitRoot.attributes.title;
block.elementURL = STUDIP.URLHelper.getURL(`dispatch.php/course/courseware/courseware/${block.unit.id}?cid=${this.context.id}#/structural_element/${block.element.id}`);
block.comments = this.blockComments.filter(comment => comment.relationships.block.data.id === block.id);
block.comments.oneDay = 0;
block.comments.oneWeek = 0;
block.comments.forEach(comment => {
comment.created = this.calcCreated(comment.attributes.mkdate);
if (comment.created.oneDay) {
block.comments.oneDay++;
}
if (comment.created.oneWeek) {
block.comments.oneWeek++;
}
});
block.feedbacks = this.blockFeedbacks.filter(feedback => feedback.relationships.block.data.id === block.id);
block.feedbacks.forEach(feedback => {
feedback.created = this.calcCreated(feedback.attributes.mkdate);
});
});
},
enableCommentsDialog(block) {
this.currentDialogBlock = block;
this.showCommentsDialog = true;
},
closeCommentsDialog() {
this.collectBlockRelations();
this.showCommentsDialog = false;
this.currentDialogBlock = null;
},
enableFeedbackDialog(block) {
this.currentDialogBlock = block;
this.showFeedbackDialog = true;
},
closeFeedbackDialog() {
this.collectBlockRelations();
this.showFeedbackDialog = false;
this.currentDialogBlock = null;
},
getMenuItems(block) {
let menuItems = [];
menuItems.push({ id: 1, label: this.$gettext('Kommentare anzeigen'), icon: 'comment2', emit: 'showComments' });
if (block.element.attributes['can-edit']) {
menuItems.push({ id: 2, label: this.$gettext('Feedback anzeigen'), icon: 'comment2', emit: 'showFeedback' });
}
return menuItems;
},
sort(sortBy) {
if (this.sortBy === sortBy) {
this.sortASC = !this.sortASC;
} else {
this.sortBy = sortBy;
}
},
getSortClass(col) {
if (col === this.sortBy) {
return this.sortASC ? 'sortasc' : 'sortdesc';
}
},
sortBlocks(blocks) {
switch (this.sortBy) {
case 'units':
blocks = blocks.sort((a, b) => {
if (this.sortASC) {
return a.unitName < b.unitName ? -1 : 1;
} else {
return a.unitName > b.unitName ? -1 : 1;
}
});
break;
case 'structural-elements':
blocks = blocks.sort((a, b) => {
if (this.sortASC) {
return a.element.attributes.title < b.element.attributes.title ? -1 : 1;
} else {
return a.element.attributes.title > b.element.attributes.title ? -1 : 1;
}
});
break;
case 'blocks':
blocks = blocks.sort((a, b) => {
if (this.sortASC) {
return a.attributes.title < b.attributes.title ? -1 : 1;
} else {
return a.attributes.title > b.attributes.title ? -1 : 1;
}
});
break;
case 'comments':
blocks = blocks.sort((a, b) => {
if (this.sortASC) {
return a.comments.length - b.comments.length;
} else {
return b.comments.length - a.comments.length;
}
});
break;
case 'feedback':
blocks = blocks.sort((a, b) => {
if (this.sortASC) {
return a.feedbacks.length - b.feedbacks.length;
} else {
return b.feedbacks.length - a.feedbacks.length;
}
});
break;
}
return blocks;
},
},
mounted() {
this.$nextTick(() => {
this.collectBlockRelations();
});
}
}
</script>
......@@ -4,6 +4,7 @@
class="cw-block-feedback"
:class="[emptyFeedback ? 'cw-block-feedback-empty' : '']"
>
<span class="sr-only" aria-live="polite">{{ srMessage }}</span>
<div class="cw-block-features-content">
<div class="cw-block-feedback-items" v-show="!emptyFeedback" ref="feedbacks">
<courseware-talk-bubble
......@@ -45,6 +46,7 @@ export default {
return {
feedbackText: '',
placeHolder: this.$gettext('Schreiben Sie ein Feedback...'),
srMessage: ''
};
},
computed: {
......@@ -99,6 +101,7 @@ export default {
});
},
async postFeedback() {
this.updateSrMessage(this.$gettext('Feedback gesendet'));
const data = {
attributes: {
feedback: this.feedbackText,
......@@ -115,6 +118,10 @@ export default {
await this.createFeedback(data, { root: true });
this.feedbackText = '';
this.loadFeedback();
},
updateSrMessage(message) {
this.srMessage = '';
this.srMessage = message;
}
},
async mounted() {
......
<template>
<studip-dialog
:title="title"
:closeText="$gettext('Schließen')"
height="600"
width="600"
@close="$emit('close')"
>
<template v-slot:dialogContent>
<h2 class="cw-comments-overview-dialog-comments-context">
<a :href="contextUrl">{{ context }}</a>
</h2>
<courseware-block-comments
v-if="isBlock && isComment"
:block="item"
/>
<courseware-structural-element-comments
v-if="isStructuralElement && isComment"
:structuralElement="item"
/>
<courseware-block-feedback
v-if="isBlock && isFeedback"
:block="item"
/>
<courseware-structural-element-feedback
v-if="isStructuralElement && isFeedback"
:structuralElement="item"
/>
</template>
</studip-dialog>
</template>
<script>
import CoursewareBlockComments from './CoursewareBlockComments.vue';
import CoursewareStructuralElementComments from './CoursewareStructuralElementComments.vue';
import CoursewareBlockFeedback from './CoursewareBlockFeedback.vue';
import CoursewareStructuralElementFeedback from './CoursewareStructuralElementFeedback.vue';
export default {
name: 'courseware-comments-overview-dialog',
components: {
CoursewareBlockComments,
CoursewareStructuralElementComments,
CoursewareBlockFeedback,
CoursewareStructuralElementFeedback
},
props: {
itemType: String,
item: Object,
comType: String,
},
computed: {
context() {
if (this.isBlock) {
const block = this.item;
return `${block.unitName} | ${block.element.attributes.title} | ${block.attributes.title}`;
}
if (this.isStructuralElement) {
const element = this.item;
return `${element.unitName} | ${element.attributes.title}`;
}
return '';
},
contextUrl() {
if (this.isBlock) {
return this.item.elementURL
}
if (this.isStructuralElement) {
return this.item.url;
}
return '';
},
title() {
if (this.isComment) {
return this.$gettext('Kommentare');
}
if (this.isFeedback) {
return this.$gettext('Feedback');
}
return '';
},
isStructuralElement() {
return this.itemType === 'structuralElement';
},
isBlock() {
return this.itemType === 'block';
},
isComment() {
return this.comType === 'comment';
},
isFeedback() {
return this.comType === 'feedback';
}
},
};
</script>
<template>
<sidebar-widget :title="$gettext('Neueste Einträge')">
<template #content>
<div class="cw-filter-widget">
<form class="default" @submit.prevent="">
<select v-model="createdFilter" :aria-label="$gettext('Filter: Neueste Einträge')">
<option value="all">
{{ $gettext('unbeschränkt') }}
</option>
<option value="oneDay">
{{ $gettext('einen Tag alt') }}
</option>
<option value="oneWeek">
{{ $gettext('eine Woche alt') }}
</option>
</select>
</form>
</div>
</template>
</sidebar-widget>
</template>
<script>
import SidebarWidget from '../SidebarWidget.vue';
import { mapActions } from 'vuex';
export default {
name: 'courseware-comments-overview-widget-filter-created',
components: {
SidebarWidget
},
data() {
return {
createdFilter: 'all',
};
},
methods: {
...mapActions({
setCreatedFilter: 'setCreatedFilter',
}),
filterCreated() {
this.setCreatedFilter(this.createdFilter);
},
},
watch: {
createdFilter() {
this.filterCreated();
},
}
};
</script>
<template>
<sidebar-widget :title="$gettext('Listen')">
<template #content>
<div class="cw-filter-widget">
<form class="default" @submit.prevent="">
<select v-model="typeFilter" :aria-label="$gettext('Filter: Listen')">
<option value="all">
{{ $gettext('Blöcke und Seiten') }}
</option>
<option value="blocks">
{{ $gettext('Blöcke') }}
</option>
<option value="elements">
{{ $gettext('Seiten') }}
</option>
</select>
</form>
</div>
</template>
</sidebar-widget>
</template>
<script>
import SidebarWidget from '../SidebarWidget.vue';
import { mapActions } from 'vuex';
export default {
name: 'courseware-comments-overview-widget-filter-type',
components: {
SidebarWidget
},
data() {
return {
typeFilter: 'all',
};
},
methods: {
...mapActions({
setTypeFilter: 'setTypeFilter',
}),
filterType() {
this.setTypeFilter(this.typeFilter);
},
},
watch: {
typeFilter() {
this.filterType();
},
}
};
</script>
<template>
<sidebar-widget :title="$gettext('Lernmaterial')">
<template #content>
<div class="cw-filter-widget">
<form class="default" @submit.prevent="">
<select v-model="unitFilter" :aria-label="$gettext('Filter: Lernmaterial')">
<option value="all">
{{ $gettext('Alle') }}
</option>
<option v-for="unit in sortedUnits" :key="unit.id" :value="unit.id">
{{ getUnitName(unit) }}
</option>
</select>
</form>
</div>
</template>
</sidebar-widget>
</template>
<script>
import SidebarWidget from '../SidebarWidget.vue';
import { mapActions, mapGetters } from 'vuex';
export default {
name: 'courseware-comments-overview-widget-filter-unit',
data() {
return {
unitFilter: 'all',
};
},
computed: {
...mapGetters({
units: 'courseware-units/all',
elementById: 'courseware-structural-elements/byId',
}),
sortedUnits() {
let units = _.cloneDeep(this.units);
units = units.sort((a, b) => this.getUnitName(a) < this.getUnitName(b) ? -1 : 1);
return units;
}
},
methods: {
...mapActions({
setUnitFilter: 'setUnitFilter',
}),
filterUnit() {
this.setUnitFilter(this.unitFilter);
},
getUnitName(unit) {
return this.elementById({ id: unit.relationships['structural-element'].data.id}).attributes.title;
}
},
watch: {
unitFilter() {
this.filterUnit();
},
}
};
</script>
......@@ -3,6 +3,7 @@
class="cw-structural-element-comments"
:class="[emptyComments ? 'cw-structural-element-comments-empty' : '']"
>
<span class="sr-only" aria-live="polite">{{ srMessage }}</span>
<div class="cw-structural-element-comments-items" v-show="!emptyComments" ref="commentsRef">
<courseware-talk-bubble
v-for="comment in comments"
......@@ -33,6 +34,7 @@ export default {
return {
createComment: '',
placeHolder: this.$gettext('Stellen Sie eine Frage oder kommentieren Sie...'),
srMessage: ''
};
},
computed: {
......@@ -76,6 +78,7 @@ export default {
});
},
async postComment() {
this.updateSrMessage(this.$gettext('Kommentar gesendet'));
const data = {
attributes: {
comment: this.createComment
......@@ -113,6 +116,10 @@ export default {
return payload;
},
updateSrMessage(message) {
this.srMessage = '';
this.srMessage = message;
}
},
mounted() {
this.loadComments();
......
<template>
<div class="cw-structural-element-comments-overview-wrapper">
<table class="default">
<caption>
{{ $gettext('Seiten') }}
</caption>
<colgroup>
<col style="width: 16em">
<col style="width: 16em">
<col style="width: 8em">
<col class="responsive-hidden" style="width: 8em">
<col class="responsive-hidden" style="width: 8em">
<col style="width: 2em">
</colgroup>
<thead>
<tr class="sortable">
<th :class="getSortClass('units')" @click="sort('units')">
<a href="#">{{ $gettext('Lernmaterial') }}</a>
</th>
<th :class="getSortClass('structural-elements')" @click="sort('structural-elements')">
<a href="#">{{ $gettext('Seite') }}</a>
</th>
<th></th>
<th class="responsive-hidden" :class="getSortClass('comments')" @click="sort('comments')">
<a href="#">{{ $gettext('Kommentare') }}</a>
</th>
<th class="responsive-hidden" :class="getSortClass('feedback')" @click="sort('feedback')">
<a href="#">{{ $gettext('Feedback') }}</a>
</th>
<th class="actions">
{{ $gettext('Aktionen') }}
</th>
</tr>
</thead>
<tbody v-if="filteredElements.length > 0">
<tr v-for="element in filteredElements" :key="element.id">
<td>{{ element.unitName }}</td>
<td>
<a :href="element.url">
{{ element.attributes.title }}
</a>
</td>
<td></td>
<td class="responsive-hidden">
<a
href="#"
:title="$gettext('Kommentare anzeigen')"
@click.prevent="enableCommentsDialog(element)"
>
{{ $gettextInterpolate(
$ngettext('%{length} Kommentar', '%{length} Kommentare', element.comments.length),
{length: element.comments.length}
) }}
</a>
</td>
<td class="responsive-hidden">
<a
v-if="element.attributes['can-edit'] && element.feedbacks.length > 0"
href="#"
:title="$gettext('Feedback anzeigen')"
@click.prevent="enableFeedbackDialog(element)"
>
{{ $gettextInterpolate(
$ngettext('%{length} Feedback', '%{length} Feedbacks', element.feedbacks.length),
{length: element.feedbacks.length}
) }}
</a>
<template v-else>
-
</template>
</td>
<td class="actions">
<studip-action-menu
:items="getMenuItems(element)"
:context="$gettext('Seiten')"
@showComments="enableCommentsDialog(element)"
@showFeedback="enableFeedbackDialog(element)"
/>
</td>
</tr>
</tbody>
<tbody v-else>
<tr class="empty">
<td colspan="6">
{{ $gettext('Es wurden keine Kommentare oder Feedback gefunden') }}
</td>
</tr>
</tbody>
</table>
<courseware-comments-overview-dialog
v-if="showCommentsDialog"
item-type="structuralElement"
com-type="comment"
:item="currentDialogElement"
@close="closeCommentsDialog"
/>
<courseware-comments-overview-dialog
v-if="showFeedbackDialog"
item-type="structuralElement"
com-type="feedback"
:item="currentDialogElement"
@close="closeFeedbackDialog"
/>
</div>
</template>
<script>
import CoursewareCommentsOverviewDialog from './CoursewareCommentsOverviewDialog.vue';
import commentsOverviewMixin from '@/vue/mixins/courseware/comments-overview-helper.js';
import { mapGetters } from 'vuex';
export default {
name: 'courseware-structural-element-comments-overview',
components: {
CoursewareCommentsOverviewDialog
},
mixins: [commentsOverviewMixin],
data() {
return {
elementsWithRelations: [],
currentDialogElement: null,
showCommentsDialog: false,
showFeedbackDialog: false,
sortBy: 'units',
sortASC: true,
}
},
computed: {
...mapGetters({
units: 'courseware-units/all',
elements: 'courseware-structural-elements/all',
elementComments: 'courseware-structural-element-comments/all',
elementFeedbacks: 'courseware-structural-element-feedback/all',
elementById: 'courseware-structural-elements/byId',
unitById: 'courseware-units/byId',
context: 'context',
createdFilter: 'createdFilter',
unitFilter: 'unitFilter'
}),
filteredElements() {
let filteredElements = this.elementsWithRelations;
if (this.unitFilter !== 'all') {
filteredElements = filteredElements.filter(block => block.unit.id === this.unitFilter);
}
return this.sortElements(filteredElements);
}
},
methods: {
collectElementRelations() {
this.elementsWithRelations = _.cloneDeep(this.elements);
this.elementsWithRelations.forEach(element => {
element.comments = this.elementComments.filter(comment => comment.relationships['structural-element'].data.id === element.id);
element.comments.oneDay = 0;
element.comments.oneWeek = 0;
element.comments.forEach(comment => {
comment.created = this.calcCreated(comment.attributes.mkdate);
if (comment.created.oneDay) {
element.comments.oneDay++;
}
if (comment.created.oneWeek) {
element.comments.oneWeek++;
}
});
element.feedbacks = this.elementFeedbacks.filter(feedback => feedback.relationships['structural-element'].data.id === element.id);
element.feedbacks.forEach(feedback => {
feedback.created = this.calcCreated(feedback.attributes.mkdate);
});
if (element.comments.length === 0 && element.feedbacks.length === 0) {
element.empty = true;
} else {
element.unit = this.unitById({ id: element.relationships.unit.data.id });
const unitRoot = this.elementById({ id: element.unit.relationships['structural-element'].data.id});
element.unitName = unitRoot.attributes.title;
element.url = STUDIP.URLHelper.getURL(`dispatch.php/course/courseware/courseware/${element.unit.id}?cid=${this.context.id}#/structural_element/${element.id}`);
}
});
this.elementsWithRelations = this.elementsWithRelations.filter(element => !element.empty);
},
enableCommentsDialog(element) {
this.currentDialogElement = element;
this.showCommentsDialog = true;
},
closeCommentsDialog() {
this.showCommentsDialog = false;
this.currentDialogElement = null;
this.collectElementRelations();
},
enableFeedbackDialog(element) {
this.currentDialogElement = element;
this.showFeedbackDialog = true;
},
closeFeedbackDialog() {
this.showFeedbackDialog = false;
this.currentDialogElement = null;
this.collectElementRelations();
},
getMenuItems(element) {
let menuItems = [];
menuItems.push({ id: 1, label: this.$gettext('Kommentare anzeigen'), icon: 'comment2', emit: 'showComments' });
if (element.attributes['can-edit']) {
menuItems.push({ id: 2, label: this.$gettext('Feedback anzeigen'), icon: 'comment2', emit: 'showFeedback' });
}
return menuItems;
},
sort(sortBy) {
if (this.sortBy === sortBy) {
this.sortASC = !this.sortASC;
} else {
this.sortBy = sortBy;
}
},
getSortClass(col) {
if (col === this.sortBy) {
return this.sortASC ? 'sortasc' : 'sortdesc';
}
},
sortElements(elements) {
switch (this.sortBy) {
case 'units':
elements = elements.sort((a, b) => {
if (this.sortASC) {
return a.unitName < b.unitName ? -1 : 1;
} else {
return a.unitName > b.unitName ? -1 : 1;
}
});
break;
case 'structural-elements':
elements = elements.sort((a, b) => {
if (this.sortASC) {
return a.attributes.title < b.attributes.title ? -1 : 1;
} else {
return a.attributes.title > b.attributes.title ? -1 : 1;
}
});
break;
case 'comments':
elements = elements.sort((a, b) => {
if (this.sortASC) {
return a.comments.length - b.comments.length;
} else {
return b.comments.length - a.comments.length;
}
});
break;
case 'feedback':
elements = elements.sort((a, b) => {
if (this.sortASC) {
return a.feedbacks.length - b.feedbacks.length;
} else {
return b.feedbacks.length - a.feedbacks.length;
}
});
break;
}
return elements;
}
},
mounted() {
this.$nextTick(() => {
this.collectElementRelations();
});
}
}
</script>
......@@ -4,6 +4,7 @@
class="cw-structural-element-feedback"
:class="[emptyFeedback ? 'cw-structural-element-feedback-empty' : '']"
>
<span class="sr-only" aria-live="polite">{{ srMessage }}</span>
<div class="cw-structural-element-feedback-items" v-show="!emptyFeedback" ref="feedbacks">
<courseware-talk-bubble
v-for="feedback in feedback"
......@@ -42,6 +43,7 @@ export default {
return {
feedbackText: '',
placeHolder: this.$gettext('Schreiben Sie ein Feedback...'),
srMessage: ''
};
},
computed: {
......@@ -99,6 +101,7 @@ export default {
});
},
async postFeedback() {
this.updateSrMessage(this.$gettext('Feedback gesendet'));
const data = {
attributes: {
feedback: this.feedbackText,
......@@ -115,6 +118,10 @@ export default {
await this.createFeedback( data, { root: true });
this.feedbackText = '';
this.loadFeedback();
},
updateSrMessage(message) {
this.srMessage = '';
this.srMessage = message;
}
},
async mounted() {
......
import CoursewareCommentsModule from './store/courseware/courseware-comments.module';
import CommentsApp from './components/courseware/CommentsApp.vue';
import Vuex from 'vuex';
import axios from 'axios';
import { mapResourceModules } from '@elan-ev/reststate-vuex';
const mountApp = async (STUDIP, createApp, element) => {
const getHttpClient = () =>
axios.create({
baseURL: STUDIP.URLHelper.getURL(`jsonapi.php/v1`, {}, true),
headers: {
'Content-Type': 'application/vnd.api+json',
},
});
let elem = document.getElementById(element.substring(1));
let entry_id = null;
let entry_type = null;
if (elem !== undefined) {
if (elem.attributes !== undefined) {
if (elem.attributes['entry-type'] !== undefined) {
entry_type = elem.attributes['entry-type'].value;
}
if (elem.attributes['entry-id'] !== undefined) {
entry_id = elem.attributes['entry-id'].value;
}
}
}
const httpClient = getHttpClient();
const store = new Vuex.Store({
modules: {
'courseware-comments': CoursewareCommentsModule,
...mapResourceModules({
names: [
'courseware-blocks',
'courseware-block-comments',
'courseware-block-feedback',
'courseware-containers',
'courseware-units',
'courseware-structural-elements',
'courseware-structural-element-comments',
'courseware-structural-element-feedback',
'users',
'course-memberships',
'institutes',
'institute-memberships',
],
httpClient,
}),
},
});
store.dispatch('setHttpClient', httpClient);
store.dispatch('setContext', {
id: entry_id,
type: entry_type,
});
store.dispatch('setUserId', STUDIP.USER_ID);
await store.dispatch('users/loadById', { id: STUDIP.USER_ID });
await store.dispatch('loadTeacherStatus', STUDIP.USER_ID);
const data = await axios(STUDIP.URLHelper.getURL('dispatch.php/course/courseware/comments_overview_data/'));
store.commit(
'courseware-units/REPLACE_ALL_RECORDS',
JSON.parse(data.data['units']).data,
{ root: true }
);
store.commit(
'courseware-structural-elements/REPLACE_ALL_RECORDS',
JSON.parse(data.data['elements']).data,
{ root: true }
);
store.commit(
'courseware-containers/REPLACE_ALL_RECORDS',
JSON.parse(data.data['containers']).data,
{ root: true }
);
store.commit(
'courseware-blocks/REPLACE_ALL_RECORDS',
JSON.parse(data.data['blocks']).data,
{ root: true }
);
store.commit(
'courseware-block-comments/REPLACE_ALL_RECORDS',
JSON.parse(data.data['block_comments']).data,
{ root: true }
);
store.commit(
'courseware-block-feedback/REPLACE_ALL_RECORDS',
JSON.parse(data.data['block_feedbacks']).data,
{ root: true }
);
store.commit(
'courseware-structural-element-comments/REPLACE_ALL_RECORDS',
JSON.parse(data.data['element_comments']).data,
{ root: true }
);
store.commit(
'courseware-structural-element-feedback/REPLACE_ALL_RECORDS',
JSON.parse(data.data['element_feedbacks']).data,
{ root: true }
);
const app = createApp({
render: (h) => h(CommentsApp),
store,
});
app.$mount(element);
};
export default mountApp;
export default {
methods: {
calcCreated(mkdate) {
let created = {oneDay: false, oneWeek: false};
const delta = (new Date() - new Date(mkdate)) / 1000 / 60 / 60 / 24;
if (delta < 2) {
created.oneDay = true;
}
if (delta < 8) {
created.oneWeek = true;
}
return created;
}
}
}
\ No newline at end of file
const getDefaultState = () => {
return {
context: null,
httpClient: null,
userId: null,
userIsTeacher: false,
teacherStatusLoaded: false,
typeFilter: 'all', // all, blocks, elements
createdFilter: 'all', // all, oneDay, oneWeek
unitFilter: 'all', // all or unit id
};
};
const initialState = getDefaultState();
const getters = {
context(state) {
return state.context;
},
httpClient(state) {
return state.httpClient;
},
userId(state) {
return state.userId;
},
userIsTeacher(state) {
return state.userIsTeacher;
},
teacherStatusLoaded(state) {
return state.teacherStatusLoaded;
},
typeFilter(state) {
return state.typeFilter;
},
createdFilter(state) {
return state.createdFilter;
},
unitFilter(state) {
return state.unitFilter;
}
};
export const state = { ...initialState };
export const actions = {
// setters
setContext({ commit }, context) {
commit('setContext', context);
},
setHttpClient({ commit }, httpClient) {
commit('setHttpClient', httpClient);
},
setUserId({ commit }, id) {
commit('setUserId', id);
},
setTypeFilter({ commit }, type) {
commit('setTypeFilter', type);
},
setCreatedFilter({ commit }, created) {
commit('setCreatedFilter', created);
},
setUnitFilter({ commit }, id) {
commit('setUnitFilter', id);
},
// other actions
async loadTeacherStatus({ dispatch, rootGetters, state, commit, getters }, userId) {
const user = rootGetters['users/byId']({ id: userId });
if (user.attributes.permission === 'root') {
commit('setUserIsTeacher', true);
return;
}
if (user.attributes.permission === 'admin') {
await dispatch('courses/loadById', { id: state.context.id });
const course = rootGetters['courses/byId']({id: state.context.id });
const instituteId = course.relationships.institute.data.id;
const parent = { type: 'users', id: `${userId}` };
const relationship = 'institute-memberships';
const options = {};
await dispatch('institute-memberships/loadRelated', { parent, relationship, options }, { root: true });
const instituteMemberships = rootGetters['institute-memberships/all'];
const instituteMembership = instituteMemberships.filter(membership => membership.relationships.institute.data.id === instituteId);
if (instituteMembership.length > 0 && instituteMembership[0].attributes.permission === 'admin') {
commit('setUserIsTeacher', true);
return;
}
}
const membershipId = `${state.context.id}_${userId}`;
try {
await dispatch('course-memberships/loadById', { id: membershipId });
} catch (error) {
console.error(`Could not find course membership for ${membershipId}.`);
commit('setUserIsTeacher', false);
return false;
}
const membership = rootGetters['course-memberships/byId']({ id: membershipId });
if (membership) {
const membershipPermission = membership.attributes.permission;
const isTeacher = membershipPermission === 'dozent' || membershipPermission === 'tutor';
commit('setUserIsTeacher', isTeacher);
return true;
} else {
console.error(`Could not find course membership for ${membershipId}.`);
commit('setUserIsTeacher', false);
return false;
}
},
};
export const mutations = {
setContext(state, data) {
state.context = data;
},
setHttpClient(state, data) {
state.httpClient = data;
},
setUserId(state, data) {
state.userId = data;
},
setTypeFilter(state, data) {
state.typeFilter = data;
},
setCreatedFilter(state, data) {
state.createdFilter = data;
},
setUnitFilter(state, data) {
state.unitFilter = data;
},
setUserIsTeacher(state, isTeacher) {
state.teacherStatusLoaded = true;
state.userIsTeacher = isTeacher;
},
};
export default {
state,
actions,
mutations,
getters,
};
\ No newline at end of file
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment