From 5da08f6c40f714fd37860f3ac19662888aa3780b Mon Sep 17 00:00:00 2001 From: Ron Lucke <lucke@elan-ev.de> Date: Thu, 22 Jun 2023 10:36:07 +0000 Subject: [PATCH] =?UTF-8?q?Courseware:=20=C3=9Cbersichtsseite=20=C3=BCber?= =?UTF-8?q?=20Feedback=20und=20Kommentare?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2689 Merge request studip/studip!1754 --- app/controllers/course/courseware.php | 192 ++++++++++++ .../course/courseware/comments_overview.php | 5 + lib/modules/CoursewareModule.class.php | 4 + .../javascripts/bootstrap/courseware.js | 11 + .../assets/stylesheets/scss/courseware.scss | 34 ++- .../vue/components/courseware/CommentsApp.vue | 46 +++ .../courseware/CoursewareBlockComments.vue | 7 + .../CoursewareBlockCommentsOverview.vue | 282 ++++++++++++++++++ .../courseware/CoursewareBlockFeedback.vue | 7 + .../CoursewareCommentsOverviewDialog.vue | 98 ++++++ ...areCommentsOverviewWidgetFilterCreated.vue | 51 ++++ ...sewareCommentsOverviewWidgetFilterType.vue | 51 ++++ ...sewareCommentsOverviewWidgetFilterUnit.vue | 60 ++++ .../CoursewareStructuralElementComments.vue | 7 + ...ewareStructuralElementCommentsOverview.vue | 267 +++++++++++++++++ .../CoursewareStructuralElementFeedback.vue | 7 + resources/vue/courseware-comments-app.js | 114 +++++++ .../courseware/comments-overview-helper.js | 16 + .../courseware/courseware-comments.module.js | 146 +++++++++ 19 files changed, 1395 insertions(+), 10 deletions(-) create mode 100644 app/views/course/courseware/comments_overview.php create mode 100644 resources/vue/components/courseware/CommentsApp.vue create mode 100644 resources/vue/components/courseware/CoursewareBlockCommentsOverview.vue create mode 100644 resources/vue/components/courseware/CoursewareCommentsOverviewDialog.vue create mode 100644 resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterCreated.vue create mode 100644 resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterType.vue create mode 100644 resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterUnit.vue create mode 100644 resources/vue/components/courseware/CoursewareStructuralElementCommentsOverview.vue create mode 100644 resources/vue/courseware-comments-app.js create mode 100644 resources/vue/mixins/courseware/comments-overview-helper.js create mode 100644 resources/vue/store/courseware/courseware-comments.module.js diff --git a/app/controllers/course/courseware.php b/app/controllers/course/courseware.php index f35483f311f..29c48d9e032 100644 --- a/app/controllers/course/courseware.php +++ b/app/controllers/course/courseware.php @@ -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')); + } } diff --git a/app/views/course/courseware/comments_overview.php b/app/views/course/courseware/comments_overview.php new file mode 100644 index 00000000000..358a5e579f3 --- /dev/null +++ b/app/views/course/courseware/comments_overview.php @@ -0,0 +1,5 @@ +<div + id="courseware-comments-app" + entry-type="courses" + entry-id="<?= htmlReady(Context::getId()) ?>" +></div> \ No newline at end of file diff --git a/lib/modules/CoursewareModule.class.php b/lib/modules/CoursewareModule.class.php index 56a205c2f05..d085de22eb6 100644 --- a/lib/modules/CoursewareModule.class.php +++ b/lib/modules/CoursewareModule.class.php @@ -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]; } diff --git a/resources/assets/javascripts/bootstrap/courseware.js b/resources/assets/javascripts/bootstrap/courseware.js index ccb789217d8..d24bd4db392 100644 --- a/resources/assets/javascripts/bootstrap/courseware.js +++ b/resources/assets/javascripts/bootstrap/courseware.js @@ -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'); + }); + }); + } }); diff --git a/resources/assets/stylesheets/scss/courseware.scss b/resources/assets/stylesheets/scss/courseware.scss index 09a3a7e5e2b..964d1bf5734 100644 --- a/resources/assets/stylesheets/scss/courseware.scss +++ b/resources/assets/stylesheets/scss/courseware.scss @@ -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 +* * * * * * * * * * * * * * * * * * * */ diff --git a/resources/vue/components/courseware/CommentsApp.vue b/resources/vue/components/courseware/CommentsApp.vue new file mode 100644 index 00000000000..1716d4ddf71 --- /dev/null +++ b/resources/vue/components/courseware/CommentsApp.vue @@ -0,0 +1,46 @@ +<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 diff --git a/resources/vue/components/courseware/CoursewareBlockComments.vue b/resources/vue/components/courseware/CoursewareBlockComments.vue index 8e18909cc3d..6ef33221374 100644 --- a/resources/vue/components/courseware/CoursewareBlockComments.vue +++ b/resources/vue/components/courseware/CoursewareBlockComments.vue @@ -1,5 +1,6 @@ <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(); diff --git a/resources/vue/components/courseware/CoursewareBlockCommentsOverview.vue b/resources/vue/components/courseware/CoursewareBlockCommentsOverview.vue new file mode 100644 index 00000000000..32ba86e6318 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareBlockCommentsOverview.vue @@ -0,0 +1,282 @@ +<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> diff --git a/resources/vue/components/courseware/CoursewareBlockFeedback.vue b/resources/vue/components/courseware/CoursewareBlockFeedback.vue index 88ac2d7747c..a34ad8fed85 100644 --- a/resources/vue/components/courseware/CoursewareBlockFeedback.vue +++ b/resources/vue/components/courseware/CoursewareBlockFeedback.vue @@ -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() { diff --git a/resources/vue/components/courseware/CoursewareCommentsOverviewDialog.vue b/resources/vue/components/courseware/CoursewareCommentsOverviewDialog.vue new file mode 100644 index 00000000000..3858479e742 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareCommentsOverviewDialog.vue @@ -0,0 +1,98 @@ +<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> diff --git a/resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterCreated.vue b/resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterCreated.vue new file mode 100644 index 00000000000..96074cde9a9 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterCreated.vue @@ -0,0 +1,51 @@ +<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> diff --git a/resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterType.vue b/resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterType.vue new file mode 100644 index 00000000000..9aaf61748e1 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterType.vue @@ -0,0 +1,51 @@ +<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> diff --git a/resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterUnit.vue b/resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterUnit.vue new file mode 100644 index 00000000000..4d0afd15bf6 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterUnit.vue @@ -0,0 +1,60 @@ +<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> diff --git a/resources/vue/components/courseware/CoursewareStructuralElementComments.vue b/resources/vue/components/courseware/CoursewareStructuralElementComments.vue index 197d1bfe066..d1f48b5939b 100644 --- a/resources/vue/components/courseware/CoursewareStructuralElementComments.vue +++ b/resources/vue/components/courseware/CoursewareStructuralElementComments.vue @@ -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(); diff --git a/resources/vue/components/courseware/CoursewareStructuralElementCommentsOverview.vue b/resources/vue/components/courseware/CoursewareStructuralElementCommentsOverview.vue new file mode 100644 index 00000000000..a5ba72e7432 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareStructuralElementCommentsOverview.vue @@ -0,0 +1,267 @@ +<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> diff --git a/resources/vue/components/courseware/CoursewareStructuralElementFeedback.vue b/resources/vue/components/courseware/CoursewareStructuralElementFeedback.vue index 249b13aa2a2..3fa2c3c1160 100644 --- a/resources/vue/components/courseware/CoursewareStructuralElementFeedback.vue +++ b/resources/vue/components/courseware/CoursewareStructuralElementFeedback.vue @@ -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() { diff --git a/resources/vue/courseware-comments-app.js b/resources/vue/courseware-comments-app.js new file mode 100644 index 00000000000..0e9a4ba430c --- /dev/null +++ b/resources/vue/courseware-comments-app.js @@ -0,0 +1,114 @@ +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; diff --git a/resources/vue/mixins/courseware/comments-overview-helper.js b/resources/vue/mixins/courseware/comments-overview-helper.js new file mode 100644 index 00000000000..f45d6ea038e --- /dev/null +++ b/resources/vue/mixins/courseware/comments-overview-helper.js @@ -0,0 +1,16 @@ +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 diff --git a/resources/vue/store/courseware/courseware-comments.module.js b/resources/vue/store/courseware/courseware-comments.module.js new file mode 100644 index 00000000000..c65ee118d06 --- /dev/null +++ b/resources/vue/store/courseware/courseware-comments.module.js @@ -0,0 +1,146 @@ +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 -- GitLab