From f48bd1282222c728b801b93d2aea8e64e103e99a Mon Sep 17 00:00:00 2001 From: Asli Aykan <56061820+asliayk@users.noreply.github.com> Date: Fri, 31 Jan 2025 22:29:56 +0100 Subject: [PATCH] Development: Combine post and answer post reactions bars in communication mode client code (#10224) --- .../channel-icon/channel-icon.component.html | 8 +- .../channel-icon/channel-icon.component.ts | 20 +- .../answer-post/answer-post.component.html | 4 +- .../answer-post/answer-post.component.ts | 6 +- .../app/shared/metis/post/post.component.html | 8 +- .../app/shared/metis/post/post.component.ts | 6 +- .../answer-post-reactions-bar.component.html | 100 ---- .../answer-post-reactions-bar.component.ts | 122 ----- .../post-reactions-bar.component.html | 149 ------ .../post-reactions-bar.component.ts | 239 --------- .../posting-reactions-bar.component.html | 172 +++++++ .../posting-reactions-bar.component.ts | 484 ++++++++++++++++++ .../posting-reactions-bar.directive.ts | 223 -------- .../answer-post/answer-post.component.spec.ts | 11 +- .../shared/metis/post/post.component.spec.ts | 18 +- .../posting-footer.component.spec.ts | 2 - ...nswer-post-reactions-bar.component.spec.ts | 225 -------- ...> posting-reactions-bar.component.spec.ts} | 326 +++++++++--- 18 files changed, 966 insertions(+), 1157 deletions(-) delete mode 100644 src/main/webapp/app/shared/metis/posting-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component.html delete mode 100644 src/main/webapp/app/shared/metis/posting-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component.ts delete mode 100644 src/main/webapp/app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component.html delete mode 100644 src/main/webapp/app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component.ts create mode 100644 src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.component.html create mode 100644 src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.component.ts delete mode 100644 src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.directive.ts delete mode 100644 src/test/javascript/spec/component/shared/metis/postings-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component.spec.ts rename src/test/javascript/spec/component/shared/metis/postings-reactions-bar/{post-reactions-bar/post-reactions-bar.component.spec.ts => posting-reactions-bar.component.spec.ts} (54%) diff --git a/src/main/webapp/app/overview/course-conversations/other/channel-icon/channel-icon.component.html b/src/main/webapp/app/overview/course-conversations/other/channel-icon/channel-icon.component.html index 06e1828dfe59..c3c9c747a120 100644 --- a/src/main/webapp/app/overview/course-conversations/other/channel-icon/channel-icon.component.html +++ b/src/main/webapp/app/overview/course-conversations/other/channel-icon/channel-icon.component.html @@ -1,14 +1,14 @@ - @if (isArchived) { + @if (isArchived()) { } - @if (isPublic) { + @if (isPublic()) { } - @if (!isPublic) { + @if (!isPublic()) { } - @if (isAnnouncementChannel) { + @if (isAnnouncementChannel()) { } diff --git a/src/main/webapp/app/overview/course-conversations/other/channel-icon/channel-icon.component.ts b/src/main/webapp/app/overview/course-conversations/other/channel-icon/channel-icon.component.ts index 6dda4bb3f70b..2c7bdea6762a 100644 --- a/src/main/webapp/app/overview/course-conversations/other/channel-icon/channel-icon.component.ts +++ b/src/main/webapp/app/overview/course-conversations/other/channel-icon/channel-icon.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from '@angular/core'; +import { Component, input } from '@angular/core'; import { faBoxArchive, faBullhorn, faHashtag, faLock } from '@fortawesome/free-solid-svg-icons'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; @@ -8,17 +8,13 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; imports: [FaIconComponent], }) export class ChannelIconComponent { - @Input() - isPublic = true; + isPublic = input(true); + isArchived = input(false); + isAnnouncementChannel = input(false); - @Input() - isArchived = false; - - @Input() - isAnnouncementChannel = false; // icons - faHashtag = faHashtag; - faLock = faLock; - faBoxArchive = faBoxArchive; - faBullhorn = faBullhorn; + readonly faHashtag = faHashtag; + readonly faLock = faLock; + readonly faBoxArchive = faBoxArchive; + readonly faBullhorn = faBullhorn; } diff --git a/src/main/webapp/app/shared/metis/answer-post/answer-post.component.html b/src/main/webapp/app/shared/metis/answer-post/answer-post.component.html index 75ef10f97c02..8cc1d2c47a93 100644 --- a/src/main/webapp/app/shared/metis/answer-post/answer-post.component.html +++ b/src/main/webapp/app/shared/metis/answer-post/answer-post.component.html @@ -44,7 +44,7 @@ (channelReferenceClicked)="channelReferenceClicked.emit($event)" />
- @if (!isDeleted) {
- implements // ng-container to render answerPostCreateEditModalComponent @ViewChild('createEditAnswerPostContainer', { read: ViewContainerRef }) containerRef: ViewContainerRef; - @ViewChild(AnswerPostReactionsBarComponent) private reactionsBarComponent!: AnswerPostReactionsBarComponent; + @ViewChild(PostingReactionsBarComponent) protected reactionsBarComponent!: PostingReactionsBarComponent; // Icons faBookmark = faBookmark; diff --git a/src/main/webapp/app/shared/metis/post/post.component.html b/src/main/webapp/app/shared/metis/post/post.component.html index 0278db06a3ae..6c05c881803b 100644 --- a/src/main/webapp/app/shared/metis/post/post.component.html +++ b/src/main/webapp/app/shared/metis/post/post.component.html @@ -92,9 +92,9 @@
@if (!previewMode) { - @if (!previewMode) { - implements OnInit, OnC @ViewChild('createEditAnswerPostContainer', { read: ViewContainerRef }) containerRef: ViewContainerRef; @ViewChild('postFooter') postFooterComponent: PostingFooterComponent; @ViewChild('emojiPickerTrigger') emojiPickerTrigger!: CdkOverlayOrigin; - @ViewChild(PostReactionsBarComponent) protected reactionsBarComponent!: PostReactionsBarComponent; + @ViewChild(PostingReactionsBarComponent) protected reactionsBarComponent!: PostingReactionsBarComponent; static activeDropdownPost: PostComponent | undefined = undefined; diff --git a/src/main/webapp/app/shared/metis/posting-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component.html b/src/main/webapp/app/shared/metis/posting-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component.html deleted file mode 100644 index 87d39a5b6354..000000000000 --- a/src/main/webapp/app/shared/metis/posting-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component.html +++ /dev/null @@ -1,100 +0,0 @@ -
- @for (reactionMetaData of reactionMetaDataMap | keyvalue; track reactionMetaData) { - @if (isEmojiCount) { -
- -
- } - } -
- @if ((isAnyReactionCountAboveZero() && isEmojiCount) || !isEmojiCount) { - - - @if (!isReadOnlyMode) { - - - - } - - } - @if (!isEmojiCount) { - @if (!isAnswerOfAnnouncement && (isAtLeastTutorInCourse || isAuthorOfOriginalPost)) { - - } - @if (mayEdit) { - - } - @if (mayDelete) { - - } - - } -
-
diff --git a/src/main/webapp/app/shared/metis/posting-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component.ts b/src/main/webapp/app/shared/metis/posting-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component.ts deleted file mode 100644 index e68da18c1d0b..000000000000 --- a/src/main/webapp/app/shared/metis/posting-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { Component, EventEmitter, Input, OnChanges, OnInit, Output, output } from '@angular/core'; -import { Reaction } from 'app/entities/metis/reaction.model'; -import { ConfirmIconComponent } from 'app/shared/confirm-icon/confirm-icon.component'; -import { EmojiPickerComponent } from 'app/shared/metis/emoji/emoji-picker.component'; -import { EmojiComponent } from 'app/shared/metis/emoji/emoji.component'; -import { PostingsReactionsBarDirective } from 'app/shared/metis/posting-reactions-bar/posting-reactions-bar.directive'; -import { AnswerPost } from 'app/entities/metis/answer-post.model'; -import { faCheck, faPencilAlt, faSmile } from '@fortawesome/free-solid-svg-icons'; -import { getAsChannelDTO } from 'app/entities/metis/conversation/channel.model'; -import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; -import { CdkConnectedOverlay, CdkOverlayOrigin } from '@angular/cdk/overlay'; -import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { AsyncPipe, KeyValuePipe, NgClass } from '@angular/common'; -import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; -import { ReactingUsersOnPostingPipe } from 'app/shared/pipes/reacting-users-on-posting.pipe'; - -@Component({ - selector: 'jhi-answer-post-reactions-bar', - templateUrl: './answer-post-reactions-bar.component.html', - styleUrls: ['../posting-reactions-bar.component.scss'], - imports: [ - NgbTooltip, - EmojiComponent, - CdkOverlayOrigin, - FaIconComponent, - CdkConnectedOverlay, - EmojiPickerComponent, - NgClass, - ConfirmIconComponent, - AsyncPipe, - KeyValuePipe, - ArtemisTranslatePipe, - ReactingUsersOnPostingPipe, - ], -}) -export class AnswerPostReactionsBarComponent extends PostingsReactionsBarDirective implements OnInit, OnChanges { - @Input() isReadOnlyMode = false; - @Input() isEmojiCount = false; - @Input() isLastAnswer = false; - - @Output() openPostingCreateEditModal = new EventEmitter(); - @Output() postingUpdated = new EventEmitter(); - - // Icons - readonly farSmile = faSmile; - readonly faCheck = faCheck; - readonly faPencilAlt = faPencilAlt; - - isAuthorOfOriginalPost: boolean; - isAnswerOfAnnouncement: boolean; - mayDelete: boolean; - mayEdit: boolean; - - mayDeleteOutput = output(); - mayEditOutput = output(); - - ngOnInit() { - super.ngOnInit(); - this.setMayEdit(); - this.setMayDelete(); - } - - ngOnChanges() { - super.ngOnChanges(); - this.setMayEdit(); - this.setMayDelete(); - } - - isAnyReactionCountAboveZero(): boolean { - return Object.values(this.reactionMetaDataMap).some((reaction) => reaction.count >= 1); - } - - /** - * invokes the metis service to delete an answer post - */ - deletePosting(): void { - this.isDeleteEvent.emit(true); - } - - /** - * builds and returns a Reaction model out of an emojiId and thereby sets the answerPost property properly - * @param emojiId emojiId to build the model for - */ - buildReaction(emojiId: string): Reaction { - const reaction = new Reaction(); - reaction.emojiId = emojiId; - reaction.answerPost = this.posting; - return reaction; - } - - setMayDelete(): void { - // determines if the current user is the author of the original post, that the answer belongs to - this.isAuthorOfOriginalPost = this.metisService.metisUserIsAuthorOfPosting(this.posting.post!); - this.isAnswerOfAnnouncement = getAsChannelDTO(this.posting.post?.conversation)?.isAnnouncementChannel ?? false; - const isCourseWideChannel = getAsChannelDTO(this.posting.post?.conversation)?.isCourseWide ?? false; - const canDeletePost = this.isAnswerOfAnnouncement ? this.metisService.metisUserIsAtLeastInstructorInCourse() : this.metisService.metisUserIsAtLeastTutorInCourse(); - const mayDeleteOtherUsersAnswer = - (isCourseWideChannel && canDeletePost) || (getAsChannelDTO(this.metisService.getCurrentConversation())?.hasChannelModerationRights ?? false); - this.mayDelete = !this.isReadOnlyMode && (this.isAuthorOfPosting || (mayDeleteOtherUsersAnswer && canDeletePost)); - this.mayDeleteOutput.emit(this.mayDelete); - } - - setMayEdit() { - this.mayEdit = this.isAuthorOfPosting; - this.mayEditOutput.emit(this.mayEdit); - } - - editPosting() { - this.openPostingCreateEditModal.emit(); - } - - /** - * toggles the resolvesPost property of an answer post if the user is at least tutor in a course or the user is the author of the original post, - * delegates the update to the metis service - */ - toggleResolvesPost(): void { - if (this.isAtLeastTutorInCourse || this.isAuthorOfOriginalPost) { - this.posting.resolvesPost = !this.posting.resolvesPost; - this.metisService.updateAnswerPost(this.posting).subscribe(); - } - } -} diff --git a/src/main/webapp/app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component.html b/src/main/webapp/app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component.html deleted file mode 100644 index 60a95dfbd683..000000000000 --- a/src/main/webapp/app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component.html +++ /dev/null @@ -1,149 +0,0 @@ -
- @if (hoverBar && sortedAnswerPosts.length === 0) { -
- -
- } - @if (!isCommunicationPage) { - @if (sortedAnswerPosts.length) { - - @if (showAnswers) { -
- -
- } @else { - -
- -
- } - } - } @else { - @if (!isThreadSidebar) { - - @if (!showAnswers && sortedAnswerPosts.length) { -
- -
- } - } - } - @for (reactionMetaData of reactionMetaDataMap | keyvalue; track reactionMetaData) { - @if (isEmojiCount) { -
- -
- } - } -
- @if ((isAnyReactionCountAboveZero() && isEmojiCount) || !isEmojiCount) { - - - - - @if (!readOnlyMode) { - - } - - - } - - @if (!isEmojiCount && mayEdit) { - - } - - @if (!isEmojiCount && mayDelete) { - - } - @if (!isEmojiCount && (displayPriority === DisplayPriority.PINNED || canPin)) { - - } - @if (!isEmojiCount) { - - } -
-
- @if (isEmojiCount && getShowNewMessageIcon()) { -
- } -
-
diff --git a/src/main/webapp/app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component.ts b/src/main/webapp/app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component.ts deleted file mode 100644 index 963c4f99ac14..000000000000 --- a/src/main/webapp/app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, ViewChild, inject, output } from '@angular/core'; -import { Reaction } from 'app/entities/metis/reaction.model'; -import { Post } from 'app/entities/metis/post.model'; -import { ConfirmIconComponent } from 'app/shared/confirm-icon/confirm-icon.component'; -import { TranslateDirective } from 'app/shared/language/translate.directive'; -import { EmojiPickerComponent } from 'app/shared/metis/emoji/emoji-picker.component'; -import { EmojiComponent } from 'app/shared/metis/emoji/emoji.component'; -import { PostingsReactionsBarDirective } from 'app/shared/metis/posting-reactions-bar/posting-reactions-bar.directive'; -import { DisplayPriority } from 'app/shared/metis/metis.util'; -import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; -import { faArrowRight, faPencilAlt, faSmile } from '@fortawesome/free-solid-svg-icons'; -import { AnswerPost } from 'app/entities/metis/answer-post.model'; -import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; -import dayjs from 'dayjs/esm'; -import { getAsChannelDTO, isChannelDTO } from 'app/entities/metis/conversation/channel.model'; -import { isGroupChatDTO } from 'app/entities/metis/conversation/group-chat.model'; -import { AccountService } from 'app/core/auth/account.service'; -import { isOneToOneChatDTO } from 'app/entities/metis/conversation/one-to-one-chat.model'; -import { ConversationDTO } from 'app/entities/metis/conversation/conversation.model'; -import { PostCreateEditModalComponent } from 'app/shared/metis/posting-create-edit-modal/post-create-edit-modal/post-create-edit-modal.component'; -import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; -import { CdkConnectedOverlay, CdkOverlayOrigin } from '@angular/cdk/overlay'; -import { AsyncPipe, KeyValuePipe } from '@angular/common'; -import { ReactingUsersOnPostingPipe } from 'app/shared/pipes/reacting-users-on-posting.pipe'; - -@Component({ - selector: 'jhi-post-reactions-bar', - templateUrl: './post-reactions-bar.component.html', - styleUrls: ['../posting-reactions-bar.component.scss'], - imports: [ - FaIconComponent, - TranslateDirective, - EmojiComponent, - NgbTooltip, - CdkOverlayOrigin, - CdkConnectedOverlay, - EmojiPickerComponent, - PostCreateEditModalComponent, - ConfirmIconComponent, - AsyncPipe, - KeyValuePipe, - ArtemisTranslatePipe, - ReactingUsersOnPostingPipe, - ], -}) -export class PostReactionsBarComponent extends PostingsReactionsBarDirective implements OnInit, OnChanges, OnDestroy { - private accountService = inject(AccountService); - - pinTooltip: string; - displayPriority: DisplayPriority; - canPin = false; - readonly DisplayPriority = DisplayPriority; - - // Icons - faSmile = faSmile; - faArrowRight = faArrowRight; - faPencilAlt = faPencilAlt; - faTrash = faTrashAlt; - - @Input() readOnlyMode = false; - @Input() showAnswers: boolean; - @Input() sortedAnswerPosts: AnswerPost[]; - @Input() isCommunicationPage: boolean; - @Input() lastReadDate?: dayjs.Dayjs; - @Input() previewMode: boolean; - @Input() isEmojiCount = false; - @Input() hoverBar = true; - - @Output() showAnswersChange = new EventEmitter(); - @Output() openPostingCreateEditModal = new EventEmitter(); - @Output() closePostingCreateEditModal = new EventEmitter(); - @Output() openThread = new EventEmitter(); - @Output() canPinOutput = new EventEmitter(); - - @ViewChild(PostCreateEditModalComponent) postCreateEditModal?: PostCreateEditModalComponent; - @ViewChild('createEditModal') createEditModal!: PostCreateEditModalComponent; - - isAtLeastInstructorInCourse: boolean; - mayDeleteOutput = output(); - mayEditOutput = output(); - mayEdit: boolean; - mayDelete: boolean; - - isAnyReactionCountAboveZero(): boolean { - return Object.values(this.reactionMetaDataMap).some((reaction) => reaction.count >= 1); - } - - openAnswerView() { - this.showAnswersChange.emit(true); - this.openPostingCreateEditModal.emit(); - } - - closeAnswerView() { - this.showAnswersChange.emit(false); - this.closePostingCreateEditModal.emit(); - } - - /** - * on initialization: call resetTooltipsAndPriority - */ - ngOnInit() { - super.ngOnInit(); - - const currentConversation = this.metisService.getCurrentConversation(); - this.setCanPin(currentConversation); - this.setMayDelete(); - this.setMayEdit(); - this.resetTooltipsAndPriority(); - } - - ngOnDestroy() { - this.postCreateEditModal?.modalRef?.close(); - } - - /** - * Checks whether the user can pin the message in the conversation - * - * @param currentConversation the conversation the post belongs to - */ - private setCanPin(currentConversation: ConversationDTO | undefined) { - if (!currentConversation) { - this.canPin = this.metisService.metisUserIsAtLeastTutorInCourse(); - return; - } - - if (isChannelDTO(currentConversation)) { - this.canPin = currentConversation.hasChannelModerationRights ?? false; - } else if (isGroupChatDTO(currentConversation)) { - this.canPin = currentConversation.creator?.id === this.accountService.userIdentity?.id; - } else if (isOneToOneChatDTO(currentConversation)) { - this.canPin = true; - } - this.canPinOutput.emit(this.canPin); - } - - /** - * on changes: call resetTooltipsAndPriority - */ - ngOnChanges() { - super.ngOnChanges(); - this.resetTooltipsAndPriority(); - this.setMayDelete(); - this.setMayEdit(); - } - - /** - * builds and returns a Reaction model out of an emojiId and thereby sets the post property properly - * @param emojiId emojiId to build the model for - */ - buildReaction(emojiId: string): Reaction { - const reaction = new Reaction(); - reaction.emojiId = emojiId; - reaction.post = this.posting; - return reaction; - } - - /** - * changes the state of the displayPriority property on a post to PINNED by invoking the metis service - * in case the displayPriority is already set to PINNED, it will be changed to NONE - */ - togglePin() { - if (this.displayPriority === DisplayPriority.PINNED) { - this.displayPriority = DisplayPriority.NONE; - } else { - this.displayPriority = DisplayPriority.PINNED; - } - this.posting.displayPriority = this.displayPriority; - this.metisService.updatePostDisplayPriority(this.posting.id!, this.displayPriority).subscribe(); - } - - checkIfPinned(): DisplayPriority { - return this.displayPriority; - } - - /** - * provides the tooltip for the pin icon dependent on the user authority and the pin state of a posting - * - */ - getPinTooltip(): string { - if (this.canPin && this.displayPriority === DisplayPriority.PINNED) { - return 'artemisApp.metis.removePinPostTooltip'; - } - if (this.canPin && this.displayPriority !== DisplayPriority.PINNED) { - return 'artemisApp.metis.pinPostTooltip'; - } - return 'artemisApp.metis.pinnedPostTooltip'; - } - - getShowNewMessageIcon(): boolean { - let showIcon = false; - // iterate over all answer posts - this.sortedAnswerPosts.forEach((answerPost) => { - // check if the answer post is newer than the last read date - const isAuthor = this.metisService.metisUserIsAuthorOfPosting(answerPost); - if (!isAuthor && !!(!this.lastReadDate || (this.lastReadDate && answerPost.creationDate && answerPost.creationDate.isAfter(this.lastReadDate!)))) { - showIcon = true; - } - }); - return showIcon; - } - - private resetTooltipsAndPriority() { - this.displayPriority = this.posting.displayPriority!; - this.pinTooltip = this.getPinTooltip(); - } - - /** - * invokes the metis service to delete a post - */ - deletePosting(): void { - this.isDeleteEvent.emit(true); - } - - editPosting() { - if (this.posting.title != '') { - this.createEditModal.open(); - } else { - this.isModalOpen.emit(); - } - } - - setMayDelete(): void { - this.isAtLeastInstructorInCourse = this.metisService.metisUserIsAtLeastInstructorInCourse(); - const isCourseWideChannel = getAsChannelDTO(this.posting.conversation)?.isCourseWide ?? false; - const isAnswerOfAnnouncement = getAsChannelDTO(this.posting.conversation)?.isAnnouncementChannel ?? false; - const isAtLeastTutorInCourse = this.metisService.metisUserIsAtLeastTutorInCourse(); - const canDeleteAnnouncement = isAnswerOfAnnouncement ? this.metisService.metisUserIsAtLeastInstructorInCourse() : true; - const mayDeleteOtherUsersAnswer = - (isCourseWideChannel && isAtLeastTutorInCourse) || (getAsChannelDTO(this.metisService.getCurrentConversation())?.hasChannelModerationRights ?? false); - this.mayDelete = !this.readOnlyMode && !this.previewMode && (this.isAuthorOfPosting || mayDeleteOtherUsersAnswer) && canDeleteAnnouncement; - this.mayDeleteOutput.emit(this.mayDelete); - } - - setMayEdit(): void { - this.mayEdit = this.isAuthorOfPosting; - this.mayEditOutput.emit(this.mayEdit); - } -} diff --git a/src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.component.html b/src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.component.html new file mode 100644 index 000000000000..144f0e3859cb --- /dev/null +++ b/src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.component.html @@ -0,0 +1,172 @@ +
+ @if (getPostingType() === 'post') { + @if (hoverBar() && sortedAnswerPosts()?.length === 0) { +
+ +
+ } + @if (!isCommunicationPage() && sortedAnswerPosts()?.length) { + @if (showAnswers()) { +
+ +
+ } @else { +
+ +
+ } + } @else if (!isThreadSidebar() && !showAnswers() && sortedAnswerPosts()?.length) { +
+ +
+ } + } + + @for (reactionMetaData of reactionMetaDataMap | keyvalue; track reactionMetaData) { + @if (isEmojiCount()) { +
+ +
+ } + } + +
+ + @if ((isAnyReactionCountAboveZero() && isEmojiCount()) || !isEmojiCount()) { + + + + + + + + } + + + @if (!isEmojiCount() && mayEdit) { + + } + + + @if (!isEmojiCount() && mayDelete) { + + } + + + @if (getPostingType() === 'answerPost' && !isEmojiCount()) { + @if (!isAnswerOfAnnouncement && (isAtLeastTutorInCourse || isAuthorOfOriginalPost)) { + + } + } + + + @if (getPostingType() === 'post' && !isEmojiCount() && (displayPriority === DisplayPriority.PINNED || canPin)) { + + } + + + @if (!isEmojiCount()) { + + } +
+ + + @if (getPostingType() === 'post' && isEmojiCount() && getShowNewMessageIcon()) { +
+ } +
diff --git a/src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.component.ts b/src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.component.ts new file mode 100644 index 000000000000..1dc6ddce5c0e --- /dev/null +++ b/src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.component.ts @@ -0,0 +1,484 @@ +import { Component, OnChanges, OnInit, inject, input, output, viewChild } from '@angular/core'; +import { Posting } from 'app/entities/metis/posting.model'; +import { MetisService } from 'app/shared/metis/metis.service'; +import { EmojiData } from '@ctrl/ngx-emoji-mart/ngx-emoji'; +import { Reaction } from 'app/entities/metis/reaction.model'; +import { PLACEHOLDER_USER_REACTED, ReactingUsersOnPostingPipe } from 'app/shared/pipes/reacting-users-on-posting.pipe'; +import { faArrowRight, faBookmark, faCheck, faPencilAlt, faSmile, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; +import { EmojiComponent } from 'app/shared/metis/emoji/emoji.component'; +import { EmojiPickerComponent } from 'app/shared/metis/emoji/emoji-picker.component'; +import { ConfirmIconComponent } from 'app/shared/confirm-icon/confirm-icon.component'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; +import { CdkConnectedOverlay, CdkOverlayOrigin } from '@angular/cdk/overlay'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { AsyncPipe, KeyValuePipe, NgClass } from '@angular/common'; +import { AccountService } from 'app/core/auth/account.service'; +import { DisplayPriority } from '../metis.util'; +import { Post } from 'app/entities/metis/post.model'; +import { Conversation, ConversationDTO } from 'app/entities/metis/conversation/conversation.model'; +import { getAsChannelDTO, isChannelDTO } from 'app/entities/metis/conversation/channel.model'; +import { isGroupChatDTO } from 'app/entities/metis/conversation/group-chat.model'; +import { isOneToOneChatDTO } from 'app/entities/metis/conversation/one-to-one-chat.model'; +import { AnswerPost } from 'app/entities/metis/answer-post.model'; +import dayjs from 'dayjs'; +import { PostCreateEditModalComponent } from 'app/shared/metis/posting-create-edit-modal/post-create-edit-modal/post-create-edit-modal.component'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; + +const PIN_EMOJI_ID = 'pushpin'; +const ARCHIVE_EMOJI_ID = 'open_file_folder'; +const HEAVY_MULTIPLICATION_ID = 'heavy_multiplication_x'; + +const SPEECH_BALLOON_UNICODE = '1F4AC'; +const ARCHIVE_EMOJI_UNICODE = '1F4C2'; +const PIN_EMOJI_UNICODE = '1F4CC'; +const HEAVY_MULTIPLICATION_UNICODE = '2716'; + +/** + * event triggered by the emoji mart component, including EmojiData + */ +interface ReactionEvent { + $event: Event; + emoji?: EmojiData; +} + +/** + * represents the amount of users that reacted + * hasReacted indicates if the currently logged-in user is among those counted users + */ +interface ReactionMetaData { + count: number; + hasReacted: boolean; + reactingUsers: string[]; +} + +/** + * data structure used for displaying emoji reactions with metadata on postings + */ +interface ReactionMetaDataMap { + [emojiId: string]: ReactionMetaData; +} + +@Component({ + selector: 'jhi-posting-reactions-bar', + templateUrl: './posting-reactions-bar.component.html', + styleUrls: ['./posting-reactions-bar.component.scss'], + imports: [ + NgbTooltip, + EmojiComponent, + CdkOverlayOrigin, + FaIconComponent, + TranslateDirective, + CdkConnectedOverlay, + EmojiPickerComponent, + ConfirmIconComponent, + AsyncPipe, + KeyValuePipe, + ArtemisTranslatePipe, + ReactingUsersOnPostingPipe, + NgClass, + PostCreateEditModalComponent, + ], +}) +export class PostingReactionsBarComponent implements OnInit, OnChanges { + protected metisService = inject(MetisService); + private accountService = inject(AccountService); + + pinEmojiId: string = PIN_EMOJI_ID; + archiveEmojiId: string = ARCHIVE_EMOJI_ID; + closeCrossId: string = HEAVY_MULTIPLICATION_ID; + + posting = input(); + isThreadSidebar = input(); + isEmojiCount = input(false); + isReadOnlyMode = input(false); + + openPostingCreateEditModal = output(); + closePostingCreateEditModal = output(); + reactionsUpdated = output(); + isModalOpen = output(); + + showReactionSelector = false; + isAtLeastTutorInCourse: boolean; + isAuthorOfPosting: boolean; + isAuthorOfOriginalPost: boolean; + isAnswerOfAnnouncement: boolean; + isAtLeastInstructorInCourse: boolean; + mayDeleteOutput = output(); + mayEditOutput = output(); + canPinOutput = output(); + showAnswers = input(); + sortedAnswerPosts = input(); + isCommunicationPage = input(); + lastReadDate = input(); + previewMode = input(); + hoverBar = input(true); + + showAnswersChange = output(); + isLastAnswer = input(false); + postingUpdated = output(); + mayEdit: boolean; + mayDelete: boolean; + pinTooltip: string; + displayPriority: DisplayPriority; + canPin = false; + readonly DisplayPriority = DisplayPriority; + + isDeleteEvent = output(); + readonly onBookmarkClicked = output(); + openThread = output(); + + // Icons + readonly faBookmark = faBookmark; + readonly faSmile = faSmile; + readonly faCheck = faCheck; + readonly faPencilAlt = faPencilAlt; + readonly faArrowRight = faArrowRight; + readonly faTrash = faTrashAlt; + + createEditModal = viewChild.required('createEditModal'); + + /** + * on initialization: updates the current posting and its reactions, + * invokes metis service to check user authority + */ + ngOnInit() { + this.updatePostingWithReactions(); + this.isAuthorOfPosting = this.metisService.metisUserIsAuthorOfPosting(this.posting() as Posting); + this.isAtLeastTutorInCourse = this.metisService.metisUserIsAtLeastTutorInCourse(); + this.isAtLeastInstructorInCourse = this.metisService.metisUserIsAtLeastInstructorInCourse(); + this.isAnswerOfAnnouncement = + this.getPostingType() === 'answerPost' ? (getAsChannelDTO((this.posting() as AnswerPost).post?.conversation)?.isAnnouncementChannel ?? false) : false; + this.isAuthorOfOriginalPost = this.getPostingType() === 'answerPost' ? this.metisService.metisUserIsAuthorOfPosting((this.posting() as AnswerPost).post!) : false; + + if (this.getPostingType() === 'post') { + const currentConversation = this.metisService.getCurrentConversation(); + this.setCanPin(currentConversation); + this.resetTooltipsAndPriority(); + } + this.setMayDelete(); + this.setMayEdit(); + } + + /** + * on changes: updates the current posting and its reactions, + * invokes metis service to check user authority + */ + ngOnChanges() { + this.updatePostingWithReactions(); + this.isAtLeastTutorInCourse = this.metisService.metisUserIsAtLeastTutorInCourse(); + if (this.getPostingType() === 'post') { + this.resetTooltipsAndPriority(); + } + this.setMayDelete(); + this.setMayEdit(); + } + + /* + * icons (as svg paths) to be used as category preview image in emoji mart selector + */ + categoriesIcons: { [key: string]: string } = { + // category 'recent' (would show recently used emojis) is overwritten by a preselected set of emojis for that course, + // therefore category icon is an asterisk (indicating customization) instead of a clock (indicating the "recently used"-use case) + recent: `M10 1h3v21h-3zm10.186 4l1.5 2.598L3.5 18.098 2 15.5zM2 7.598L3.5 5l18.186 10.5-1.5 2.598z`, + }; + + /** + * Checks whether the user can pin the message in the conversation + * + * @param currentConversation the conversation the post belongs to + */ + setCanPin(currentConversation: ConversationDTO | undefined) { + if (!currentConversation) { + this.canPin = this.metisService.metisUserIsAtLeastTutorInCourse(); + return; + } + + if (isChannelDTO(currentConversation)) { + this.canPin = currentConversation.hasChannelModerationRights ?? false; + } else if (isGroupChatDTO(currentConversation)) { + this.canPin = currentConversation.creator?.id === this.accountService.userIdentity?.id; + } else if (isOneToOneChatDTO(currentConversation)) { + this.canPin = true; + } + this.canPinOutput.emit(this.canPin); + } + + private resetTooltipsAndPriority() { + this.displayPriority = (this.posting() as Post).displayPriority!; + this.pinTooltip = this.getPinTooltip(); + } + + getShowNewMessageIcon(): boolean { + let showIcon = false; + // iterate over all answer posts + (this.sortedAnswerPosts() as unknown as AnswerPost[]).forEach((answerPost: Posting) => { + // check if the answer post is newer than the last read date + const isAuthor = this.metisService.metisUserIsAuthorOfPosting(answerPost); + const lastReadDate = this.lastReadDate?.(); + const creationDate = answerPost.creationDate; + + if (lastReadDate && creationDate) { + const lastReadDateDayJs = dayjs(lastReadDate); + // @ts-ignore + if (!isAuthor && creationDate.isAfter(lastReadDateDayJs)) { + showIcon = true; + } + } + }); + return showIcon; + } + + /** + * provides the tooltip for the pin icon dependent on the user authority and the pin state of a posting + * + */ + getPinTooltip(): string { + if (this.canPin && this.displayPriority === DisplayPriority.PINNED) { + return 'artemisApp.metis.removePinPostTooltip'; + } + if (this.canPin && this.displayPriority !== DisplayPriority.PINNED) { + return 'artemisApp.metis.pinPostTooltip'; + } + return 'artemisApp.metis.pinnedPostTooltip'; + } + + /** + * currently predefined fixed set of emojis that should be used within a course, + * they will be listed on first page of the emoji-mart selector + */ + selectedCourseEmojis = ['smile', 'joy', 'sunglasses', 'tada', 'rocket', 'heavy_plus_sign', 'thumbsup', 'memo', 'coffee', 'recycle']; + + /** + * emojis that have a predefined meaning, i.e. pin and archive emoji, + * should not appear in the emoji-mart selector + */ + emojisToShowFilter: (emoji: string | EmojiData) => boolean = (emoji) => { + if (typeof emoji === 'string') { + return emoji !== PIN_EMOJI_UNICODE && emoji !== ARCHIVE_EMOJI_UNICODE && emoji !== SPEECH_BALLOON_UNICODE && emoji !== HEAVY_MULTIPLICATION_UNICODE; + } else { + return ( + emoji.unified !== PIN_EMOJI_UNICODE && + emoji.unified !== ARCHIVE_EMOJI_UNICODE && + emoji.unified !== SPEECH_BALLOON_UNICODE && + emoji.unified !== HEAVY_MULTIPLICATION_UNICODE + ); + } + }; + + /** + * map that lists associated reaction (by emojiId) for the current posting together with its count + * and a flag that indicates if the current user has used this reaction + */ + reactionMetaDataMap: ReactionMetaDataMap = {}; + + /** + * builds and returns a Reaction model out of an emojiId and thereby sets the answerPost property properly + * @param emojiId emojiId to build the model for + */ + buildReaction(emojiId: string): Reaction { + const reaction = new Reaction(); + reaction.emojiId = emojiId; + if (this.getPostingType() === 'answerPost') { + reaction.answerPost = this.posting() as AnswerPost; + } else { + reaction.post = this.posting() as Post; + } + return reaction; + } + + /** + * updates the reaction based on the ReactionEvent emitted by the emoji-mart selector component + */ + selectReaction(reactionEvent: ReactionEvent): void { + if (reactionEvent.emoji != undefined) { + this.addOrRemoveReaction(reactionEvent.emoji.id); + } + } + + /** + * opens the emoji selector overlay if user clicks the '.reaction-button' + * closes the emoji selector overly if user clicks the '.reaction-button' again or somewhere outside the overlay + */ + toggleEmojiSelect() { + this.showReactionSelector = !this.showReactionSelector; + } + + /** + * updates the reaction based when a displayed emoji reaction is clicked, + * i.e. when agree on an existing reaction (+1) or removing own reactions (-1) + */ + updateReaction(emojiId: string): void { + if (emojiId != undefined) { + this.addOrRemoveReaction(emojiId); + } + } + + /** + * adds or removes a reaction by invoking the metis service, + * depending on if the current user already reacted with the given emojiId (remove) or not (add) + * @param emojiId emojiId representing the reaction to be added/removed + */ + addOrRemoveReaction(emojiId: string): void { + const existingReactionIdx = (this.posting() as Posting).reactions + ? (this.posting() as Posting).reactions!.findIndex((reaction) => reaction.user?.id === this.metisService.getUser().id && reaction.emojiId === emojiId) + : -1; + if ((this.posting() as Posting).reactions && existingReactionIdx > -1) { + const reactionToDelete = (this.posting() as Posting).reactions![existingReactionIdx]; + this.metisService.deleteReaction(reactionToDelete).subscribe(() => { + (this.posting() as Posting).reactions = (this.posting() as Posting).reactions?.filter((reaction) => reaction.id !== reactionToDelete.id); + this.updatePostingWithReactions(); + this.showReactionSelector = false; + this.reactionsUpdated.emit((this.posting() as Posting).reactions!); + }); + } else { + const reactionToCreate = this.buildReaction(emojiId); + this.metisService.createReaction(reactionToCreate).subscribe(() => { + this.updatePostingWithReactions(); + this.showReactionSelector = false; + this.reactionsUpdated.emit((this.posting() as Posting).reactions || []); + }); + } + } + + /** + * updates the posting's reactions by calling the build function for the reactionMetaDataMap if there are any reaction on the posting + */ + updatePostingWithReactions(): void { + if ((this.posting() as Posting).reactions && (this.posting() as Posting).reactions!.length > 0) { + // filter out emoji for pin and archive as they should not be listed in the reactionMetaDataMap + const filteredReactions = (this.posting() as Posting).reactions!.filter( + (reaction: Reaction) => reaction.emojiId !== this.pinEmojiId || reaction.emojiId !== this.archiveEmojiId, + ); + this.reactionMetaDataMap = this.buildReactionMetaDataMap(filteredReactions); + } else { + this.reactionMetaDataMap = {}; + } + } + + /** + * builds the ReactionMetaDataMap data structure out of a given array of reactions + * @param reactions array of reactions associated to the current posting + */ + buildReactionMetaDataMap(reactions: Reaction[]): ReactionMetaDataMap { + return reactions.reduce((metaDataMap: ReactionMetaDataMap, reaction: Reaction) => { + const hasReacted = reaction.user?.id === this.metisService.getUser().id; + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + const reactingUser = hasReacted ? PLACEHOLDER_USER_REACTED : reaction.user?.name!; + const reactionMetaData: ReactionMetaData = { + count: metaDataMap[reaction.emojiId!] ? metaDataMap[reaction.emojiId!].count + 1 : 1, + hasReacted: metaDataMap[reaction.emojiId!] ? metaDataMap[reaction.emojiId!].hasReacted || hasReacted : hasReacted, + reactingUsers: metaDataMap[reaction.emojiId!] ? metaDataMap[reaction.emojiId!].reactingUsers.concat(reactingUser) : [reactingUser], + }; + return { ...metaDataMap, [reaction.emojiId!]: reactionMetaData }; + }, {}); + } + + protected bookmarkPosting() { + this.onBookmarkClicked.emit(); + } + + isAnyReactionCountAboveZero(): boolean { + return Object.values(this.reactionMetaDataMap).some((reaction) => reaction.count >= 1); + } + + /** + * invokes the metis service to delete posting + */ + deletePosting(): void { + this.isDeleteEvent.emit(true); + } + + /** + * changes the state of the displayPriority property on a post to PINNED by invoking the metis service + * in case the displayPriority is already set to PINNED, it will be changed to NONE + */ + togglePin() { + if (this.canPin) { + if (this.displayPriority === DisplayPriority.PINNED) { + this.displayPriority = DisplayPriority.NONE; + } else { + this.displayPriority = DisplayPriority.PINNED; + } + (this.posting() as Post).displayPriority = this.displayPriority; + this.metisService.updatePostDisplayPriority((this.posting() as Posting).id!, this.displayPriority).subscribe(); + } + } + + /** + * toggles the resolvesPost property of an answer post if the user is at least tutor in a course or the user is the author of the original post, + * delegates the update to the metis service + */ + toggleResolvesPost(): void { + if (this.isAtLeastTutorInCourse || this.isAuthorOfOriginalPost) { + (this.posting() as AnswerPost).resolvesPost = !(this.posting() as AnswerPost).resolvesPost; + this.metisService.updateAnswerPost(this.posting() as AnswerPost).subscribe(); + } + } + + checkIfPinned(): DisplayPriority { + return this.displayPriority; + } + + openAnswerView() { + this.showAnswersChange.emit(true); + this.openPostingCreateEditModal.emit(); + } + + closeAnswerView() { + this.showAnswersChange.emit(false); + this.closePostingCreateEditModal.emit(); + } + + setMayEdit(): void { + this.mayEdit = this.isAuthorOfPosting; + this.mayEditOutput.emit(this.mayEdit); + } + + editPosting() { + if (this.getPostingType() === 'post') { + if ((this.posting() as Post)!.title != '') { + this.createEditModal().open(); + } else { + this.isModalOpen.emit(); + } + } else { + this.openPostingCreateEditModal.emit(); + } + } + + setMayDelete(): void { + const conversation = this.getConversation(); + const channel = getAsChannelDTO(conversation); + + const isAnswerOfAnnouncement = this.getPostingType() === 'answerPost' ? (channel?.isAnnouncementChannel ?? false) : false; + const isCourseWide = channel?.isCourseWide ?? false; + + const canDeleteAnnouncement = isAnswerOfAnnouncement ? this.isAtLeastInstructorInCourse : true; + const mayDeleteOtherUsers = + (isCourseWide && this.isAtLeastTutorInCourse) || (getAsChannelDTO(this.metisService.getCurrentConversation())?.hasChannelModerationRights ?? false); + + this.mayDelete = !this.isReadOnlyMode() && !this.previewMode() && (this.isAuthorOfPosting || mayDeleteOtherUsers) && canDeleteAnnouncement; + this.mayDeleteOutput.emit(this.mayDelete); + } + + private getConversation(): Conversation | undefined { + if (this.getPostingType() === 'answerPost') { + return (this.posting() as AnswerPost).post?.conversation; + } else { + return (this.posting() as Post).conversation; + } + } + + getPostingType(): 'post' | 'answerPost' { + return this.posting() && 'post' in this.posting()! ? 'answerPost' : 'post'; + } + + getSaved(): boolean { + return (this.posting() as Posting)?.isSaved; + } + + getResolvesPost(): boolean { + return (this.posting() as AnswerPost)?.resolvesPost; + } +} diff --git a/src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.directive.ts b/src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.directive.ts deleted file mode 100644 index d0cc31c1d752..000000000000 --- a/src/main/webapp/app/shared/metis/posting-reactions-bar/posting-reactions-bar.directive.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { Directive, EventEmitter, Input, OnChanges, OnInit, Output, inject, output } from '@angular/core'; -import { Posting } from 'app/entities/metis/posting.model'; -import { MetisService } from 'app/shared/metis/metis.service'; -import { EmojiData } from '@ctrl/ngx-emoji-mart/ngx-emoji'; -import { Reaction } from 'app/entities/metis/reaction.model'; -import { PLACEHOLDER_USER_REACTED } from 'app/shared/pipes/reacting-users-on-posting.pipe'; -import { faBookmark } from '@fortawesome/free-solid-svg-icons'; -import { faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons'; - -const PIN_EMOJI_ID = 'pushpin'; -const ARCHIVE_EMOJI_ID = 'open_file_folder'; -const HEAVY_MULTIPLICATION_ID = 'heavy_multiplication_x'; - -const SPEECH_BALLOON_UNICODE = '1F4AC'; -const ARCHIVE_EMOJI_UNICODE = '1F4C2'; -const PIN_EMOJI_UNICODE = '1F4CC'; -const HEAVY_MULTIPLICATION_UNICODE = '2716'; - -/** - * event triggered by the emoji mart component, including EmojiData - */ -interface ReactionEvent { - $event: Event; - emoji?: EmojiData; -} - -/** - * represents the amount of users that reacted - * hasReacted indicates if the currently logged-in user is among those counted users - */ -interface ReactionMetaData { - count: number; - hasReacted: boolean; - reactingUsers: string[]; -} - -/** - * data structure used for displaying emoji reactions with metadata on postings - */ -interface ReactionMetaDataMap { - [emojiId: string]: ReactionMetaData; -} - -@Directive() -export abstract class PostingsReactionsBarDirective implements OnInit, OnChanges { - protected metisService = inject(MetisService); - - pinEmojiId: string = PIN_EMOJI_ID; - archiveEmojiId: string = ARCHIVE_EMOJI_ID; - closeCrossId: string = HEAVY_MULTIPLICATION_ID; - - @Input() posting: T; - @Input() isThreadSidebar: boolean; - - @Output() openPostingCreateEditModal = new EventEmitter(); - @Output() reactionsUpdated = new EventEmitter(); - @Output() isModalOpen = new EventEmitter(); - - showReactionSelector = false; - currentUserIsAtLeastTutor: boolean; - isAtLeastTutorInCourse: boolean; - isAuthorOfPosting: boolean; - - isDeleteEvent = output(); - readonly onBookmarkClicked = output(); - - // Icons - readonly farBookmark = farBookmark; - readonly faBookmark = faBookmark; - - /* - * icons (as svg paths) to be used as category preview image in emoji mart selector - */ - categoriesIcons: { [key: string]: string } = { - // category 'recent' (would show recently used emojis) is overwritten by a preselected set of emojis for that course, - // therefore category icon is an asterisk (indicating customization) instead of a clock (indicating the "recently used"-use case) - recent: `M10 1h3v21h-3zm10.186 4l1.5 2.598L3.5 18.098 2 15.5zM2 7.598L3.5 5l18.186 10.5-1.5 2.598z`, - }; - - /** - * currently predefined fixed set of emojis that should be used within a course, - * they will be listed on first page of the emoji-mart selector - */ - selectedCourseEmojis = ['smile', 'joy', 'sunglasses', 'tada', 'rocket', 'heavy_plus_sign', 'thumbsup', 'memo', 'coffee', 'recycle']; - - /** - * emojis that have a predefined meaning, i.e. pin and archive emoji, - * should not appear in the emoji-mart selector - */ - emojisToShowFilter: (emoji: string | EmojiData) => boolean = (emoji) => { - if (typeof emoji === 'string') { - return emoji !== PIN_EMOJI_UNICODE && emoji !== ARCHIVE_EMOJI_UNICODE && emoji !== SPEECH_BALLOON_UNICODE && emoji !== HEAVY_MULTIPLICATION_UNICODE; - } else { - return ( - emoji.unified !== PIN_EMOJI_UNICODE && - emoji.unified !== ARCHIVE_EMOJI_UNICODE && - emoji.unified !== SPEECH_BALLOON_UNICODE && - emoji.unified !== HEAVY_MULTIPLICATION_UNICODE - ); - } - }; - - /** - * map that lists associated reaction (by emojiId) for the current posting together with its count - * and a flag that indicates if the current user has used this reaction - */ - reactionMetaDataMap: ReactionMetaDataMap = {}; - - /** - * on initialization: updates the current posting and its reactions, - * invokes metis service to check user authority - */ - ngOnInit(): void { - this.updatePostingWithReactions(); - this.currentUserIsAtLeastTutor = this.metisService.metisUserIsAtLeastTutorInCourse(); - this.isAuthorOfPosting = this.metisService.metisUserIsAuthorOfPosting(this.posting); - this.isAtLeastTutorInCourse = this.metisService.metisUserIsAtLeastTutorInCourse(); - } - - deletePosting(): void { - this.metisService.deletePost(this.posting); - } - - /** - * on changes: updates the current posting and its reactions, - * invokes metis service to check user authority - */ - ngOnChanges() { - this.updatePostingWithReactions(); - this.currentUserIsAtLeastTutor = this.metisService.metisUserIsAtLeastTutorInCourse(); - } - - abstract buildReaction(emojiId: string): Reaction; - - /** - * updates the reaction based on the ReactionEvent emitted by the emoji-mart selector component - */ - selectReaction(reactionEvent: ReactionEvent): void { - if (reactionEvent.emoji != undefined) { - this.addOrRemoveReaction(reactionEvent.emoji.id); - } - } - - /** - * opens the emoji selector overlay if user clicks the '.reaction-button' - * closes the emoji selector overly if user clicks the '.reaction-button' again or somewhere outside the overlay - */ - toggleEmojiSelect() { - this.showReactionSelector = !this.showReactionSelector; - } - - /** - * updates the reaction based when a displayed emoji reaction is clicked, - * i.e. when agree on an existing reaction (+1) or removing own reactions (-1) - */ - updateReaction(emojiId: string): void { - if (emojiId != undefined) { - this.addOrRemoveReaction(emojiId); - } - } - - /** - * adds or removes a reaction by invoking the metis service, - * depending on if the current user already reacted with the given emojiId (remove) or not (add) - * @param emojiId emojiId representing the reaction to be added/removed - */ - addOrRemoveReaction(emojiId: string): void { - const existingReactionIdx = this.posting.reactions - ? this.posting.reactions.findIndex((reaction) => reaction.user?.id === this.metisService.getUser().id && reaction.emojiId === emojiId) - : -1; - if (this.posting.reactions && existingReactionIdx > -1) { - const reactionToDelete = this.posting.reactions[existingReactionIdx]; - this.metisService.deleteReaction(reactionToDelete).subscribe(() => { - this.posting.reactions = this.posting.reactions?.filter((reaction) => reaction.id !== reactionToDelete.id); - this.updatePostingWithReactions(); - this.showReactionSelector = false; - this.reactionsUpdated.emit(this.posting.reactions); - }); - } else { - const reactionToCreate = this.buildReaction(emojiId); - this.metisService.createReaction(reactionToCreate).subscribe(() => { - this.updatePostingWithReactions(); - this.showReactionSelector = false; - this.reactionsUpdated.emit(this.posting.reactions); - }); - } - } - - /** - * updates the posting's reactions by calling the build function for the reactionMetaDataMap if there are any reaction on the posting - */ - updatePostingWithReactions(): void { - if (this.posting.reactions && this.posting.reactions.length > 0) { - // filter out emoji for pin and archive as they should not be listed in the reactionMetaDataMap - const filteredReactions = this.posting.reactions.filter((reaction: Reaction) => reaction.emojiId !== this.pinEmojiId || reaction.emojiId !== this.archiveEmojiId); - this.reactionMetaDataMap = this.buildReactionMetaDataMap(filteredReactions); - } else { - this.reactionMetaDataMap = {}; - } - } - - /** - * builds the ReactionMetaDataMap data structure out of a given array of reactions - * @param reactions array of reactions associated to the current posting - */ - buildReactionMetaDataMap(reactions: Reaction[]): ReactionMetaDataMap { - return reactions.reduce((metaDataMap: ReactionMetaDataMap, reaction: Reaction) => { - const hasReacted = reaction.user?.id === this.metisService.getUser().id; - // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - const reactingUser = hasReacted ? PLACEHOLDER_USER_REACTED : reaction.user?.name!; - const reactionMetaData: ReactionMetaData = { - count: metaDataMap[reaction.emojiId!] ? metaDataMap[reaction.emojiId!].count + 1 : 1, - hasReacted: metaDataMap[reaction.emojiId!] ? metaDataMap[reaction.emojiId!].hasReacted || hasReacted : hasReacted, - reactingUsers: metaDataMap[reaction.emojiId!] ? metaDataMap[reaction.emojiId!].reactingUsers.concat(reactingUser) : [reactingUser], - }; - return { ...metaDataMap, [reaction.emojiId!]: reactionMetaData }; - }, {}); - } - - protected bookmarkPosting() { - this.onBookmarkClicked.emit(); - } -} diff --git a/src/test/javascript/spec/component/shared/metis/answer-post/answer-post.component.spec.ts b/src/test/javascript/spec/component/shared/metis/answer-post/answer-post.component.spec.ts index 01e4f96bba36..efcc2ed8bf78 100644 --- a/src/test/javascript/spec/component/shared/metis/answer-post/answer-post.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/answer-post/answer-post.component.spec.ts @@ -4,7 +4,6 @@ import { DebugElement, input, runInInjectionContext } from '@angular/core'; import { MockComponent, MockDirective, MockModule, MockPipe, ngMocks } from 'ng-mocks'; import { HtmlForMarkdownPipe } from 'app/shared/pipes/html-for-markdown.pipe'; import { By } from '@angular/platform-browser'; -import { AnswerPostReactionsBarComponent } from 'app/shared/metis/posting-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component'; import { PostingContentComponent } from 'app/shared/metis/posting-content/posting-content.components'; import { metisPostExerciseUser1, metisResolvingAnswerPostUser1 } from '../../../../helpers/sample/metis-sample-data'; import { OverlayModule } from '@angular/cdk/overlay'; @@ -27,9 +26,12 @@ import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideHttpClient } from '@angular/common/http'; import { MockSyncStorage } from '../../../../helpers/mocks/service/mock-sync-storage.service'; -import { SessionStorageService } from 'ngx-webstorage'; +import { LocalStorageService, SessionStorageService } from 'ngx-webstorage'; import { MetisConversationService } from 'app/shared/metis/metis-conversation.service'; import { MockMetisConversationService } from '../../../../helpers/mocks/service/mock-metis-conversation.service'; +import { AccountService } from '../../../../../../../main/webapp/app/core/auth/account.service'; +import { MockAccountService } from '../../../../helpers/mocks/service/mock-account.service'; +import { MockLocalStorageService } from '../../../../helpers/mocks/service/mock-local-storage.service'; describe('AnswerPostComponent', () => { let component: AnswerPostComponent; @@ -50,7 +52,6 @@ describe('AnswerPostComponent', () => { MockComponent(PostingContentComponent), MockComponent(PostingHeaderComponent), MockComponent(AnswerPostCreateEditModalComponent), - MockComponent(AnswerPostReactionsBarComponent), ArtemisDatePipe, ArtemisTranslatePipe, MockDirective(TranslateDirective), @@ -63,6 +64,8 @@ describe('AnswerPostComponent', () => { { provide: TranslateService, useClass: MockTranslateService }, { provide: SessionStorageService, useClass: MockSyncStorage }, { provide: MetisConversationService, useClass: MockMetisConversationService }, + { provide: AccountService, useClass: MockAccountService }, + { provide: LocalStorageService, useClass: MockLocalStorageService }, ], }) .compileComponents() @@ -118,7 +121,7 @@ describe('AnswerPostComponent', () => { component.posting = metisResolvingAnswerPostUser1; fixture.detectChanges(); - const reactionsBar = debugElement.query(By.css('jhi-answer-post-reactions-bar')); + const reactionsBar = debugElement.query(By.css('jhi-posting-reactions-bar')); expect(reactionsBar).not.toBeNull(); }); diff --git a/src/test/javascript/spec/component/shared/metis/post/post.component.spec.ts b/src/test/javascript/spec/component/shared/metis/post/post.component.spec.ts index 5866fbdeb437..40af94515518 100644 --- a/src/test/javascript/spec/component/shared/metis/post/post.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/post/post.component.spec.ts @@ -4,8 +4,8 @@ import { DebugElement } from '@angular/core'; import { HtmlForMarkdownPipe } from 'app/shared/pipes/html-for-markdown.pipe'; import { PostComponent } from 'app/shared/metis/post/post.component'; import { getElement } from '../../../../helpers/utils/general.utils'; -import { PostingFooterComponent } from '../../../../../../../main/webapp/app/shared/metis/posting-footer/posting-footer.component'; -import { PostingHeaderComponent } from '../../../../../../../main/webapp/app/shared/metis/posting-header/posting-header.component'; +import { PostingFooterComponent } from 'app/shared/metis/posting-footer/posting-footer.component'; +import { PostingHeaderComponent } from 'app/shared/metis/posting-header/posting-header.component'; import { PostingContentComponent } from 'app/shared/metis/posting-content/posting-content.components'; import { MockMetisService } from '../../../../helpers/mocks/service/mock-metis-service.service'; import { MetisService } from 'app/shared/metis/metis.service'; @@ -33,17 +33,20 @@ import { OneToOneChatDTO } from 'app/entities/metis/conversation/one-to-one-chat import { HttpResponse } from '@angular/common/http'; import { MockRouter } from '../../../../helpers/mocks/mock-router'; import { AnswerPostCreateEditModalComponent } from 'app/shared/metis/posting-create-edit-modal/answer-post-create-edit-modal/answer-post-create-edit-modal.component'; -import { PostReactionsBarComponent } from 'app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { DOCUMENT } from '@angular/common'; import { Posting, PostingType } from 'app/entities/metis/posting.model'; import { Post } from 'app/entities/metis/post.model'; -import { ArtemisTranslatePipe } from '../../../../../../../main/webapp/app/shared/pipes/artemis-translate.pipe'; -import { ArtemisDatePipe } from '../../../../../../../main/webapp/app/shared/pipes/artemis-date.pipe'; -import { TranslateDirective } from '../../../../../../../main/webapp/app/shared/language/translate.directive'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; import { TranslateService } from '@ngx-translate/core'; import { By } from '@angular/platform-browser'; import dayjs from 'dayjs/esm'; +import { AccountService } from 'app/core/auth/account.service'; +import { MockAccountService } from '../../../../helpers/mocks/service/mock-account.service'; +import { MockLocalStorageService } from '../../../../helpers/mocks/service/mock-local-storage.service'; +import { LocalStorageService } from 'ngx-webstorage'; describe('PostComponent', () => { let component: PostComponent; @@ -71,6 +74,8 @@ describe('PostComponent', () => { MockProvider(MetisConversationService), MockProvider(OneToOneChatService), { provide: TranslateService, useClass: MockTranslateService }, + { provide: AccountService, useClass: MockAccountService }, + { provide: LocalStorageService, useClass: MockLocalStorageService }, ], declarations: [ PostComponent, @@ -80,7 +85,6 @@ describe('PostComponent', () => { MockComponent(PostingContentComponent), MockComponent(PostingFooterComponent), MockComponent(AnswerPostCreateEditModalComponent), - MockComponent(PostReactionsBarComponent), MockRouterLinkDirective, MockQueryParamsDirective, TranslatePipeMock, diff --git a/src/test/javascript/spec/component/shared/metis/postings-footer/posting-footer.component.spec.ts b/src/test/javascript/spec/component/shared/metis/postings-footer/posting-footer.component.spec.ts index bfdc7d6e567b..3efd5fc951c0 100644 --- a/src/test/javascript/spec/component/shared/metis/postings-footer/posting-footer.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/postings-footer/posting-footer.component.spec.ts @@ -1,7 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MockComponent, MockModule } from 'ng-mocks'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { PostReactionsBarComponent } from 'app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component'; import { ArtemisCoursesRoutingModule } from 'app/overview/courses-routing.module'; import { MetisService } from 'app/shared/metis/metis.service'; import { PostService } from 'app/shared/metis/post.service'; @@ -46,7 +45,6 @@ describe('PostingFooterComponent', () => { PostingFooterComponent, TranslatePipeMock, MockComponent(FaIconComponent), - MockComponent(PostReactionsBarComponent), MockComponent(PostComponent), MockComponent(AnswerPostComponent), MockComponent(AnswerPostCreateEditModalComponent), diff --git a/src/test/javascript/spec/component/shared/metis/postings-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component.spec.ts b/src/test/javascript/spec/component/shared/metis/postings-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component.spec.ts deleted file mode 100644 index 96517283b95c..000000000000 --- a/src/test/javascript/spec/component/shared/metis/postings-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component.spec.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { EmojiComponent } from 'app/shared/metis/emoji/emoji.component'; -import { MetisService } from 'app/shared/metis/metis.service'; -import { MockComponent, MockModule, MockPipe, MockProvider } from 'ng-mocks'; -import { OverlayModule } from '@angular/cdk/overlay'; -import { Reaction } from 'app/entities/metis/reaction.model'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { ReactionService } from 'app/shared/metis/reaction.service'; -import { MockReactionService } from '../../../../../helpers/mocks/service/mock-reaction.service'; -import { AccountService } from 'app/core/auth/account.service'; -import { MockAccountService } from '../../../../../helpers/mocks/service/mock-account.service'; -import { AnswerPostReactionsBarComponent } from 'app/shared/metis/posting-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component'; -import { AnswerPost } from 'app/entities/metis/answer-post.model'; -import { EmojiModule } from '@ctrl/ngx-emoji-mart/ngx-emoji'; -import { PickerModule } from '@ctrl/ngx-emoji-mart'; -import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { TranslateService } from '@ngx-translate/core'; -import { MockTranslateService, TranslatePipeMock } from '../../../../../helpers/mocks/service/mock-translate.service'; -import { Router } from '@angular/router'; -import { MockRouter } from '../../../../../helpers/mocks/mock-router'; -import { MockLocalStorageService } from '../../../../../helpers/mocks/service/mock-local-storage.service'; -import { LocalStorageService, SessionStorageService } from 'ngx-webstorage'; -import { ReactingUsersOnPostingPipe } from 'app/shared/pipes/reacting-users-on-posting.pipe'; -import { By } from '@angular/platform-browser'; -import { metisCourse, metisPostInChannel, metisResolvingAnswerPostUser1, metisUser1, post } from '../../../../../helpers/sample/metis-sample-data'; -import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; -import { NotificationService } from 'app/shared/notification/notification.service'; -import { MockNotificationService } from '../../../../../helpers/mocks/service/mock-notification.service'; -import { provideHttpClient } from '@angular/common/http'; -import { ConfirmIconComponent } from 'app/shared/confirm-icon/confirm-icon.component'; -import { getElement } from '../../../../../helpers/utils/general.utils'; -import { DebugElement } from '@angular/core'; -import { UserRole } from 'app/shared/metis/metis.util'; - -describe('AnswerPostReactionsBarComponent', () => { - let component: AnswerPostReactionsBarComponent; - let fixture: ComponentFixture; - let debugElement: DebugElement; - let metisService: MetisService; - let answerPost: AnswerPost; - let reactionToCreate: Reaction; - let reactionToDelete: Reaction; - let metisServiceUserIsAtLeastTutorMock: jest.SpyInstance; - let metisServiceUserIsAtLeastInstructorMock: jest.SpyInstance; - let metisServiceUserPostingAuthorMock: jest.SpyInstance; - let metisServiceUpdateAnswerPostMock: jest.SpyInstance; - - beforeEach(() => { - return TestBed.configureTestingModule({ - imports: [MockModule(OverlayModule), MockModule(EmojiModule), MockModule(PickerModule), MockModule(NgbTooltipModule)], - declarations: [ - AnswerPostReactionsBarComponent, - TranslatePipeMock, - MockPipe(ReactingUsersOnPostingPipe), - MockComponent(FaIconComponent), - MockComponent(EmojiComponent), - MockComponent(ConfirmIconComponent), - ], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - MockProvider(SessionStorageService), - { provide: NotificationService, useClass: MockNotificationService }, - { provide: MetisService, useClass: MetisService }, - { provide: ReactionService, useClass: MockReactionService }, - { provide: AccountService, useClass: MockAccountService }, - { provide: TranslateService, useClass: MockTranslateService }, - { provide: Router, useClass: MockRouter }, - { provide: LocalStorageService, useClass: MockLocalStorageService }, - ], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(AnswerPostReactionsBarComponent); - metisService = TestBed.inject(MetisService); - debugElement = fixture.debugElement; - metisServiceUserIsAtLeastTutorMock = jest.spyOn(metisService, 'metisUserIsAtLeastTutorInCourse'); - metisServiceUserIsAtLeastInstructorMock = jest.spyOn(metisService, 'metisUserIsAtLeastInstructorInCourse'); - metisServiceUserPostingAuthorMock = jest.spyOn(metisService, 'metisUserIsAuthorOfPosting'); - metisServiceUpdateAnswerPostMock = jest.spyOn(metisService, 'updateAnswerPost'); - component = fixture.componentInstance; - answerPost = new AnswerPost(); - answerPost.id = 1; - answerPost.author = metisUser1; - reactionToDelete = new Reaction(); - reactionToDelete.id = 1; - reactionToDelete.emojiId = 'smile'; - reactionToDelete.user = metisUser1; - reactionToDelete.answerPost = answerPost; - answerPost.reactions = [reactionToDelete]; - component.posting = answerPost; - metisService.setCourse(metisCourse); - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - function getEditButton(): DebugElement | null { - return debugElement.query(By.css('button.reaction-button.clickable.px-2.fs-small.edit')); - } - - function getDeleteButton(): DebugElement | null { - return debugElement.query(By.css('.delete')); - } - - function getResolveButton(): DebugElement | null { - return debugElement.query(By.css('#toggleElement')); - } - - it('should invoke metis service method with correctly built reaction to create it', () => { - component.ngOnInit(); - fixture.detectChanges(); - const metisServiceCreateReactionSpy = jest.spyOn(metisService, 'createReaction'); - reactionToCreate = new Reaction(); - reactionToCreate.emojiId = '+1'; - reactionToCreate.answerPost = component.posting; - component.addOrRemoveReaction(reactionToCreate.emojiId); - expect(metisServiceCreateReactionSpy).toHaveBeenCalledWith(reactionToCreate); - expect(component.showReactionSelector).toBeFalse(); - }); - - it('should display edit and delete options to post author', () => { - metisServiceUserPostingAuthorMock.mockReturnValue(true); - fixture.detectChanges(); - expect(getEditButton()).not.toBeNull(); - expect(getDeleteButton()).not.toBeNull(); - }); - - it('should display the delete option to instructor if posting is in course-wide channel from a student', () => { - metisServiceUserIsAtLeastInstructorMock.mockReturnValue(true); - metisServiceUserPostingAuthorMock.mockReturnValue(false); - metisServiceUserIsAtLeastTutorMock.mockReturnValue(true); - component.posting = { ...metisResolvingAnswerPostUser1, post: { ...metisPostInChannel } }; - component.posting.authorRole = UserRole.USER; - component.ngOnInit(); - fixture.detectChanges(); - expect(getDeleteButton()).not.toBeNull(); - }); - - it('should not display the edit option to user (even instructor) if s/he is not the author of posting', () => { - metisServiceUserIsAtLeastInstructorMock.mockReturnValue(true); - metisServiceUserPostingAuthorMock.mockReturnValue(false); - - component.ngOnInit(); - fixture.detectChanges(); - - expect(getEditButton()).toBeNull(); - }); - - it('should display the edit option to user if s/he is the author of posting', () => { - metisServiceUserIsAtLeastInstructorMock.mockReturnValue(false); - metisServiceUserPostingAuthorMock.mockReturnValue(true); - - component.ngOnInit(); - fixture.detectChanges(); - - expect(getEditButton()).not.toBeNull(); - }); - - it('should display edit and delete options to tutor if posting is in course-wide channel from a student', () => { - metisServiceUserIsAtLeastInstructorMock.mockReturnValue(false); - metisServiceUserIsAtLeastTutorMock.mockReturnValue(true); - metisServiceUserPostingAuthorMock.mockReturnValue(false); - component.posting = { ...metisResolvingAnswerPostUser1, post: { ...metisPostInChannel } }; - component.posting.authorRole = UserRole.USER; - component.ngOnInit(); - fixture.detectChanges(); - expect(getEditButton()).toBeNull(); - expect(getDeleteButton()).not.toBeNull(); - }); - - it('should not display edit and delete options to users that are neither author or tutor', () => { - metisServiceUserIsAtLeastTutorMock.mockReturnValue(false); - metisServiceUserPostingAuthorMock.mockReturnValue(false); - metisServiceUserIsAtLeastInstructorMock.mockReturnValue(false); - fixture.detectChanges(); - expect(getEditButton()).toBeNull(); - expect(getDeleteButton()).toBeNull(); - }); - - it('should emit event to create embedded view when edit icon is clicked', () => { - component.posting = metisResolvingAnswerPostUser1; - const openPostingCreateEditModalEmitSpy = jest.spyOn(component.openPostingCreateEditModal, 'emit'); - metisServiceUserPostingAuthorMock.mockReturnValue(true); - fixture.detectChanges(); - getElement(debugElement, '.edit').click(); - expect(openPostingCreateEditModalEmitSpy).toHaveBeenCalledOnce(); - }); - - it('should invoke metis service method with own reaction to delete it', () => { - component.posting!.author!.id = 99; - component.ngOnInit(); - fixture.detectChanges(); - const metisServiceCreateReactionSpy = jest.spyOn(metisService, 'deleteReaction'); - component.addOrRemoveReaction(reactionToDelete.emojiId!); - expect(metisServiceCreateReactionSpy).toHaveBeenCalledWith(reactionToDelete); - }); - - it('should invoke metis service method with own reaction to remove it', () => { - component.ngOnInit(); - const addOrRemoveSpy = jest.spyOn(component, 'addOrRemoveReaction'); - component.updateReaction(reactionToDelete.emojiId!); - expect(addOrRemoveSpy).toHaveBeenCalledWith(reactionToDelete.emojiId!); - }); - - it('answer now button should be invisible if answer is not the last one', () => { - component.posting = post; - component.isLastAnswer = false; - fixture.detectChanges(); - const answerNowButton = fixture.debugElement.query(By.css('.reply-btn')); - expect(answerNowButton).toBeNull(); - }); - - it('should invoke metis service when toggle resolve is clicked', () => { - metisServiceUserPostingAuthorMock.mockReturnValue(true); - fixture.detectChanges(); - expect(getResolveButton()).not.toBeNull(); - const previousState = component.posting.resolvesPost; - component.toggleResolvesPost(); - expect(component.posting.resolvesPost).toEqual(!previousState); - expect(metisServiceUpdateAnswerPostMock).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/test/javascript/spec/component/shared/metis/postings-reactions-bar/post-reactions-bar/post-reactions-bar.component.spec.ts b/src/test/javascript/spec/component/shared/metis/postings-reactions-bar/posting-reactions-bar.component.spec.ts similarity index 54% rename from src/test/javascript/spec/component/shared/metis/postings-reactions-bar/post-reactions-bar/post-reactions-bar.component.spec.ts rename to src/test/javascript/spec/component/shared/metis/postings-reactions-bar/posting-reactions-bar.component.spec.ts index bedc64c1611d..830fa68b6fda 100644 --- a/src/test/javascript/spec/component/shared/metis/postings-reactions-bar/post-reactions-bar/post-reactions-bar.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/postings-reactions-bar/posting-reactions-bar.component.spec.ts @@ -1,42 +1,53 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MetisService } from 'app/shared/metis/metis.service'; -import { DebugElement } from '@angular/core'; +import { DebugElement, input, runInInjectionContext } from '@angular/core'; import { Post } from 'app/entities/metis/post.model'; import { MockComponent, MockDirective, MockPipe, MockProvider } from 'ng-mocks'; -import { getElement } from '../../../../../helpers/utils/general.utils'; -import { PostReactionsBarComponent } from 'app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component'; +import { getElement } from '../../../../helpers/utils/general.utils'; import { Reaction } from 'app/entities/metis/reaction.model'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ReactionService } from 'app/shared/metis/reaction.service'; -import { MockReactionService } from '../../../../../helpers/mocks/service/mock-reaction.service'; +import { MockReactionService } from '../../../../helpers/mocks/service/mock-reaction.service'; import { AccountService } from 'app/core/auth/account.service'; -import { MockAccountService } from '../../../../../helpers/mocks/service/mock-account.service'; +import { MockAccountService } from '../../../../helpers/mocks/service/mock-account.service'; import { EmojiData } from '@ctrl/ngx-emoji-mart/ngx-emoji'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { DisplayPriority, UserRole } from 'app/shared/metis/metis.util'; -import { MockTranslateService, TranslatePipeMock } from '../../../../../helpers/mocks/service/mock-translate.service'; +import { MockTranslateService, TranslatePipeMock } from '../../../../helpers/mocks/service/mock-translate.service'; import { TranslateService } from '@ngx-translate/core'; import { Router } from '@angular/router'; -import { MockRouter } from '../../../../../helpers/mocks/mock-router'; -import { MockLocalStorageService } from '../../../../../helpers/mocks/service/mock-local-storage.service'; +import { MockRouter } from '../../../../helpers/mocks/mock-router'; +import { MockLocalStorageService } from '../../../../helpers/mocks/service/mock-local-storage.service'; import { LocalStorageService, SessionStorageService } from 'ngx-webstorage'; import { By } from '@angular/platform-browser'; import { PLACEHOLDER_USER_REACTED, ReactingUsersOnPostingPipe } from 'app/shared/pipes/reacting-users-on-posting.pipe'; -import { metisAnnouncement, metisCourse, metisPostExerciseUser1, metisPostInChannel, metisUser1, sortedAnswerArray } from '../../../../../helpers/sample/metis-sample-data'; +import { + metisAnnouncement, + metisCourse, + metisPostExerciseUser1, + metisPostInChannel, + metisResolvingAnswerPostUser1, + metisUser1, + sortedAnswerArray, + unApprovedAnswerPost1, +} from '../../../../helpers/sample/metis-sample-data'; import { EmojiComponent } from 'app/shared/metis/emoji/emoji.component'; import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import { NotificationService } from 'app/shared/notification/notification.service'; -import { MockNotificationService } from '../../../../../helpers/mocks/service/mock-notification.service'; +import { MockNotificationService } from '../../../../helpers/mocks/service/mock-notification.service'; import { ConversationDTO, ConversationType } from 'app/entities/metis/conversation/conversation.model'; import { ChannelDTO } from 'app/entities/metis/conversation/channel.model'; import { User } from 'app/core/user/user.model'; import { provideHttpClient } from '@angular/common/http'; import { PostCreateEditModalComponent } from 'app/shared/metis/posting-create-edit-modal/post-create-edit-modal/post-create-edit-modal.component'; import { ConfirmIconComponent } from 'app/shared/confirm-icon/confirm-icon.component'; +import { PostingReactionsBarComponent } from 'app/shared/metis/posting-reactions-bar/posting-reactions-bar.component'; +import { Posting } from 'app/entities/metis/posting.model'; +import { AnswerPost } from 'app/entities/metis/answer-post.model'; -describe('PostReactionsBarComponent', () => { - let component: PostReactionsBarComponent; - let fixture: ComponentFixture; +describe('PostingReactionsBarComponent', () => { + let component: PostingReactionsBarComponent; + let fixture: ComponentFixture>; let debugElement: DebugElement; let metisService: MetisService; let accountService: AccountService; @@ -44,6 +55,7 @@ describe('PostReactionsBarComponent', () => { let metisServiceUserIsAtLeastTutorStub: jest.SpyInstance; let metisServiceUserIsAtLeastInstructorStub: jest.SpyInstance; let metisServiceUserIsAuthorOfPostingStub: jest.SpyInstance; + let metisServiceUpdateAnswerPostMock: jest.SpyInstance; let post: Post; let reactionToCreate: Reaction; let reactionToDelete: Reaction; @@ -57,7 +69,6 @@ describe('PostReactionsBarComponent', () => { return TestBed.configureTestingModule({ imports: [MockDirective(NgbTooltip)], declarations: [ - PostReactionsBarComponent, TranslatePipeMock, MockPipe(ReactingUsersOnPostingPipe), MockComponent(FaIconComponent), @@ -80,7 +91,7 @@ describe('PostReactionsBarComponent', () => { }) .compileComponents() .then(() => { - fixture = TestBed.createComponent(PostReactionsBarComponent); + fixture = TestBed.createComponent(PostingReactionsBarComponent); metisService = TestBed.inject(MetisService); accountService = TestBed.inject(AccountService); debugElement = fixture.debugElement; @@ -89,18 +100,21 @@ describe('PostReactionsBarComponent', () => { metisServiceUserIsAtLeastTutorStub = jest.spyOn(metisService, 'metisUserIsAtLeastTutorInCourse'); metisServiceUserIsAtLeastInstructorStub = jest.spyOn(metisService, 'metisUserIsAtLeastInstructorInCourse'); metisServiceUserIsAuthorOfPostingStub = jest.spyOn(metisService, 'metisUserIsAuthorOfPosting'); + metisServiceUpdateAnswerPostMock = jest.spyOn(metisService, 'updateAnswerPost'); post = new Post(); post.id = 1; post.author = metisUser1; post.displayPriority = DisplayPriority.NONE; - component.sortedAnswerPosts = sortedAnswerArray; + runInInjectionContext(fixture.debugElement.injector, () => { + component.sortedAnswerPosts = input(sortedAnswerArray); + component.posting = input(post); + }); reactionToDelete = new Reaction(); reactionToDelete.id = 1; reactionToDelete.emojiId = 'smile'; reactionToDelete.user = metisUser1; reactionToDelete.post = post; post.reactions = [reactionToDelete]; - component.posting = post; metisService.setCourse(metisCourse); }); }); @@ -117,11 +131,15 @@ describe('PostReactionsBarComponent', () => { return debugElement.query(By.css('jhi-confirm-icon')); } + function getResolveButton(): DebugElement | null { + return debugElement.query(By.css('#toggleElement')); + } + it('should initialize user authority and reactions correctly', () => { metisCourse.isAtLeastTutor = false; metisService.setCourse(metisCourse); component.ngOnInit(); - expect(component.currentUserIsAtLeastTutor).toBeFalse(); + expect(component.isAtLeastTutorInCourse).toBeFalse(); fixture.detectChanges(); const reaction = getElement(debugElement, 'ngx-emoji'); expect(reaction).toBeDefined(); @@ -135,11 +153,12 @@ describe('PostReactionsBarComponent', () => { }); it('should display edit and delete options to the author when not in read-only or preview mode', () => { - component.readOnlyMode = false; - component.previewMode = false; + runInInjectionContext(fixture.debugElement.injector, () => { + component.isReadOnlyMode = input(false); + component.previewMode = input(false); + component.posting = input({ id: 1, title: 'Test Post' } as Post); + }); jest.spyOn(metisService, 'metisUserIsAuthorOfPosting').mockReturnValue(true); - component.posting = { id: 1, title: 'Test Post' } as Post; - component.ngOnInit(); fixture.detectChanges(); @@ -148,9 +167,12 @@ describe('PostReactionsBarComponent', () => { }); it('should display the delete option to user with channel moderation rights when not the author', () => { - component.readOnlyMode = false; - component.previewMode = false; - component.isEmojiCount = false; + runInInjectionContext(fixture.debugElement.injector, () => { + component.isReadOnlyMode = input(false); + component.previewMode = input(false); + component.isEmojiCount = input(false); + component.posting = input({ id: 1, title: 'Test Post' } as Post); + }); const channelConversation = { type: ConversationType.CHANNEL, @@ -168,9 +190,11 @@ describe('PostReactionsBarComponent', () => { }); it('should not display the edit option to user (even instructor) if s/he is not the author of posting', () => { - component.readOnlyMode = false; - component.previewMode = false; - component.isEmojiCount = false; + runInInjectionContext(fixture.debugElement.injector, () => { + component.isReadOnlyMode = input(false); + component.previewMode = input(false); + component.isEmojiCount = input(false); + }); const channelConversation = { type: ConversationType.CHANNEL, @@ -188,17 +212,19 @@ describe('PostReactionsBarComponent', () => { }); it('should display the edit option to user if s/he is the author of posting', () => { - component.readOnlyMode = false; - component.previewMode = false; - component.isEmojiCount = false; + runInInjectionContext(fixture.debugElement.injector, () => { + component.isReadOnlyMode = input(false); + component.previewMode = input(false); + component.isEmojiCount = input(false); + }); const channelConversation = { type: ConversationType.CHANNEL, hasChannelModerationRights: true, } as ChannelDTO; - jest.spyOn(metisService, 'metisUserIsAuthorOfPosting').mockReturnValue(true); - jest.spyOn(metisService, 'metisUserIsAtLeastInstructorInCourse').mockReturnValue(false); + metisServiceUserIsAuthorOfPostingStub.mockReturnValue(true); + metisServiceUserIsAtLeastInstructorStub.mockReturnValue(false); jest.spyOn(metisService, 'getCurrentConversation').mockReturnValue(channelConversation); component.ngOnInit(); @@ -207,12 +233,49 @@ describe('PostReactionsBarComponent', () => { expect(getEditButton()).not.toBeNull(); }); + it('should display the delete option to tutor if posting is in course-wide channel from a student', () => { + metisServiceUserIsAtLeastTutorStub.mockReturnValue(true); + metisServiceUserIsAuthorOfPostingStub.mockReturnValue(false); + const channelConversation = { + type: ConversationType.CHANNEL, + isCourseWide: true, + hasChannelModerationRights: true, + } as ChannelDTO; + jest.spyOn(metisService, 'getCurrentConversation').mockReturnValue(channelConversation); + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input({ ...metisResolvingAnswerPostUser1, post: { ...metisPostInChannel }, authorRole: UserRole.USER } as AnswerPost); + component.isEmojiCount = input(false); + }); + component.ngOnInit(); + fixture.detectChanges(); + expect(getDeleteButton()).not.toBeNull(); + }); + + it('should display edit and delete options to post author', () => { + metisServiceUserIsAuthorOfPostingStub.mockReturnValue(true); + fixture.detectChanges(); + expect(getEditButton()).not.toBeNull(); + expect(getDeleteButton()).not.toBeNull(); + }); + + it('should not display the edit option to user (even instructor) if s/he is not the author of posting', () => { + metisServiceUserIsAtLeastInstructorStub.mockReturnValue(true); + metisServiceUserIsAuthorOfPostingStub.mockReturnValue(false); + + component.ngOnInit(); + fixture.detectChanges(); + + expect(getEditButton()).toBeNull(); + }); + it('should not display edit and delete options when user is not the author and lacks permissions', () => { - component.readOnlyMode = false; - component.previewMode = false; + runInInjectionContext(fixture.debugElement.injector, () => { + component.isReadOnlyMode = input(false); + component.previewMode = input(false); + component.posting = input({ conversation: { isCourseWide: false } } as Post); + }); jest.spyOn(metisService, 'metisUserIsAuthorOfPosting').mockReturnValue(false); jest.spyOn(metisService, 'metisUserIsAtLeastInstructorInCourse').mockReturnValue(false); - component.posting = { conversation: { isCourseWide: false } } as Post; component.ngOnInit(); fixture.detectChanges(); @@ -225,8 +288,15 @@ describe('PostReactionsBarComponent', () => { metisServiceUserIsAtLeastInstructorStub.mockReturnValue(false); metisServiceUserIsAtLeastTutorStub.mockReturnValue(true); metisServiceUserIsAuthorOfPostingStub.mockReturnValue(false); - component.posting = { ...metisPostInChannel }; - component.posting.authorRole = UserRole.USER; + const channelConversation = { + type: ConversationType.CHANNEL, + isCourseWide: true, + hasChannelModerationRights: true, + } as ChannelDTO; + jest.spyOn(metisService, 'getCurrentConversation').mockReturnValue(channelConversation); + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input({ ...metisPostInChannel, authorRole: UserRole.USER }); + }); component.ngOnInit(); fixture.detectChanges(); expect(getEditButton()).toBeNull(); @@ -236,7 +306,9 @@ describe('PostReactionsBarComponent', () => { it('should not display edit and delete options to tutor if posting is announcement', () => { metisServiceUserIsAtLeastInstructorStub.mockReturnValue(false); metisServiceUserIsAuthorOfPostingStub.mockReturnValue(false); - component.posting = metisAnnouncement; + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(metisAnnouncement); + }); component.ngOnInit(); fixture.detectChanges(); expect(getEditButton()).toBeNull(); @@ -246,7 +318,9 @@ describe('PostReactionsBarComponent', () => { it('should display edit and delete options to instructor if his posting is announcement', () => { metisServiceUserIsAtLeastInstructorStub.mockReturnValue(true); metisServiceUserIsAuthorOfPostingStub.mockReturnValue(true); - component.posting = metisAnnouncement; + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(metisAnnouncement); + }); component.ngOnInit(); fixture.detectChanges(); expect(getEditButton()).not.toBeNull(); @@ -257,8 +331,15 @@ describe('PostReactionsBarComponent', () => { metisServiceUserIsAtLeastInstructorStub.mockReturnValue(true); metisServiceUserIsAtLeastTutorStub.mockReturnValue(true); metisServiceUserIsAuthorOfPostingStub.mockReturnValue(false); - component.posting = { ...metisPostInChannel }; - component.posting.authorRole = UserRole.USER; + const channelConversation = { + type: ConversationType.CHANNEL, + isCourseWide: true, + hasChannelModerationRights: true, + } as ChannelDTO; + jest.spyOn(metisService, 'getCurrentConversation').mockReturnValue(channelConversation); + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input({ ...metisPostInChannel, authorRole: UserRole.USER }); + }); component.ngOnInit(); fixture.detectChanges(); @@ -271,16 +352,17 @@ describe('PostReactionsBarComponent', () => { { type: ConversationType.GROUP_CHAT, creator: { id: 99 } }, { type: ConversationType.ONE_TO_ONE }, ])('should initialize user authority and reactions correctly with same user', (dto: ConversationDTO) => { - component.posting!.author!.id = 99; jest.spyOn(metisService, 'getCurrentConversation').mockReturnValue(dto); jest.spyOn(accountService, 'userIdentity', 'get').mockReturnValue({ id: 99 } as User); reactionToDelete.user = { id: 99 } as User; post.reactions = [reactionToDelete]; - component.posting = post; - + runInInjectionContext(fixture.debugElement.injector, () => { + post.author!!.id = 99; + component.posting = input(post); + component.isEmojiCount = input(true); + }); component.ngOnInit(); - component.isEmojiCount = true; fixture.detectChanges(); expect(component.reactionMetaDataMap).toEqual({ smile: { @@ -308,14 +390,17 @@ describe('PostReactionsBarComponent', () => { const metisServiceCreateReactionMock = jest.spyOn(metisService, 'createReaction'); reactionToCreate = new Reaction(); reactionToCreate.emojiId = '+1'; - reactionToCreate.post = component.posting; + reactionToCreate.post = component.posting(); component.addOrRemoveReaction(reactionToCreate.emojiId); expect(metisServiceCreateReactionMock).toHaveBeenCalledWith(reactionToCreate); expect(component.showReactionSelector).toBeFalsy(); }); it('should invoke metis service method with own reaction to delete it', () => { - component.posting!.author!.id = 99; + post.author!.id = 99; + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(post); + }); component.ngOnInit(); fixture.detectChanges(); const metisServiceDeleteReactionMock = jest.spyOn(metisService, 'deleteReaction'); @@ -324,14 +409,21 @@ describe('PostReactionsBarComponent', () => { expect(component.showReactionSelector).toBeFalsy(); }); + it('should invoke metis service method with own reaction to remove it', () => { + component.ngOnInit(); + const addOrRemoveSpy = jest.spyOn(component, 'addOrRemoveReaction'); + component.updateReaction(reactionToDelete.emojiId!); + expect(addOrRemoveSpy).toHaveBeenCalledWith(reactionToDelete.emojiId!); + }); + it('should invoke metis service method when pin icon is toggled', () => { jest.spyOn(metisService, 'getCurrentConversation').mockReturnValue({ type: ConversationType.CHANNEL, hasChannelModerationRights: true } as ChannelDTO); component.ngOnInit(); fixture.detectChanges(); const pinEmoji = getElement(debugElement, '.pin'); pinEmoji.click(); - component.posting.displayPriority = DisplayPriority.PINNED; - expect(metisServiceUpdateDisplayPriorityMock).toHaveBeenCalledWith(component.posting.id!, DisplayPriority.PINNED); + (component.posting() as Post)!.displayPriority = DisplayPriority.PINNED; + expect(metisServiceUpdateDisplayPriorityMock).toHaveBeenCalledWith(component.posting()!.id!, DisplayPriority.PINNED); component.ngOnChanges(); // set correct tooltips for tutor and post that is pinned and not archived expect(component.pinTooltip).toBe('artemisApp.metis.removePinPostTooltip'); @@ -340,7 +432,10 @@ describe('PostReactionsBarComponent', () => { it('should show non-clickable pin emoji with correct tooltip for student when post is pinned', () => { metisCourse.isAtLeastTutor = false; metisService.setCourse(metisCourse); - component.posting.displayPriority = DisplayPriority.PINNED; + post.displayPriority = DisplayPriority.PINNED; + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(post); + }); component.ngOnInit(); fixture.detectChanges(); const pinEmoji = getElement(debugElement, '.pin.reaction-button--not-hoverable'); @@ -352,25 +447,31 @@ describe('PostReactionsBarComponent', () => { }); it('should display button to show single answer', () => { - component.posting = post; - component.sortedAnswerPosts = [metisPostExerciseUser1]; - component.showAnswers = false; + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(post); + component.sortedAnswerPosts = input([metisPostExerciseUser1]); + component.showAnswers = input(false); + }); fixture.detectChanges(); const answerNowButton = fixture.debugElement.query(By.css('.expand-answers-btn')).nativeElement; expect(answerNowButton.innerHTML).toContain('showSingleAnswer'); }); it('should display button to show multiple answers', () => { - component.posting = post; - component.showAnswers = false; + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(post); + component.showAnswers = input(false); + }); fixture.detectChanges(); const answerNowButton = fixture.debugElement.query(By.css('.expand-answers-btn')).nativeElement; expect(answerNowButton.innerHTML).toContain('showMultipleAnswers'); }); it('should display button to collapse answers', () => { - component.posting = post; - component.showAnswers = true; + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(post); + component.showAnswers = input(true); + }); fixture.detectChanges(); const answerNowButton = fixture.debugElement.query(By.css('.collapse-answers-btn')).nativeElement; expect(answerNowButton.innerHTML).toContain('collapseAnswers'); @@ -395,4 +496,113 @@ describe('PostReactionsBarComponent', () => { expect(showAnswersChangeSpy).toHaveBeenCalledWith(false); expect(closePostingCreateEditModalSpy).toHaveBeenCalled(); }); + + it('should not display edit and delete options to users that are neither author or tutor', () => { + metisServiceUserIsAtLeastTutorStub.mockReturnValue(false); + metisServiceUserIsAuthorOfPostingStub.mockReturnValue(false); + metisServiceUserIsAtLeastInstructorStub.mockReturnValue(false); + fixture.detectChanges(); + expect(getEditButton()).toBeNull(); + expect(getDeleteButton()).toBeNull(); + }); + + it('should emit event to create embedded view when edit icon is clicked', () => { + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(metisResolvingAnswerPostUser1); + }); + const openPostingCreateEditModalEmitSpy = jest.spyOn(component.openPostingCreateEditModal, 'emit'); + metisServiceUserIsAuthorOfPostingStub.mockReturnValue(true); + fixture.detectChanges(); + getElement(debugElement, '.edit').click(); + expect(openPostingCreateEditModalEmitSpy).toHaveBeenCalledOnce(); + }); + + it('answer now button should be invisible if answer is not the last one', () => { + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(post); + component.isLastAnswer = input(false); + }); + fixture.detectChanges(); + const answerNowButton = fixture.debugElement.query(By.css('.reply-btn')); + expect(answerNowButton).toBeNull(); + }); + + it('should invoke metis service when toggle resolve is clicked', () => { + runInInjectionContext(fixture.debugElement.injector, () => { + unApprovedAnswerPost1.post = post; + component.posting = input(unApprovedAnswerPost1); + component.isEmojiCount = input(false); + + metisServiceUserIsAtLeastTutorStub.mockReturnValue(true); + fixture.detectChanges(); + expect(getResolveButton()).not.toBeNull(); + const previousState = (component.posting() as AnswerPost).resolvesPost; + component.toggleResolvesPost(); + expect(component.getResolvesPost()).toEqual(!previousState); + expect(metisServiceUpdateAnswerPostMock).toHaveBeenCalledOnce(); + }); + }); + + it('should create a Reaction with answerPost when posting type is answerPost', () => { + const answerPost = new AnswerPost(); + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(answerPost); + }); + + const reaction = component.buildReaction('thumbsup'); + expect(reaction.answerPost).toBe(answerPost); + expect(reaction.post).toBeUndefined(); + }); + + it('should create a Reaction with post when posting type is post', () => { + const post = new Post(); + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(post); + }); + + const reaction = component.buildReaction('thumbsup'); + expect(reaction.post).toBe(post); + expect(reaction.answerPost).toBeUndefined(); + }); + + it('should not toggle pin when user has no permission', () => { + const channelConversation = { + type: ConversationType.CHANNEL, + hasChannelModerationRights: false, + } as ChannelDTO; + component.setCanPin(channelConversation); + fixture.detectChanges(); + component.togglePin(); + expect(metisServiceUpdateDisplayPriorityMock).not.toHaveBeenCalled(); + }); + + it('should emit isDeleteEvent when deletePosting is called', () => { + const spy = jest.spyOn(component.isDeleteEvent, 'emit'); + component.deletePosting(); + expect(spy).toHaveBeenCalledWith(true); + }); + + it('should toggle pin and update displayPriority when user has permission', () => { + jest.spyOn(metisService, 'metisUserIsAtLeastTutorInCourse').mockReturnValue(true); + + const moderatorChannel = { + type: ConversationType.CHANNEL, + hasChannelModerationRights: true, + } as ChannelDTO; + jest.spyOn(metisService, 'getCurrentConversation').mockReturnValue(moderatorChannel); + + runInInjectionContext(fixture.debugElement.injector, () => { + component.posting = input(post); + }); + component.ngOnInit(); + expect(component.displayPriority).toBe(DisplayPriority.NONE); + + component.togglePin(); + expect(metisServiceUpdateDisplayPriorityMock).toHaveBeenCalledWith(post.id!, DisplayPriority.PINNED); + expect(component.displayPriority).toBe(DisplayPriority.PINNED); + + component.togglePin(); + expect(metisServiceUpdateDisplayPriorityMock).toHaveBeenCalledWith(post.id!, DisplayPriority.NONE); + expect(component.displayPriority).toBe(DisplayPriority.NONE); + }); });