aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorElmar Ludwig <elmar.ludwig@uni-osnabrueck.de>2024-12-17 15:36:20 +0000
committerElmar Ludwig <elmar.ludwig@uni-osnabrueck.de>2024-12-17 15:36:20 +0000
commit4b26a804f91f0b19651ab7104ff37b6c8b883150 (patch)
treec90a2723106168391c9924bfefff079c94061a99
parent515a80a6bba02be314c0e19795beeb40edf224df (diff)
add Blubber block for Courseware, fixes #726
Closes #726 Merge request studip/studip!3216
-rw-r--r--lib/classes/JsonApi/RouteMap.php1
-rw-r--r--lib/classes/JsonApi/Routes/Blubber/Authority.php19
-rw-r--r--lib/classes/JsonApi/Routes/Blubber/ThreadsCreate.php34
-rw-r--r--lib/classes/JsonApi/Routes/Blubber/ThreadsUpdate.php15
-rw-r--r--lib/models/Courseware/BlockTypes/BlockType.php1
-rw-r--r--lib/models/Courseware/BlockTypes/Blubber.json12
-rw-r--r--lib/models/Courseware/BlockTypes/Blubber.php99
-rw-r--r--resources/assets/stylesheets/scss/courseware/blocks/blubber.scss42
-rw-r--r--resources/assets/stylesheets/scss/courseware/variables.scss1
-rw-r--r--resources/vue/components/courseware/blocks/CoursewareBlubberBlock.vue184
-rw-r--r--resources/vue/components/courseware/blocks/CoursewareBlubberComment.vue210
-rw-r--r--resources/vue/components/courseware/blocks/CoursewareBlubberThread.vue156
-rw-r--r--resources/vue/components/courseware/containers/container-components.js2
-rw-r--r--resources/vue/store/courseware/courseware.module.js94
-rw-r--r--tests/jsonapi/BlubberThreadsCreateTest.php40
15 files changed, 889 insertions, 21 deletions
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index a7048bd..3870d9a 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -199,6 +199,7 @@ class RouteMap
$group->get('/users/{id}/blubber-threads', Routes\Blubber\ThreadsIndex::class)->setArgument('type', 'private');
$group->get('/blubber-threads', Routes\Blubber\ThreadsIndex::class)->setArgument('type', 'all');
$group->get('/blubber-threads/{id}', Routes\Blubber\ThreadsShow::class);
+ $group->post('/blubber-threads', Routes\Blubber\ThreadsCreate::class);
$group->patch('/blubber-threads/{id}', Routes\Blubber\ThreadsUpdate::class);
// create, read, update and delete BlubberComments
diff --git a/lib/classes/JsonApi/Routes/Blubber/Authority.php b/lib/classes/JsonApi/Routes/Blubber/Authority.php
index b03b6aa..5f56abf 100644
--- a/lib/classes/JsonApi/Routes/Blubber/Authority.php
+++ b/lib/classes/JsonApi/Routes/Blubber/Authority.php
@@ -4,6 +4,7 @@ namespace JsonApi\Routes\Blubber;
use BlubberComment;
use BlubberThread;
+use Course;
use User;
class Authority
@@ -23,6 +24,16 @@ class Authority
return self::userIsAuthor($user);
}
+ public static function canCreateCourseBlubberThread(User $user, Course $course)
+ {
+ return self::userIsTeacher($user, $course);
+ }
+
+ public static function canEditCourseBlubberThread(User $user, Course $course)
+ {
+ return self::userIsTeacher($user, $course);
+ }
+
public static function canCreateComment(User $user, BlubberThread $resource)
{
return self::userIsAuthor($user) && $resource->isCommentable($user->id);
@@ -57,4 +68,12 @@ class Authority
{
return $GLOBALS['perm']->have_perm('autor', $user->id);
}
+
+ /**
+ * @SuppressWarnings(PHPMD.Superglobals)
+ */
+ private static function userIsTeacher(User $user, Course $course)
+ {
+ return $GLOBALS['perm']->have_studip_perm('tutor', $course->id, $user->id);
+ }
}
diff --git a/lib/classes/JsonApi/Routes/Blubber/ThreadsCreate.php b/lib/classes/JsonApi/Routes/Blubber/ThreadsCreate.php
index b5bc943..78f8fb2 100644
--- a/lib/classes/JsonApi/Routes/Blubber/ThreadsCreate.php
+++ b/lib/classes/JsonApi/Routes/Blubber/ThreadsCreate.php
@@ -24,29 +24,43 @@ class ThreadsCreate extends JsonApiController
{
$json = $this->validate($request);
- if (!Authority::canCreatePrivateBlubberThread($user = $this->getUser($request))) {
- throw new AuthorizationFailedException();
+ $contextType = self::arrayGet($json, 'data.attributes.context-type', '');
+ if (!in_array($contextType, ['private', 'course'])) {
+ throw new BadRequestException('Only blubber threads of context-type private or course can be created.');
}
- $contextType = self::arrayGet($json, 'data.attributes.context-type', '');
- if ('private' !== $contextType) {
- throw new BadRequestException('Only blubber threads of context-type=private can be created.');
+ if ($contextType === 'private') {
+ if (!Authority::canCreatePrivateBlubberThread($user = $this->getUser($request))) {
+ throw new AuthorizationFailedException();
+ }
+ $contextId = 'global';
+ } else {
+ $contextId = self::arrayGet($json, 'data.attributes.context-id', '');
+ $course = \Course::find($contextId);
+ if (!Authority::canCreateCourseBlubberThread($user = $this->getUser($request), $course)) {
+ throw new AuthorizationFailedException();
+ }
}
+ $content = self::arrayGet($json, 'data.attributes.content', '');
+ $visible_in_stream = self::arrayGet($json, 'data.attributes.is-visible-in-stream', 1);
+
$thread = \BlubberThread::create(
[
- 'context_type' => 'private',
- 'context_id' => 'global',
+ 'context_type' => $contextType,
+ 'context_id' => $contextId,
'user_id' => $user->id,
'external_contact' => 0,
'display_class' => null,
- 'visible_in_stream' => 1,
+ 'visible_in_stream' => $visible_in_stream,
'commentable' => 1,
- 'content' => '',
+ 'content' => $content,
]
);
- \BlubberMention::create(['thread_id' => $thread->id, 'user_id' => $user->id]);
+ if ($contextType === 'private') {
+ \BlubberMention::create(['thread_id' => $thread->id, 'user_id' => $user->id]);
+ }
return $this->getCreatedResponse($thread);
}
diff --git a/lib/classes/JsonApi/Routes/Blubber/ThreadsUpdate.php b/lib/classes/JsonApi/Routes/Blubber/ThreadsUpdate.php
index 42688c8..3770d2b 100644
--- a/lib/classes/JsonApi/Routes/Blubber/ThreadsUpdate.php
+++ b/lib/classes/JsonApi/Routes/Blubber/ThreadsUpdate.php
@@ -5,6 +5,7 @@ namespace JsonApi\Routes\Blubber;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\BadRequestException;
use JsonApi\Errors\RecordNotFoundException;
use JsonApi\JsonApiController;
use JsonApi\Routes\TimestampTrait;
@@ -50,6 +51,20 @@ class ThreadsUpdate extends JsonApiController
}
}
+ if (self::arrayGet($json, 'data.attributes.content')) {
+ if ($thread['context_type'] !== 'course') {
+ throw new BadRequestException('Only blubber threads of context-type course can be edited.');
+ }
+
+ $course = \Course::find($thread['context_id']);
+ if (!Authority::canEditCourseBlubberThread($this->getUser($request), $course)) {
+ throw new AuthorizationFailedException();
+ }
+
+ $thread['content'] = self::arrayGet($json, 'data.attributes.content');
+ $thread->store();
+ }
+
return $this->getContentResponse($thread);
}
diff --git a/lib/models/Courseware/BlockTypes/BlockType.php b/lib/models/Courseware/BlockTypes/BlockType.php
index 37e617b..3e301eb 100644
--- a/lib/models/Courseware/BlockTypes/BlockType.php
+++ b/lib/models/Courseware/BlockTypes/BlockType.php
@@ -101,6 +101,7 @@ abstract class BlockType
BiographyCareer::class,
BiographyGoals::class,
BiographyPersonalInformation::class,
+ Blubber::class,
Canvas::class,
Chart::class,
Code::class,
diff --git a/lib/models/Courseware/BlockTypes/Blubber.json b/lib/models/Courseware/BlockTypes/Blubber.json
new file mode 100644
index 0000000..2d11564
--- /dev/null
+++ b/lib/models/Courseware/BlockTypes/Blubber.json
@@ -0,0 +1,12 @@
+{
+ "title": "Payload schema of Courseware\\BlockType\\Blubber",
+ "type": "object",
+ "properties": {
+ "thread_id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ ],
+ "additionalProperties": false
+}
diff --git a/lib/models/Courseware/BlockTypes/Blubber.php b/lib/models/Courseware/BlockTypes/Blubber.php
new file mode 100644
index 0000000..88624a8
--- /dev/null
+++ b/lib/models/Courseware/BlockTypes/Blubber.php
@@ -0,0 +1,99 @@
+<?php
+
+namespace Courseware\BlockTypes;
+
+use BlubberThread;
+use Course;
+
+/**
+ * This class represents the content of a Courseware blubber block.
+ *
+ * @author Ron Lucke <lucke@elan-ev.de>
+ * @license GPL2 or any later version
+ *
+ * @since Stud.IP 5.0
+ */
+class Blubber extends BlockType
+{
+ public static function getType(): string
+ {
+ return 'blubber';
+ }
+
+ public static function getTitle(): string
+ {
+ return _('Blubber');
+ }
+
+ public static function getDescription(): string
+ {
+ return _('Lehrende können eine Konversation starten oder eine bestehende Konversation einbinden.');
+ }
+
+ public function initialPayload(): array
+ {
+ return [
+ 'thread_id' => '',
+ ];
+ }
+
+ public static function getJsonSchema(): string
+ {
+ $schemaFile = __DIR__.'/Blubber.json';
+ return file_get_contents($schemaFile);
+ }
+
+ public static function getCategories(): array
+ {
+ return ['interaction'];
+ }
+
+ public static function getContentTypes(): array
+ {
+ return ['text'];
+ }
+
+ public static function getFileTypes(): array
+ {
+ return [];
+ }
+
+ public function copyPayload(string $rangeId = ''): array
+ {
+ $payload = $this->getPayload();
+ $threadId = $payload['thread_id'];
+
+ $course = Course::find($rangeId);
+
+ if ( $threadId === '' || $rangeId === '' || !$course) {
+ return $this->initialPayload();
+ }
+
+ $remoteBlubberThread = \BlubberThread::find($threadId);
+
+ $threadTitle = $remoteBlubberThread['content'];
+
+ $presentBlubberThread = \BlubberThread::findOneBySQL('content = ? AND context_id = ?', array($threadTitle, $rangeId));
+
+ if ($presentBlubberThread !== null) {
+ $payload['thread_id'] = $presentBlubberThread['thread_id'];
+ } else {
+ $user = \User::findCurrent();
+ $newBlubberThread = \BlubberThread::create(
+ [
+ 'context_type' => 'course',
+ 'context_id' => $rangeId,
+ 'user_id' => $user->id,
+ 'external_contact' => 0,
+ 'display_class' => null,
+ 'visible_in_stream' => 1,
+ 'commentable' => 1,
+ 'content' => $threadTitle,
+ ]
+ );
+ $payload['thread_id'] = $newBlubberThread['thread_id'];
+ }
+
+ return $payload;
+ }
+}
diff --git a/resources/assets/stylesheets/scss/courseware/blocks/blubber.scss b/resources/assets/stylesheets/scss/courseware/blocks/blubber.scss
new file mode 100644
index 0000000..385840e
--- /dev/null
+++ b/resources/assets/stylesheets/scss/courseware/blocks/blubber.scss
@@ -0,0 +1,42 @@
+@use '../../../mixins.scss' as *;
+
+.cw-block-blubber-content {
+ border: solid thin var(--content-color-40);
+ border-top: none;
+
+ .cw-blubber-thread {
+ background-color: var(--white);
+ border: unset;
+ width: unset;
+ max-width: unset;
+ margin-right: 0;
+ }
+
+ .cw-blubber-comments {
+ padding-left: 0;
+ padding-bottom: 10px;
+ max-height: 400px;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ scrollbar-width: thin;
+ scrollbar-color: var(--base-color) var(--content-color-10);
+ }
+
+ .cw-blubber-thread-add-comment {
+ border-top: solid thin var(--content-color-40);
+ padding-top: 1em;
+ margin: 10px;
+ textarea {
+ width: calc(100% - 6px);
+ resize: none;
+ border: solid thin var(--content-color-40);
+ &:active {
+ border: solid thin var(--content-color-80);
+ }
+ }
+ }
+
+ .cw-blubber-thread-empty {
+ margin: 0 10px 10px 10px;
+ }
+}
diff --git a/resources/assets/stylesheets/scss/courseware/variables.scss b/resources/assets/stylesheets/scss/courseware/variables.scss
index 37be478..033c99f 100644
--- a/resources/assets/stylesheets/scss/courseware/variables.scss
+++ b/resources/assets/stylesheets/scss/courseware/variables.scss
@@ -54,6 +54,7 @@ $border-colors: (
$blockadder-items: (
before-after: block-comparison,
+ blubber: blubber,
canvas: block-canvas,
gallery: block-gallery,
image-map: block-imagemap,
diff --git a/resources/vue/components/courseware/blocks/CoursewareBlubberBlock.vue b/resources/vue/components/courseware/blocks/CoursewareBlubberBlock.vue
new file mode 100644
index 0000000..ccb310d
--- /dev/null
+++ b/resources/vue/components/courseware/blocks/CoursewareBlubberBlock.vue
@@ -0,0 +1,184 @@
+<template>
+ <div class="cw-block cw-block-blubber">
+ <courseware-default-block
+ :block="block"
+ :canEdit="canEdit"
+ :isTeacher="isTeacher"
+ :preview="true"
+ @storeEdit="storeBlock"
+ @closeEdit="initCurrentData"
+ >
+ <template #content>
+ <div v-if="currentTitle" class="cw-block-title">
+ {{ currentTitle }}
+ </div>
+ <div v-if="currentThreadId" class="cw-block-blubber-content" >
+ <courseware-blubber-thread
+ :thread-id="currentThreadId"
+ @threadContent="setTitle"
+ />
+ </div>
+ <courseware-companion-box
+ v-else
+ :msgCompanion="$gettext('Es wurde noch keine Blubber-Konversation angelegt.')"
+ mood="unsure"
+ />
+ </template>
+ <template v-if="canEdit" #edit>
+ <form v-if="isTeacher && context.type === 'courses'" class="default" @submit.prevent="">
+ <label>
+ {{ $gettext('Blubber Konversation') }}
+ <select v-model="currentThreadId">
+ <option value="">
+ <translate>neue Konversation</translate>
+ </option>
+ <option
+ v-for="thread in availableThreads"
+ :key="thread.id"
+ :value="thread.id"
+ >
+ {{ thread.attributes.content }}
+ </option>
+ </select>
+ </label>
+ <label>
+ {{ $gettext('Titel') }}
+ <input type="text" v-model="currentTitle" required/>
+ </label>
+ </form>
+ <courseware-companion-box
+ v-if="!isTeacher"
+ :msgCompanion="onlyTeachersInfo"
+ mood="pointing"
+ />
+ <courseware-companion-box
+ v-if="context.type !== 'courses'"
+ :msgCompanion="notInCourseInfo"
+ mood="pointing"
+ />
+ </template>
+ <template #info>
+ <p>{{ $gettext('Informationen zum Blubber-Block') }}</p>
+ </template>
+ </courseware-default-block>
+ </div>
+</template>
+
+<script>
+import BlockComponents from './block-components.js';
+import blockMixin from '@/vue/mixins/courseware/block.js';
+import CoursewareBlubberThread from './CoursewareBlubberThread.vue';
+import { mapActions, mapGetters } from 'vuex';
+
+export default {
+ name: 'courseware-blubber-block',
+ mixins: [blockMixin],
+ components: Object.assign(BlockComponents, { CoursewareBlubberThread }),
+ props: {
+ block: Object,
+ canEdit: Boolean,
+ isTeacher: Boolean,
+ },
+ data() {
+ return {
+ currentTitle: '',
+ currentThreadId: '',
+ availableThreads: [],
+ }
+ },
+ computed: {
+ ...mapGetters({
+ context: 'context',
+ }),
+ notInCourseInfo() {
+ return this.$gettext('Blubber-Konversationen für Courseware Blöcke können nur in Veranstaltungen anlegen werden.');
+ },
+ onlyTeachersInfo() {
+ return this.$gettext('Nur Lehrende dürfen Blubber-Konversationen anlegen und ändern.')
+ }
+ },
+ methods:{
+ ...mapActions({
+ updateBlock: 'updateBlockInContainer',
+ loadCourseBlubberThreads: 'loadCourseBlubberThreads',
+ createBlubberThread: 'createBlubberThread',
+ updateBlubberThread: 'updateBlubberThread',
+ companionWarning: 'companionWarning'
+ }),
+ async initCurrentData() {
+ this.currentThreadId = this.block?.attributes?.payload?.thread_id;
+ if (this.context.type === 'courses') {
+ this.availableThreads = await this.loadCourseBlubberThreads({cid: this.context.id});
+ this.availableThreads = this.availableThreads.filter(thread => thread.attributes.content !== null && thread.attributes.content !== '');
+ }
+ },
+ setTitle(e) {
+ this.currentTitle = e;
+ },
+ async storeBlock() {
+ if (this.context.type !== 'courses') {
+ this.companionWarning({
+ info: this.notInCourseInfo
+ });
+ return;
+ }
+ if (!this.isTeacher) {
+ this.companionWarning({
+ info: onlyTeachersInfo
+ });
+ return;
+ }
+ let attributes = {};
+ attributes.payload = {};
+
+ if (this.currentThreadId !== '' && this.currentTitle !== '') {
+ await this.updateBlubberThread({
+ content: this.currentTitle,
+ threadId: this.currentThreadId
+ });
+ }
+
+ if (this.currentTitle === '') {
+ this.companionWarning({
+ info: this.$gettext('Bitte vergeben Sie einen Titel.')
+ });
+ return;
+ }
+
+ if (this.currentThreadId === '' && this.context.type === 'courses') {
+ await this.createBlubberThread({
+ attributes: {
+ 'context-type': 'course',
+ 'context-id': this.context.id,
+ 'content': this.currentTitle,
+ 'is-visible-in-stream': false
+ }
+ });
+ const newThread = this.$store.getters['blubber-threads/lastCreated'];
+ this.currentThreadId = newThread.id;
+ }
+
+ attributes.payload.thread_id = this.currentThreadId;
+
+ this.updateBlock({
+ attributes: attributes,
+ blockId: this.block.id,
+ containerId: this.block.relationships.container.data.id,
+ });
+ },
+ },
+ mounted() {
+ this.initCurrentData();
+ },
+ watch: {
+ currentThreadId(newId) {
+ if (newId === '') {
+ this.currentTitle = '';
+ }
+ }
+ }
+}
+</script>
+<style lang="scss">
+@import '../../../../assets/stylesheets/scss/courseware/blocks/blubber.scss';
+</style>
diff --git a/resources/vue/components/courseware/blocks/CoursewareBlubberComment.vue b/resources/vue/components/courseware/blocks/CoursewareBlubberComment.vue
new file mode 100644
index 0000000..5fc28bd
--- /dev/null
+++ b/resources/vue/components/courseware/blocks/CoursewareBlubberComment.vue
@@ -0,0 +1,210 @@
+<template>
+ <li
+ v-if="commentUser"
+ :class="{ 'talk-bubble-own-post': ownComment }"
+ class="talk-bubble-wrapper"
+ >
+ <div v-if="!ownComment" class="talk-bubble-avatar">
+ <a :href="userProfileURL" :title="userFormattedName">
+ <img :src="userAvatar" />
+ </a>
+ </div>
+ <div class="talk-bubble" :class="{ editing: editActive }">
+ <div class="talk-bubble-content">
+ <header v-if="!ownComment" class="talk-bubble-header">
+ <a :href="userProfileURL">{{ userFormattedName }}</a>
+ </header>
+ <div class="talk-bubble-talktext">
+ <template v-if="!editActive">
+ <div v-html="comment.attributes['content-html']" class="html"></div>
+ <div class="talk-bubble-footer">
+ <span class="talk-bubble-talktext-time"><studip-date-time :timestamp="chdate"
+ :relative="true"></studip-date-time></span>
+ <a href="#" v-if="ownComment" @click.prevent.stop="editComment" class="edit_comment"
+ :title="$gettext('Bearbeiten')">
+ <studip-icon shape="edit" :size="14" />
+ </a>
+ <a href="#" @click.prevent="answerComment" class="answer_comment"
+ :title="$gettext('Hierauf antworten')">
+ <studip-icon shape="reply" :size="14" />
+ </a>
+ <a href="#" v-if="ownComment || userIsTeacher" @click.prevent="showDeleteDialog = true" class="answer_comment"
+ :title="$gettext('Löschen')">
+ <studip-icon shape="trash" :size="14" />
+ </a>
+
+ </div>
+ </template>
+ <div v-else class="talk-bubble-edit">
+ <textarea
+ v-model="currentContent"
+ ref="commentedit"
+ @keydown.enter.exact.prevent="updateComment"
+ @keyup.escape.exact="resetComment"
+ ></textarea>
+ <button @click="updateComment" :title="$gettext('Speichern')">
+ <studip-icon shape="accept" />
+ </button>
+ <button @click="resetComment" :title="$gettext('Abbrechen')">
+ <studip-icon shape="decline" />
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ <studip-dialog
+ v-if="showDeleteDialog"
+ :title="$gettext('Beitrag löschen')"
+ :question="$gettext('Möchten Sie diesen Beitrag wirklich löschen')"
+ height="180"
+ width="360"
+ @confirm="deleteComment"
+ @close="showDeleteDialog = false"
+ ></studip-dialog>
+ </li>
+ <li v-else class="cw-talk-bubble">
+ <studip-progress-indicator
+ class="cw-loading-indicator-blubber-comment"
+ :description="$gettext('Lade Beitrag…')"
+ />
+ </li>
+</template>
+
+<script>
+import StudipDialog from '../../StudipDialog.vue';
+import StudipProgressIndicator from '../../StudipProgressIndicator.vue';
+import { mapActions, mapGetters } from 'vuex';
+
+export default {
+ name: 'courseware-blubber-comment',
+ components: {
+ StudipDialog,
+ StudipProgressIndicator,
+ },
+ props: {
+ commentId: String,
+ editing: Boolean,
+ },
+ data() {
+ return {
+ editActive: false,
+ currentContent: '',
+ showDeleteDialog: false
+ }
+ },
+ computed: {
+ ...mapGetters({
+ blubberCommentsById: 'blubber-comments/byId',
+ userId: 'userId',
+ usersById: 'users/byId',
+ userIsTeacher: 'userIsTeacher',
+ }),
+ comment() {
+ let comment = this.blubberCommentsById({ id: this.commentId });
+ if (comment) {
+ return comment;
+ }
+ return null;
+ },
+ content() {
+ return this.comment?.attributes?.content;
+ },
+ chdate() {
+ return new Date(this.comment?.attributes?.chdate) / 1000;
+ },
+ userFormattedName() {
+ if (this.commentUser) {
+ return this.commentUser.attributes['formatted-name']
+ }
+ return '';
+ },
+ userAvatar() {
+ if (this.commentUser) {
+ return this.commentUser.meta.avatar.medium;
+ }
+ return '';
+ },
+ userProfileURL() {
+ if (this.commentUser) {
+ return STUDIP.URLHelper.base_url + 'dispatch.php/profile?username=' + this.commentUser.attributes.username;
+ }
+ return '';
+ },
+ ownComment() {
+ if (this.commentUser && this.commentUser.id === this.userId) {
+ return true;
+ }
+ return false;
+ },
+ commentUser() {
+ let commentUserId = this.comment?.relationships?.author?.data?.id;
+ if (commentUserId) {
+ return this.usersById({ id: commentUserId });
+ }
+ return null;
+ }
+ },
+ methods: {
+ ...mapActions({
+ loadUsers: 'users/loadById',
+ updateBlubberComment: 'updateBlubberComment',
+ companionWarning: 'companionWarning',
+ deleteBlubberComment: 'deleteBlubberComment'
+ }),
+ initCurrent() {
+ this.currentContent = this.content;
+ },
+ adjustHeight() {
+ let textarea = this.$refs.commentedit;
+ textarea.style.height = textarea.scrollHeight + 'px';
+ },
+ answerComment() {
+ const quoteContent = this.content.replace(/\[quote[^\]]*\].*\[\/quote\]/g, '').trim();
+ const quote = `[quote=${this.userFormattedName}]${quoteContent} [/quote]\n`;
+ this.$emit('answer', quote);
+ },
+ editComment() {
+ this.editActive = true;
+ this.$nextTick(() => {
+ this.adjustHeight();
+ this.$refs.commentedit.focus();
+ });
+ },
+ async updateComment() {
+ if (this.currentContent === '') {
+ this.companionWarning({
+ info: this.$gettext('Bitte schreiben Sie etwas in das Textfeld.')
+ });
+ }
+ await this.updateBlubberComment({
+ content: this.currentContent,
+ id: this.comment.id
+ });
+ this.editActive = false;
+ },
+ resetComment() {
+ this.currentContent = this.content;
+ this.editActive = false;
+ },
+ deleteComment() {
+ this.deleteBlubberComment({
+ id: this.commentId
+ });
+ this.$emit('delete');
+ }
+ },
+ mounted() {
+ this.initCurrent();
+ },
+ watch: {
+ editActive(edit) {
+ this.$emit('editing', this.editActive ? this.commentId : null);
+ },
+ editing(edit) {
+ if (edit) {
+ this.editComment();
+ }
+ }
+ }
+}
+</script>
diff --git a/resources/vue/components/courseware/blocks/CoursewareBlubberThread.vue b/resources/vue/components/courseware/blocks/CoursewareBlubberThread.vue
new file mode 100644
index 0000000..504448d
--- /dev/null
+++ b/resources/vue/components/courseware/blocks/CoursewareBlubberThread.vue
@@ -0,0 +1,156 @@
+<template>
+ <div class="cw-blubber-thread blubber_thread">
+ <ol
+ v-show="!loadingThreads && threadComments.length > 0"
+ class="cw-blubber-comments comments"
+ aria-live="polite"
+ ref="commentsRef"
+ >
+ <courseware-blubber-comment
+ v-for="comment in threadComments"
+ :key="comment.id"
+ :comment-id="comment.id"
+ :editing="comment.id === editingComments"
+ @answer="answerComment"
+ @delete="loadThread(threadId)"
+ @editing="editingComment"
+ />
+ </ol>
+ <courseware-companion-box
+ v-show="!loadingThreads && threadComments.length === 0"
+ class="cw-blubber-thread-empty"
+ :msgCompanion="$gettext('Bisher wurde noch nicht diskutiert.')"
+ mood="pointing"
+ />
+ <div v-show="!loadingThreads" class="cw-blubber-thread-add-comment">
+ <textarea
+ ref="composer"
+ v-model="newComment"
+ :placeholder="$gettext('Schreiben Sie eine Nachricht…')"
+ spellcheck="true"
+ @keydown.enter.exact="createComment"
+ @keyup.up.exact="editPreviousComment"
+ ></textarea>
+ <button class="button" @click="createComment">
+ {{ $gettext('Absenden') }}
+ </button>
+ </div>
+ <studip-progress-indicator
+ v-show="loadingThreads"
+ class="cw-loading-indicator-blubber-comment"
+ :description="$gettext('Lade Beiträge…')"
+ />
+ </div>
+</template>
+
+<script>
+import CoursewareBlubberComment from './CoursewareBlubberComment.vue';
+import CoursewareCompanionBox from '../layouts/CoursewareCompanionBox.vue';
+import StudipProgressIndicator from '../../StudipProgressIndicator.vue';
+import JSUpdater from '@/assets/javascripts/lib/jsupdater.js';
+import { mapActions, mapGetters } from 'vuex';
+
+export default {
+ name: 'courseware-blubber-thread',
+ components: {
+ CoursewareBlubberComment,
+ CoursewareCompanionBox,
+ StudipProgressIndicator
+ },
+ props: {
+ threadId: String,
+ },
+ data() {
+ return {
+ newComment: '',
+ loadingThreads: true,
+ updater: null,
+ editingComments: null,
+ }
+ },
+ computed: {
+ ...mapGetters({
+ blubberThreadById: 'blubber-threads/byId',
+ blubberCommentsById: 'blubber-comments/byId',
+ }),
+ blubberThread() {
+ return this.blubberThreadById({ id: this.threadId });
+ },
+ threadComments() {
+ let comments = this.blubberThread?.relationships?.comments?.data;
+ if (comments) {
+ return comments;
+ }
+ return [];
+ },
+ threadTitle() {
+ return this.blubberThread?.attributes?.content;
+ }
+ },
+ methods: {
+ ...mapActions({
+ loadBlubberThread: 'loadBlubberThread',
+ createBlubberComment: 'createBlubberComment',
+ companionInfo: 'companionInfo',
+ }),
+ async createComment() {
+ if (this.newComment) {
+ await this.createBlubberComment({
+ threadId: this.threadId,
+ content: this.newComment
+ });
+ this.newComment = '';
+ } else {
+ this.companionInfo({ info: this.$gettext('Leere Beiträge können nicht erstellt werden.') });
+ }
+ },
+ scrollDown() {
+ this.$nextTick( () => {
+ let ref = this.$refs["commentsRef"];
+ if (ref) {
+ ref.scrollTop = ref.scrollHeight;
+ }
+ });
+ },
+ async loadThread(threadId) {
+ await this.loadBlubberThread({ threadId: threadId});
+ this.$emit('threadContent', this.threadTitle);
+ },
+ answerComment(content) {
+ this.newComment = content;
+ this.$refs.composer.focus();
+ },
+ editingComment(event) {
+ this.editingComments = event;
+ },
+ editPreviousComment() {
+ const comments = this.threadComments;
+ if (comments.length > 0) {
+ this.editingComments = comments[comments.length - 1].id;
+ }
+ }
+ },
+ mounted() {
+ this.$nextTick(async() => {
+ if (this.threadId) {
+ await this.loadThread(this.threadId);
+ this.loadingThreads = false;
+ this.scrollDown();
+ JSUpdater.register('blubber', () => this.loadThread(this.threadId), { threads: [this.threadId] });
+ }
+ });
+ },
+ watch: {
+ threadId(newId) {
+ if (newId) {
+ this.loadThread(newId);
+ }
+ },
+ threadComments(newComments, oldComments) {
+ if (newComments.length !== oldComments.length && !this.editingComments) {
+ this.scrollDown();
+ }
+ }
+ }
+}
+</script>
diff --git a/resources/vue/components/courseware/containers/container-components.js b/resources/vue/components/courseware/containers/container-components.js
index ce0b7e9..0a89bef 100644
--- a/resources/vue/components/courseware/containers/container-components.js
+++ b/resources/vue/components/courseware/containers/container-components.js
@@ -7,6 +7,7 @@ import CoursewareBiographyAchievementsBlock from '../blocks/CoursewareBiographyA
import CoursewareBiographyCareerBlock from '../blocks/CoursewareBiographyCareerBlock.vue';
import CoursewareBiographyGoalsBlock from '../blocks/CoursewareBiographyGoalsBlock.vue';
import CoursewareBiographyPersonalInformationBlock from '../blocks/CoursewareBiographyPersonalInformationBlock.vue';
+import CoursewareBlubberBlock from '../blocks/CoursewareBlubberBlock.vue';
import CoursewareCanvasBlock from '../blocks/CoursewareCanvasBlock.vue';
import CoursewareChartBlock from '../blocks/CoursewareChartBlock.vue';
import CoursewareCodeBlock from '../blocks/CoursewareCodeBlock.vue';
@@ -45,6 +46,7 @@ const ContainerComponents = {
CoursewareBiographyCareerBlock,
CoursewareBiographyGoalsBlock,
CoursewareBiographyPersonalInformationBlock,
+ CoursewareBlubberBlock,
CoursewareCanvasBlock,
CoursewareChartBlock,
CoursewareCodeBlock,
diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js
index 5dc5a1f..9e93531 100644
--- a/resources/vue/store/courseware/courseware.module.js
+++ b/resources/vue/store/courseware/courseware.module.js
@@ -1374,6 +1374,100 @@ export const actions = {
options,
});
},
+
+ async loadCourseBlubberThreads({ dispatch, rootGetters }, { cid }) {
+ const parent = {
+ type: 'courses',
+ id: cid
+ };
+ const relationship = 'blubber-threads';
+ const options = {};
+ await dispatch('courses/loadRelated', { parent, relationship, options }, { root: true });
+ const threads = rootGetters['courses/related']({parent, relationship});
+
+ return threads;
+ },
+
+ loadBlubberThread({ dispatch, rootGetters }, { threadId }) {
+ return dispatch(
+ 'blubber-threads/loadById',
+ {
+ id: threadId,
+ options: {
+ include: 'comments',
+ },
+ },
+ { root: true }
+ ).then( async () => {
+ const thread = rootGetters['blubber-threads/byId']({ id: threadId });
+
+ for (let threadComment of thread.relationships.comments.data) {
+ let comment = rootGetters['blubber-comments/byId']({ id: threadComment.id });
+ let commentUserId = comment.relationships.author.data.id;
+ let user = rootGetters['users/byId']({ id: commentUserId });
+
+ if (user === undefined) {
+ dispatch('users/loadById', { id: commentUserId });
+ }
+ }
+ });
+ },
+
+ createBlubberThread({ dispatch }, { attributes }) {
+ const blubberThread = {
+ type: 'blubber-threads',
+ attributes: attributes
+ };
+
+ return dispatch('blubber-threads/create', blubberThread, { root: true });
+ },
+
+ async updateBlubberThread({ dispatch }, { content, threadId }) {
+ const blubberThread = {
+ type: 'blubber-threads',
+ attributes: {
+ content: content
+ },
+ id: threadId,
+ };
+ await dispatch('blubber-threads/update', blubberThread, { root: true });
+
+ return dispatch('blubber-threads/loadById', { id: blubberThread.id }, { root: true });
+ },
+
+ async createBlubberComment({ dispatch }, { content, threadId }) {
+ const data = {
+ data: {
+ attributes: {
+ content: content,
+ }
+ }
+ };
+ const url = `blubber-threads/${threadId}/comments`;
+ await state.httpClient.post(url, data, {});
+
+ return dispatch('loadBlubberThread', { threadId: threadId });
+ },
+
+ async updateBlubberComment({ dispatch }, { content, id }) {
+ const blubberComment = {
+ type: 'blubber-comments',
+ attributes: {
+ content: content
+ },
+ id: id,
+ };
+ await dispatch('blubber-comments/update', blubberComment, { root: true });
+
+ return dispatch('blubber-comments/loadById', { id: blubberComment.id }, { root: true });
+ },
+
+ deleteBlubberComment({ dispatch }, { id }) {
+ const data = {
+ id: id,
+ };
+ dispatch('blubber-comments/delete', data, { root: true });
+ },
async loadUnitProgresses(context, { unitId }) {
const response = await state.httpClient.get(`courseware-units/${unitId}/courseware-user-progresses`);
if (response.status === 200) {
diff --git a/tests/jsonapi/BlubberThreadsCreateTest.php b/tests/jsonapi/BlubberThreadsCreateTest.php
index d2bdaea..7cbdd14 100644
--- a/tests/jsonapi/BlubberThreadsCreateTest.php
+++ b/tests/jsonapi/BlubberThreadsCreateTest.php
@@ -36,7 +36,7 @@ class BlubberThreadsCreateTest extends \Codeception\Test\Unit
// given
$credentials = $this->tester->getCredentialsForTestAutor();
- $response = $this->createThread($credentials, 'private');
+ $response = $this->createThread($credentials, ['context-type' => 'private']);
$this->tester->assertTrue($response->isSuccessfulDocument([201]));
$document = $response->document();
@@ -70,30 +70,48 @@ class BlubberThreadsCreateTest extends \Codeception\Test\Unit
$this->tester->assertSame($credentials['id'], $links[0]['id']);
}
+ public function testCreateCourseThreadSucessfully()
+ {
+ // given
+ $credentials = $this->tester->getCredentialsForTestDozent();
+ $course_id = 'a07535cf2f8a72df33c12ddfa4b53dde';
+ $thread_title = 'Test-Thread';
+ $attributes = [
+ 'context-type' => 'course',
+ 'context-id' => $course_id,
+ 'content' => $thread_title
+ ];
+
+ $response = $this->createThread($credentials, $attributes);
+ $this->tester->assertTrue($response->isSuccessfulDocument([201]));
+
+ $document = $response->document();
+ $this->tester->assertTrue($document->isSingleResourceDocument());
+
+ $resourceObject = $document->primaryResource();
+
+ $this->tester->assertSame('course', $resourceObject->attribute('context-type'));
+ $this->tester->assertSame($thread_title, $resourceObject->attribute('content'));
+ }
+
public function testFailToCreateAnotherTypeOfThread()
{
// given
$credentials = $this->tester->getCredentialsForTestAutor();
- $response = $this->createThread($credentials, 'course');
- $this->tester->assertSame(400, $response->getStatusCode());
-
- $response = $this->createThread($credentials, 'institute');
+ $response = $this->createThread($credentials, ['context-type' => 'institute']);
$this->tester->assertSame(400, $response->getStatusCode());
- $response = $this->createThread($credentials, 'public');
+ $response = $this->createThread($credentials, ['context-type' => 'public']);
$this->tester->assertSame(400, $response->getStatusCode());
}
-
- private function createThread($credentials, $contextType = 'private')
+ private function createThread($credentials, $attributes)
{
$body = [
'data' => [
'type' => Schema::TYPE,
- 'attributes' => [
- 'context-type' => $contextType
- ]
+ 'attributes' => $attributes
]
];