diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index 989a67ba9867179932dd008fa5c3b85fc7d2ae8f..95a7d5973496b6d3207902b4381ebc5abdc3a2be 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -193,6 +193,8 @@ class RouteMap $this->app->get('/blubber-threads', Routes\Blubber\ThreadsIndex::class) ->setArgument('type', 'all'); $this->app->get('/blubber-threads/{id}', Routes\Blubber\ThreadsShow::class); + $this->app->post('/blubber-threads', Routes\Blubber\ThreadsCreate::class); + $this->app->patch('/blubber-threads/{id}', Routes\Blubber\ThreadsUpdate::class); // create, read, update and delete BlubberComments $this->app->get('/blubber-threads/{id}/comments', Routes\Blubber\CommentsByThreadIndex::class); diff --git a/lib/classes/JsonApi/Routes/Blubber/Authority.php b/lib/classes/JsonApi/Routes/Blubber/Authority.php index 8ab431e372060f40f576fd29c3c7837a2abd81d0..57ae871627fe24f5a85099b99ade2d58b77fd29f 100644 --- a/lib/classes/JsonApi/Routes/Blubber/Authority.php +++ b/lib/classes/JsonApi/Routes/Blubber/Authority.php @@ -19,6 +19,16 @@ class Authority return self::userIsAuthor($user); } + public static function canCreateCourseBlubberThread(User $user) + { + return self::userIsTeacher($user); + } + + public static function canEditCourseBlubberThread(User $user) + { + return self::userIsTeacher($user); + } + public static function canCreateComment(User $user, BlubberThread $resource) { return self::userIsAuthor($user) && $resource->isCommentable($user->id); @@ -53,4 +63,13 @@ class Authority { return $GLOBALS['perm']->have_perm('autor', $user->id); } + + + /** + * @SuppressWarnings(PHPMD.Superglobals) + */ + private static function userIsTeacher(User $user) + { + return $GLOBALS['perm']->have_perm('tutor', $user->id); + } } diff --git a/lib/classes/JsonApi/Routes/Blubber/ThreadsCreate.php b/lib/classes/JsonApi/Routes/Blubber/ThreadsCreate.php index d503157b93516b55e4c7d93ba3c018e0e12bb2d1..98dfa695d812ca1d7bc6b7c209c66b1f1656ae34 100644 --- a/lib/classes/JsonApi/Routes/Blubber/ThreadsCreate.php +++ b/lib/classes/JsonApi/Routes/Blubber/ThreadsCreate.php @@ -24,29 +24,41 @@ 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 { + if (!Authority::canCreateCourseBlubberThread($user = $this->getUser($request))) { + throw new AuthorizationFailedException(); + } + $contextId = self::arrayGet($json, 'data.attributes.context-id', ''); } + $content = self::arrayGet($json, 'data.attributes.content', ''); + $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, '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 new file mode 100644 index 0000000000000000000000000000000000000000..144f811d65a6427bff41b9eaf4bf4e8b78deb2e0 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Blubber/ThreadsUpdate.php @@ -0,0 +1,50 @@ +<?php + +namespace JsonApi\Routes\Blubber; + +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\BadRequestException; +use JsonApi\JsonApiController; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Errors\RecordNotFoundException; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; + +/** + * Create a new private blubber thread. + */ +class ThreadsUpdate extends JsonApiController +{ + use ValidationTrait; + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + $json = $this->validate($request); + + if (!($thread = \BlubberThread::find($args['id']))) { + throw new RecordNotFoundException(); + } + + if ($thread['context_type'] !== 'course') { + throw new BadRequestException('Only blubber threads of context-type course can be edited.'); + } + + if (!Authority::canEditCourseBlubberThread($this->getUser($request))) { + throw new AuthorizationFailedException(); + } + + $thread['content'] = self::arrayGet($json, 'data.attributes.content'); + $thread->store(); + + return $this->getCodeResponse(204); + } + + protected function validateResourceDocument($json) + { + if (empty(self::arrayGet($json, 'data.attributes.content'))) { + return 'Thread content should not be empty.'; + } + } +} \ No newline at end of file diff --git a/lib/models/Courseware/BlockTypes/BlockType.php b/lib/models/Courseware/BlockTypes/BlockType.php index f4ca5b50d5d90aa68272477a0f72cb07b1d2356d..72dfd31bb84d6f4785ba81084569232c0792a8e7 100755 --- a/lib/models/Courseware/BlockTypes/BlockType.php +++ b/lib/models/Courseware/BlockTypes/BlockType.php @@ -88,6 +88,7 @@ abstract class BlockType $blockTypes = [ Audio::class, BeforeAfter::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..5d5df1489bd9c968e1822496058ba4ebab3d8bd3 --- /dev/null +++ b/lib/models/Courseware/BlockTypes/Blubber.php @@ -0,0 +1,98 @@ +<?php + +namespace Courseware\BlockTypes; + +use BlubberThread; +use Opis\JsonSchema\Schema; + +/** + * 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(): Schema + { + $schemaFile = __DIR__.'/Blubber.json'; + + return Schema::fromJsonString(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']; + + if ( $threadId === '') { + return $payload; + } + + $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.scss b/resources/assets/stylesheets/scss/courseware.scss index f9194ec4c9fd4df656212f79131c71b9cc493a4f..2464783ad111ae15c47d686a7425751b8c97db8d 100755 --- a/resources/assets/stylesheets/scss/courseware.scss +++ b/resources/assets/stylesheets/scss/courseware.scss @@ -39,6 +39,7 @@ $icon-colors:( $blockadder-items: ( before-after: block-comparison, + blubber: blubber, canvas: block-canvas, gallery: block-gallery, image-map: block-imagemap, @@ -4194,6 +4195,99 @@ text block text block end */ +/* blubber block */ + +.cw-block-blubber-content { + border: solid thin $content-color-40; + border-top: none; + padding-top: 10px; + + .cw-blubber-comments { + padding-left: 0; + max-height: 400px; + overflow-y: scroll; + overflow-x: hidden; + scrollbar-width: thin; + scrollbar-color: $base-color #f5f5f5; + + .cw-talk-bubble { + margin: 0 0 10px 28px; + float: unset; + + &.cw-talk-bubble-own-post { + margin-left: auto; + margin-right: 28px; + } + + .cw-loading-indicator-blubber-comment { + padding-top: 10px; + } + + .cw-talk-bubble-talktext { + .edit-button { + @include background-icon(edit, clickable, 16); + } + .delete-button { + @include background-icon(trash, clickable, 16); + } + .cw-talk-bubble-button { + background-color: transparent; + height: 16px; + width: 16px; + border: none; + vertical-align: top; + cursor: pointer; + } + .cw-talk-bubble-talktext-time { + .time { + margin-right: 5px; + } + } + } + + .cw-talk-bubble-talktext-edit { + textarea { + border-radius: 0; + border: solid thin $base-color-60; + width: calc(100% - 10px); + resize: none; + max-height: 240px; + scrollbar-width: thin; + scrollbar-color: $base-color #f5f5f5; + } + button.accept, + button.cancel { + min-width: unset; + width: unset; + padding: 5px; + margin: 0; + &::before { + margin: unset; + } + } + } + } + } + .cw-blubber-thread-add-comment { + border-top: solid thin $content-color-40; + padding-top: 1em; + margin: 10px; + textarea { + width: calc(100% - 6px); + resize: none; + border: solid thin $content-color-40; + &:active { + border: solid thin $content-color-80; + } + } + } + .cw-blubber-thread-empty { + margin: 0 10px 10px 10px; + } +} + +/* blubber block end*/ + /* cw tiles */ diff --git a/resources/vue/components/courseware/CoursewareBlubberBlock.vue b/resources/vue/components/courseware/CoursewareBlubberBlock.vue new file mode 100644 index 0000000000000000000000000000000000000000..cd7b137fb7ae1cbc5d011e8b1721a67a9c2ff9f8 --- /dev/null +++ b/resources/vue/components/courseware/CoursewareBlubberBlock.vue @@ -0,0 +1,181 @@ +<template> + <div class="cw-block cw-blubber-chart"> + <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 kein Blubber-Konversation angelegt.')" + mood="unsure" + /> + </template> + <template v-if="canEdit" #edit> + <form v-if="isTeacher && context.type === 'courses'" class="default" @submit.prevent=""> + <label> + <translate>Blubber Konversation</translate> + <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> + <translate>Titel</translate> + <input type="text" v-model="currentTitle" /> + </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><translate>Informationen zum Blubber-Block</translate></p> + </template> + </courseware-default-block> + </div> +</template> + +<script> +import CoursewareBlubberThread from './CoursewareBlubberThread.vue'; +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; +import CoursewareDefaultBlock from './CoursewareDefaultBlock.vue'; +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-blubber-block', + components: { + CoursewareBlubberThread, + CoursewareCompanionBox, + CoursewareDefaultBlock, + }, + 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; + 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.currentThreadId !== '' && 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 + } + }); + 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> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareBlubberComment.vue b/resources/vue/components/courseware/CoursewareBlubberComment.vue new file mode 100644 index 0000000000000000000000000000000000000000..31b9433d4058c879ea239a259a24b7ff4ae64c6f --- /dev/null +++ b/resources/vue/components/courseware/CoursewareBlubberComment.vue @@ -0,0 +1,197 @@ +<template> + <div + v-if="commentUser" + :class="{ 'cw-talk-bubble-own-post': ownComment }" + class="cw-talk-bubble" + > + <div class="cw-talk-bubble-user" v-if="!ownComment"> + <div class="cw-talk-bubble-avatar"> + <img :src="userAvatar" /> + </div> + <span>{{ userFormattedName }}</span> + </div> + <div v-show="!editActive" class="cw-talk-bubble-talktext"> + <p> + <span>{{ content }}</span> + </p> + <p class="cw-talk-bubble-talktext-time"> + <iso-date :date="chdate" class="time"/> + <button + v-if="ownComment" + :title="$gettext('Bearbeiten')" + class="cw-talk-bubble-button edit-button" + @click="editComment" + > + </button> + <button + v-if="ownComment || userIsTeacher" + :title="$gettext('Löschen')" + class="cw-talk-bubble-button delete-button" + @click="showDeleteDialog = true" + > + </button> + </p> + </div> + <div + v-show="editActive" + class="cw-talk-bubble-talktext cw-talk-bubble-talktext-edit" + > + <textarea + v-model="currentContent" + ref="commentedit" + @keydown.enter="updateComment" + @keydown.esc="resetComment" + /> + <button + class="button accept" + :title="$gettext('Speichern')" + @click="updateComment" + > + </button> + <button + class="button cancel" + :title="$gettext('Abbrechen')" + @click="resetComment" + > + </button> + </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> + </div> + <div v-else class="cw-talk-bubble"> + <studip-progress-indicator + class="cw-loading-indicator-blubber-comment" + :description="$gettext('Lade Beitrag...')" + /> + </div> +</template> + +<script> +import IsoDate from './IsoDate.vue'; +import StudipDialog from '../StudipDialog.vue'; +import StudipProgressIndicator from '../StudipProgressIndicator.vue'; +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-blubber-comment', + components: { + IsoDate, + StudipDialog, + StudipProgressIndicator, + }, + props: { + commentId: String, + }, + 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 this.comment?.attributes?.chdate; + }, + userFormattedName() { + if (this.commentUser) { + return this.commentUser.attributes['formatted-name'] + } + return ''; + }, + userAvatar() { + if (this.commentUser) { + return this.commentUser.meta.avatar.small; + } + 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'; + }, + editComment() { + this.editActive = true; + this.$nextTick(() => { + this.adjustHeight(); + }); + }, + 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); + } + } +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/CoursewareBlubberThread.vue b/resources/vue/components/courseware/CoursewareBlubberThread.vue new file mode 100644 index 0000000000000000000000000000000000000000..2c78f47b37ea857d083503d32abe6a943bc4dfbc --- /dev/null +++ b/resources/vue/components/courseware/CoursewareBlubberThread.vue @@ -0,0 +1,142 @@ +<template> + <div class="cw-blubber-thread"> + <ul + v-show="!loadingThreads && threadComments.length > 0" + class="cw-blubber-comments" + ref="commentsRef" + > + <courseware-blubber-comment + v-for="comment in threadComments" + :key="comment.id" + :comment-id="comment.id" + @delete="loadThread(threadId)" + @editing="editingComment" + /> + </ul> + <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 + v-model="newComment" + :placeholder="$gettext('Schreiben Sie eine Nachricht...')" + spellcheck="true" + @keydown.enter="createComment" + ></textarea> + <button class="button" @click="createComment"> + <translate>Absenden</translate> + </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 './CoursewareCompanionBox.vue'; +import StudipProgressIndicator from '../StudipProgressIndicator.vue'; +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: false, + } + }, + 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', + }), + async createComment() { + await this.createBlubberComment({ + threadId: this.threadId, + content: this.newComment + }); + this.newComment = ''; + }, + 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); + }, + editingComment(event) { + this.editingComments = event; + } + }, + mounted() { + this.$nextTick(async() => { + if (this.threadId) { + await this.loadThread(this.threadId); + this.loadingThreads = false; + this.scrollDown(); + } + }); + this.updater = setInterval(() => { + if (this.threadId) { + this.loadThread(this.threadId); + } + }, 10000); + }, + beforeDestroy() { + clearInterval(this.updater) + }, + watch: { + threadId(newId) { + if (newId) { + this.loadThread(newId); + } + }, + threadComments(newComments, oldComments) { + if (newComments.length !== oldComments.length && !this.editingComments) { + this.scrollDown(); + } + } + } +} +</script> \ No newline at end of file diff --git a/resources/vue/components/courseware/container-components.js b/resources/vue/components/courseware/container-components.js index 7ce04e483fc8e83a1e07e5eb0fc2892f82f8415e..c1b52da1f51ee3b14f780dfe3fe6b2d0a7c04fd1 100755 --- a/resources/vue/components/courseware/container-components.js +++ b/resources/vue/components/courseware/container-components.js @@ -4,6 +4,7 @@ import CoursewareBlockAdderArea from './CoursewareBlockAdderArea.vue'; // blocks import CoursewareAudioBlock from './CoursewareAudioBlock.vue'; import CoursewareBeforeAfterBlock from './CoursewareBeforeAfterBlock.vue'; +import CoursewareBlubberBlock from './CoursewareBlubberBlock.vue'; import CoursewareCanvasBlock from './CoursewareCanvasBlock.vue'; import CoursewareChartBlock from './CoursewareChartBlock.vue'; import CoursewareCodeBlock from './CoursewareCodeBlock.vue'; @@ -33,6 +34,7 @@ const ContainerComponents = { // blocks CoursewareAudioBlock, CoursewareBeforeAfterBlock, + CoursewareBlubberBlock, CoursewareCanvasBlock, CoursewareChartBlock, CoursewareCodeBlock, diff --git a/resources/vue/courseware-index-app.js b/resources/vue/courseware-index-app.js index 1b87c8b31c5d33f7a1967f35ef3feb9041db1737..ee785347feb174884d1158e39f4f9fccfd910d31 100755 --- a/resources/vue/courseware-index-app.js +++ b/resources/vue/courseware-index-app.js @@ -102,7 +102,9 @@ const mountApp = (STUDIP, createApp, element) => { 'semesters', 'sem-classes', 'sem-types', - 'terms-of-use' + 'terms-of-use', + 'blubber-threads', + 'blubber-comments' ], httpClient, }), diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js index bb101d5cb7a52fb187372a5769863dc83ec3ea84..996d605444f0e52052673c3b81befd00c4705176 100755 --- a/resources/vue/store/courseware/courseware.module.js +++ b/resources/vue/store/courseware/courseware.module.js @@ -966,6 +966,171 @@ export const actions = { return dispatch('loadFeedback', blockId); }, + + async createTaskGroup({ dispatch, rootGetters }, { taskGroup }) { + await dispatch('courseware-task-groups/create', taskGroup, { root: true }); + + const id = taskGroup.relationships.target.data.id; + const target = rootGetters['courseware-structural-elements/byId']({ id }); + + return dispatch('courseware-structure/loadDescendants', { root: target }); + }, + + async loadTask({ dispatch }, { taskId }) { + return dispatch( + 'courseware-tasks/loadById', + { + id: taskId, + options: { + include: 'solver,task-group,task-group.lecturer', + }, + }, + { root: true } + ); + }, + + async updateTask({ dispatch }, { attributes, taskId }) { + const task = { + type: 'courseware-tasks', + attributes: attributes, + id: taskId, + }; + await dispatch('courseware-tasks/update', task, { root: true }); + + return dispatch('loadTask', { taskId: task.id }); + }, + + async deleteTask({ dispatch }, { task }) { + const data = { + id: task.id, + }; + await dispatch('courseware-tasks/delete', data, { root: true }); + }, + + async createTaskFeedback({ dispatch }, { taskFeedback }) { + await dispatch('courseware-task-feedback/create', taskFeedback, { root: true }); + + return dispatch('loadTask', { taskId: taskFeedback.relationships.task.data.id }); + }, + + async updateTaskFeedback({ dispatch }, { attributes, taskFeedbackId }) { + const taskFeedback = { + type: 'courseware-task-feedback', + attributes: attributes, + id: taskFeedbackId, + }; + await dispatch('courseware-task-feedback/update', taskFeedback, { root: true }); + + return dispatch('courseware-task-feedback/loadById', { id: taskFeedback.id }, { root: true }); + }, + + async deleteTaskFeedback({ dispatch }, { taskFeedbackId }) { + const data = { + id: taskFeedbackId, + }; + await dispatch('courseware-task-feedback/delete', data, { root: true }); + }, + + setPurposeFilter({ commit }, purpose) { + commit('setPurposeFilter', purpose); + }, + setBookmarkFilter({ commit }, course) { + commit('setBookmarkFilter', course); + }, + + 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) { + await 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 }); + }, }; /* eslint no-param-reassign: ["error", { "props": false }] */