diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index a7048bdd3f750a0b6cebb6f7e99c2b74e708c3db..3870d9a2055b7477f44b754d3f636480b82194fd 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 b03b6aafad6eb18031175382206b3279981d6876..5f56abf69c616bf91a87198db863c0c00d878560 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 b5bc9435e301e44b03cb3335d24568313f7fccd7..78f8fb2775fdd7c1120dccca389ffeee6268df8b 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 42688c8fd60e7c92b1f3a5f5e61e68b41ef8a207..3770d2bf96d32c2197efe0286cdcf42d977a62c0 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 37e617b8fce13eb8b9531e5c72af9de51bdc2f97..3e301ebcdfa9693afe7fe08fc180630efe212259 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 0000000000000000000000000000000000000000..2d115647e04efa4e5788b1eab426f63dba19b27d --- /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 0000000000000000000000000000000000000000..88624a89abc51475efaf8894d592f19dc184ddb5 --- /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 0000000000000000000000000000000000000000..385840eabddf9636901d454d6a0b512f6c0c381c --- /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 37be478da63bd95c1363ff3308bf587a37e757d2..033c99fa0059cb956efb098ed74dfe5c0e9be767 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 0000000000000000000000000000000000000000..ccb310db90a8ee8aa8da30b32fca13b9caa1d969 --- /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 0000000000000000000000000000000000000000..5fc28bd14635a60cf33b5afeb2c1824d5b6c4a6a --- /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 0000000000000000000000000000000000000000..504448d3d15ef5a50fce71287f43aae6eb7a4e1a --- /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 ce0b7e958c12422eca501192bf37f15b70227580..0a89bef5e89d4bc802a9be914f66038bab3cd948 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 5dc5a1f1f48d0f456c8f882fcb2316b2b61c5d58..9e935313357dcb81a658d5bd5db63f69125fb2f3 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 d2bdaea1a7eaa64b9beab279d3b0fa5c6885d5c4..7cbdd1492b795cb0eafd70a81b58c25d675909a0 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 ] ];