diff --git a/src/main/webapp/app/entities/metis/answer-post.model.ts b/src/main/webapp/app/entities/metis/answer-post.model.ts index 000b7d59837b..c76399075551 100644 --- a/src/main/webapp/app/entities/metis/answer-post.model.ts +++ b/src/main/webapp/app/entities/metis/answer-post.model.ts @@ -4,6 +4,7 @@ import { Posting } from 'app/entities/metis/posting.model'; export class AnswerPost extends Posting { public resolvesPost?: boolean; public post?: Post; + public isConsecutive?: boolean = false; constructor() { super(); diff --git a/src/main/webapp/app/entities/metis/post.model.ts b/src/main/webapp/app/entities/metis/post.model.ts index 593d9535dab2..60adfe3c64c0 100644 --- a/src/main/webapp/app/entities/metis/post.model.ts +++ b/src/main/webapp/app/entities/metis/post.model.ts @@ -13,6 +13,7 @@ export class Post extends Posting { public conversation?: Conversation; public displayPriority?: DisplayPriority; public resolved?: boolean; + public isConsecutive?: boolean = false; constructor() { super(); diff --git a/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.html b/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.html index 1aba4981a8fe..eb8f742fe9c0 100644 --- a/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.html +++ b/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.html @@ -75,19 +75,24 @@ > - @for (post of posts; track postsTrackByFn($index, post)) { -
- + @for (group of groupedPosts; track postsTrackByFn($index, group)) { +
+ @for (post of group.posts; track postsTrackByFn($index, post)) { +
+ +
+ }
} diff --git a/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.scss b/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.scss index 3c1445325f8b..98c55bf617a7 100644 --- a/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.scss +++ b/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.scss @@ -74,3 +74,25 @@ display: none; } } + +.message-group { + display: flex; + flex-direction: column; + margin-bottom: 10px; +} + +.grouped-posts { + margin-left: 30px; + padding-left: 10px; +} + +.grouped-posts, +.grouped-post { + margin-top: 0; + margin-bottom: 0; + padding: 0; +} + +jhi-posting-thread { + margin-bottom: 5px; +} diff --git a/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.ts b/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.ts index 5395a5a9eee3..18673825bd65 100644 --- a/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.ts +++ b/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.ts @@ -30,6 +30,13 @@ import { canCreateNewMessageInConversation } from 'app/shared/metis/conversation import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; import { LayoutService } from 'app/shared/breakpoints/layout.service'; import { CustomBreakpointNames } from 'app/shared/breakpoints/breakpoints.service'; +import dayjs from 'dayjs/esm'; +import { User } from 'app/core/user/user.model'; + +interface PostGroup { + author: User | undefined; + posts: Post[]; +} @Component({ selector: 'jhi-conversation-messages', @@ -72,6 +79,7 @@ export class ConversationMessagesComponent implements OnInit, AfterViewInit, OnD newPost?: Post; posts: Post[] = []; + groupedPosts: PostGroup[] = []; totalNumberOfPosts = 0; page = 1; public isFetchingPosts = true; @@ -178,11 +186,66 @@ export class ConversationMessagesComponent implements OnInit, AfterViewInit, OnD }; } + private groupPosts(): void { + if (!this.posts || this.posts.length === 0) { + this.groupedPosts = []; + return; + } + + const sortedPosts = this.posts.sort((a, b) => { + const aDate = (a as any).creationDateDayjs; + const bDate = (b as any).creationDateDayjs; + return aDate?.valueOf() - bDate?.valueOf(); + }); + + const groups: PostGroup[] = []; + let currentGroup: PostGroup = { + author: sortedPosts[0].author, + posts: [{ ...sortedPosts[0], isConsecutive: false }], + }; + + for (let i = 1; i < sortedPosts.length; i++) { + const currentPost = sortedPosts[i]; + const lastPostInGroup = currentGroup.posts[currentGroup.posts.length - 1]; + + const currentDate = (currentPost as any).creationDateDayjs; + const lastDate = (lastPostInGroup as any).creationDateDayjs; + + let timeDiff = Number.MAX_SAFE_INTEGER; + if (currentDate && lastDate) { + timeDiff = currentDate.diff(lastDate, 'minute'); + } + + if (currentPost.author?.id === currentGroup.author?.id && timeDiff < 5 && timeDiff >= 0) { + currentGroup.posts.push({ ...currentPost, isConsecutive: true }); // consecutive post + } else { + groups.push(currentGroup); + currentGroup = { + author: currentPost.author, + posts: [{ ...currentPost, isConsecutive: false }], + }; + } + } + + groups.push(currentGroup); + this.groupedPosts = groups; + this.cdr.detectChanges(); + } + setPosts(posts: Post[]): void { if (this.content) { this.previousScrollDistanceFromTop = this.content.nativeElement.scrollHeight - this.content.nativeElement.scrollTop; } - this.posts = posts.slice().reverse(); + + this.posts = posts + .slice() + .reverse() + .map((post) => { + (post as any).creationDateDayjs = post.creationDate ? dayjs(post.creationDate) : undefined; + return post; + }); + + this.groupPosts(); } fetchNextPage() { diff --git a/src/main/webapp/app/overview/course-conversations/layout/conversation-thread-sidebar/conversation-thread-sidebar.component.html b/src/main/webapp/app/overview/course-conversations/layout/conversation-thread-sidebar/conversation-thread-sidebar.component.html index defd5dc8b082..64ae4ee9d908 100644 --- a/src/main/webapp/app/overview/course-conversations/layout/conversation-thread-sidebar/conversation-thread-sidebar.component.html +++ b/src/main/webapp/app/overview/course-conversations/layout/conversation-thread-sidebar/conversation-thread-sidebar.component.html @@ -22,7 +22,7 @@
+
@if (post !== undefined) {
diff --git a/src/main/webapp/app/shared/confirm-icon/confirm-icon.component.html b/src/main/webapp/app/shared/confirm-icon/confirm-icon.component.html index 90d044c33079..c0adaa4ae3e6 100644 --- a/src/main/webapp/app/shared/confirm-icon/confirm-icon.component.html +++ b/src/main/webapp/app/shared/confirm-icon/confirm-icon.component.html @@ -1,5 +1,5 @@ @if (!showConfirm) { - + } @if (showConfirm) { 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 33641bc8ad2b..073e4e907593 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 @@ -1,28 +1,42 @@
- + @if (!isConsecutive()) { + + } @if (!createAnswerPostModal.isInputOpen) { -
- +
+
+ +
+ +
+
}
@@ -30,14 +44,47 @@
@if (!isDeleted) {
-
}
- + + + + + +
+ + + + diff --git a/src/main/webapp/app/shared/metis/answer-post/answer-post.component.scss b/src/main/webapp/app/shared/metis/answer-post/answer-post.component.scss index 2289b1aa893c..f744b2dafdd3 100644 --- a/src/main/webapp/app/shared/metis/answer-post/answer-post.component.scss +++ b/src/main/webapp/app/shared/metis/answer-post/answer-post.component.scss @@ -18,3 +18,57 @@ padding-left: 0.5rem; } } + +.hover-actions { + position: absolute; + top: -1.8rem; + right: 3%; + display: flex; + gap: 10px; + visibility: hidden; + transition: + opacity 0.2s ease-in-out, + visibility 0.2s ease-in-out; + background: var(--metis-selection-option-background); + padding: 5px; + border-radius: 5px; + border: 0.01rem solid var(--metis-gray); +} + +.message-container { + position: relative; + border-radius: 5px; + transition: background-color 0.3s ease; +} + +.message-content { + padding-left: 0.3rem; + + &.force-hover { + background: var(--metis-selection-option-hover-background); + + .hover-actions { + opacity: 1; + visibility: visible; + } + } +} + +.message-content:hover { + background: var(--metis-selection-option-hover-background); +} + +.message-container:hover .hover-actions { + opacity: 1; + visibility: visible; +} + +.clickable { + cursor: pointer; +} + +.item-icon { + width: 20px; + height: 20px; + margin-right: 0.2rem; +} diff --git a/src/main/webapp/app/shared/metis/answer-post/answer-post.component.ts b/src/main/webapp/app/shared/metis/answer-post/answer-post.component.ts index 177791de4758..3b3c5e7ffdf1 100644 --- a/src/main/webapp/app/shared/metis/answer-post/answer-post.component.ts +++ b/src/main/webapp/app/shared/metis/answer-post/answer-post.component.ts @@ -1,8 +1,26 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, ViewChild, ViewContainerRef } from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + HostListener, + Inject, + Input, + Output, + Renderer2, + ViewChild, + ViewContainerRef, + input, +} from '@angular/core'; import { AnswerPost } from 'app/entities/metis/answer-post.model'; import { PostingDirective } from 'app/shared/metis/posting.directive'; import dayjs from 'dayjs/esm'; import { animate, style, transition, trigger } from '@angular/animations'; +import { Posting } from 'app/entities/metis/posting.model'; +import { Reaction } from 'app/entities/metis/reaction.model'; +import { faPencilAlt, faSmile, faThumbtack, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { DOCUMENT } from '@angular/common'; +import { AnswerPostReactionsBarComponent } from 'app/shared/metis/posting-reactions-bar/answer-post-reactions-bar/answer-post-reactions-bar.component'; @Component({ selector: 'jhi-answer-post', @@ -28,4 +46,86 @@ export class AnswerPostComponent extends PostingDirective { isReadOnlyMode = false; // ng-container to render answerPostCreateEditModalComponent @ViewChild('createEditAnswerPostContainer', { read: ViewContainerRef }) containerRef: ViewContainerRef; + isConsecutive = input(false); + readonly faPencilAlt = faPencilAlt; + readonly faSmile = faSmile; + readonly faTrash = faTrash; + readonly faThumbtack = faThumbtack; + static activeDropdownPost: AnswerPostComponent | null = null; + mayEditOrDelete: boolean = false; + @ViewChild(AnswerPostReactionsBarComponent) private reactionsBarComponent!: AnswerPostReactionsBarComponent; + + constructor( + public changeDetector: ChangeDetectorRef, + public renderer: Renderer2, + @Inject(DOCUMENT) private document: Document, + ) { + super(); + } + + get reactionsBar() { + return this.reactionsBarComponent; + } + + onPostingUpdated(updatedPosting: Posting) { + this.posting = updatedPosting; + } + + onReactionsUpdated(updatedReactions: Reaction[]) { + this.posting = { ...this.posting, reactions: updatedReactions }; + } + + @HostListener('document:click', ['$event']) + onClickOutside() { + this.showDropdown = false; + this.enableBodyScroll(); + } + + private disableBodyScroll() { + const mainContainer = this.document.querySelector('.thread-answer-post'); + if (mainContainer) { + this.renderer.setStyle(mainContainer, 'overflow', 'hidden'); + } + } + + enableBodyScroll() { + const mainContainer = this.document.querySelector('.thread-answer-post'); + if (mainContainer) { + this.renderer.setStyle(mainContainer, 'overflow-y', 'auto'); + } + } + + onMayEditOrDelete(value: boolean) { + this.mayEditOrDelete = value; + } + + onRightClick(event: MouseEvent) { + event.preventDefault(); + + if (AnswerPostComponent.activeDropdownPost && AnswerPostComponent.activeDropdownPost !== this) { + AnswerPostComponent.activeDropdownPost.showDropdown = false; + AnswerPostComponent.activeDropdownPost.enableBodyScroll(); + AnswerPostComponent.activeDropdownPost.changeDetector.detectChanges(); + } + + AnswerPostComponent.activeDropdownPost = this; + + this.dropdownPosition = { + x: event.clientX, + y: event.clientY, + }; + + this.showDropdown = true; + this.adjustDropdownPosition(); + this.disableBodyScroll(); + } + + adjustDropdownPosition() { + const dropdownWidth = 200; + const screenWidth = window.innerWidth; + + if (this.dropdownPosition.x + dropdownWidth > screenWidth) { + this.dropdownPosition.x = screenWidth - dropdownWidth - 10; + } + } } diff --git a/src/main/webapp/app/shared/metis/metis.module.ts b/src/main/webapp/app/shared/metis/metis.module.ts index f116f5b965d0..870ec4fce24d 100644 --- a/src/main/webapp/app/shared/metis/metis.module.ts +++ b/src/main/webapp/app/shared/metis/metis.module.ts @@ -12,7 +12,6 @@ import { AnswerPostHeaderComponent } from 'app/shared/metis/posting-header/answe import { PostHeaderComponent } from 'app/shared/metis/posting-header/post-header/post-header.component'; import { AnswerPostCreateEditModalComponent } from 'app/shared/metis/posting-create-edit-modal/answer-post-create-edit-modal/answer-post-create-edit-modal.component'; import { PostCreateEditModalComponent } from 'app/shared/metis/posting-create-edit-modal/post-create-edit-modal/post-create-edit-modal.component'; -import { AnswerPostFooterComponent } from 'app/shared/metis/posting-footer/answer-post-footer/answer-post-footer.component'; import { PostFooterComponent } from 'app/shared/metis/posting-footer/post-footer/post-footer.component'; import { PostTagSelectorComponent } from 'app/shared/metis/posting-create-edit-modal/post-create-edit-modal/post-tag-selector/post-tag-selector.component'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; @@ -74,7 +73,6 @@ import { ProfilePictureComponent } from 'app/shared/profile-picture/profile-pict PostTagSelectorComponent, PostFooterComponent, AnswerPostCreateEditModalComponent, - AnswerPostFooterComponent, PostingButtonComponent, PostingMarkdownEditorComponent, PostComponent, @@ -101,7 +99,6 @@ import { ProfilePictureComponent } from 'app/shared/profile-picture/profile-pict PostTagSelectorComponent, AnswerPostCreateEditModalComponent, PostFooterComponent, - AnswerPostFooterComponent, PostingButtonComponent, PostingMarkdownEditorComponent, PostComponent, 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 ca84c6b6580d..0682204dc715 100644 --- a/src/main/webapp/app/shared/metis/post/post.component.html +++ b/src/main/webapp/app/shared/metis/post/post.component.html @@ -1,5 +1,5 @@ -
-
+
+ @if (!isConsecutive()) { -
+ }
@@ -52,19 +51,43 @@ }
@if (!displayInlineInput) { - +
+
+ +
+ @if (!previewMode) { + + } +
+
+
}
@@ -74,12 +97,13 @@
} @if (!isDeleted) { -
+
@if (!previewMode) { }
@@ -108,3 +135,44 @@ (channelReferenceClicked)="onChannelReferenceClicked($event)" [hasChannelModerationRights]="hasChannelModerationRights" /> + + + + +
+ + + + diff --git a/src/main/webapp/app/shared/metis/post/post.component.scss b/src/main/webapp/app/shared/metis/post/post.component.scss index d88762caf808..1a6cf4b3ace2 100644 --- a/src/main/webapp/app/shared/metis/post/post.component.scss +++ b/src/main/webapp/app/shared/metis/post/post.component.scss @@ -14,3 +14,61 @@ .reference-hash { color: inherit; } + +.hover-actions { + position: absolute; + top: -1.8rem; + right: 1%; + display: flex; + gap: 10px; + visibility: hidden; + transition: + opacity 0.2s ease-in-out, + visibility 0.2s ease-in-out; + background: var(--metis-selection-option-background); + padding: 5px; + border-radius: 5px; + border: 0.01rem solid var(--metis-gray); +} + +.message-container { + position: relative; + border-radius: 5px; + transition: background-color 0.3s ease; + + &.force-hover { + background: var(--metis-selection-option-hover-background); + + .hover-actions { + opacity: 1; + visibility: visible; + } + } +} + +.message-content { + padding-left: 0.3rem; +} + +.message-content:hover { + background: var(--metis-selection-option-hover-background); +} + +.message-container:hover .hover-actions { + opacity: 1; + visibility: visible; +} + +.clickable { + cursor: pointer; +} + +.item-icon { + width: 20px; + height: 20px; + margin-right: 0.2rem; +} + +.pinned-message { + background-color: var(--artemis-alert-warning-background); +} diff --git a/src/main/webapp/app/shared/metis/post/post.component.ts b/src/main/webapp/app/shared/metis/post/post.component.ts index 49b9dc7a8d3e..d70c13abae68 100644 --- a/src/main/webapp/app/shared/metis/post/post.component.ts +++ b/src/main/webapp/app/shared/metis/post/post.component.ts @@ -1,9 +1,26 @@ -import { AfterContentChecked, ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild, ViewContainerRef } from '@angular/core'; +import { + AfterContentChecked, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + HostListener, + Inject, + Input, + OnChanges, + OnInit, + Output, + Renderer2, + ViewChild, + ViewContainerRef, + input, +} from '@angular/core'; import { Post } from 'app/entities/metis/post.model'; import { PostingDirective } from 'app/shared/metis/posting.directive'; +import { MetisService } from 'app/shared/metis/metis.service'; import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { ContextInformation, DisplayPriority, PageType, RouteComponents } from '../metis.util'; -import { faBullhorn, faCheckSquare } from '@fortawesome/free-solid-svg-icons'; +import { faBullhorn, faComments, faPencilAlt, faSmile, faThumbtack, faTrash } from '@fortawesome/free-solid-svg-icons'; import dayjs from 'dayjs/esm'; import { PostFooterComponent } from 'app/shared/metis/posting-footer/post-footer/post-footer.component'; import { OneToOneChatService } from 'app/shared/metis/conversations/one-to-one-chat.service'; @@ -14,6 +31,10 @@ import { getAsChannelDTO } from 'app/entities/metis/conversation/channel.model'; import { AnswerPost } from 'app/entities/metis/answer-post.model'; import { AnswerPostCreateEditModalComponent } from 'app/shared/metis/posting-create-edit-modal/answer-post-create-edit-modal/answer-post-create-edit-modal.component'; import { animate, style, transition, trigger } from '@angular/animations'; +import { PostCreateEditModalComponent } from 'app/shared/metis/posting-create-edit-modal/post-create-edit-modal/post-create-edit-modal.component'; +import { PostReactionsBarComponent } from 'app/shared/metis/posting-reactions-bar/post-reactions-bar/post-reactions-bar.component'; +import { CdkOverlayOrigin } from '@angular/cdk/overlay'; +import { DOCUMENT } from '@angular/common'; @Component({ selector: 'jhi-post', @@ -37,8 +58,12 @@ export class PostComponent extends PostingDirective implements OnInit, OnC @Input() showAnswers: boolean; @Output() openThread = new EventEmitter(); @ViewChild('createAnswerPostModal') createAnswerPostModalComponent: AnswerPostCreateEditModalComponent; + @ViewChild('createEditModal') createEditModal!: PostCreateEditModalComponent; @ViewChild('createEditAnswerPostContainer', { read: ViewContainerRef }) containerRef: ViewContainerRef; @ViewChild('postFooter') postFooterComponent: PostFooterComponent; + showReactionSelector = false; + @ViewChild('emojiPickerTrigger') emojiPickerTrigger!: CdkOverlayOrigin; + static activeDropdownPost: PostComponent | null = null; displayInlineInput = false; routerLink: RouteComponents; @@ -52,19 +77,99 @@ export class PostComponent extends PostingDirective implements OnInit, OnC contextInformation: ContextInformation; readonly PageType = PageType; readonly DisplayPriority = DisplayPriority; + mayEditOrDelete: boolean = false; + canPin: boolean = false; // Icons - faBullhorn = faBullhorn; - faCheckSquare = faCheckSquare; + readonly faBullhorn = faBullhorn; + readonly faComments = faComments; + readonly faPencilAlt = faPencilAlt; + readonly faSmile = faSmile; + readonly faTrash = faTrash; + readonly faThumbtack = faThumbtack; + + isConsecutive = input(false); + dropdownPosition = { x: 0, y: 0 }; + @ViewChild(PostReactionsBarComponent) private reactionsBarComponent!: PostReactionsBarComponent; constructor( + public metisService: MetisService, + public changeDetector: ChangeDetectorRef, private oneToOneChatService: OneToOneChatService, private metisConversationService: MetisConversationService, private router: Router, + public renderer: Renderer2, + @Inject(DOCUMENT) private document: Document, ) { super(); } + get reactionsBar() { + return this.reactionsBarComponent; + } + + isPinned(): boolean { + return this.posting.displayPriority === DisplayPriority.PINNED; + } + + onMayEditOrDelete(value: boolean) { + this.mayEditOrDelete = value; + } + + onCanPin(value: boolean) { + this.canPin = value; + } + + onRightClick(event: MouseEvent) { + event.preventDefault(); + + if (PostComponent.activeDropdownPost && PostComponent.activeDropdownPost !== this) { + PostComponent.activeDropdownPost.showDropdown = false; + PostComponent.activeDropdownPost.enableBodyScroll(); + PostComponent.activeDropdownPost.changeDetector.detectChanges(); + } + + PostComponent.activeDropdownPost = this; + + this.dropdownPosition = { + x: event.clientX, + y: event.clientY, + }; + + this.showDropdown = true; + this.adjustDropdownPosition(); + this.disableBodyScroll(); + } + + adjustDropdownPosition() { + const dropdownWidth = 200; + const screenWidth = window.innerWidth; + + if (this.dropdownPosition.x + dropdownWidth > screenWidth) { + this.dropdownPosition.x = screenWidth - dropdownWidth - 10; + } + } + + disableBodyScroll() { + const mainContainer = this.document.querySelector('.posting-infinite-scroll-container'); + if (mainContainer) { + this.renderer.setStyle(mainContainer, 'overflow', 'hidden'); + } + } + + enableBodyScroll() { + const mainContainer = this.document.querySelector('.posting-infinite-scroll-container'); + if (mainContainer) { + this.renderer.setStyle(mainContainer, 'overflow-y', 'auto'); + } + } + + @HostListener('document:click', ['$event']) + onClickOutside() { + this.showDropdown = false; + this.enableBodyScroll(); + } + /** * on initialization: evaluates post context and page type */ diff --git a/src/main/webapp/app/shared/metis/posting-create-edit-modal/answer-post-create-edit-modal/answer-post-create-edit-modal.component.ts b/src/main/webapp/app/shared/metis/posting-create-edit-modal/answer-post-create-edit-modal/answer-post-create-edit-modal.component.ts index 4df22238350c..e061a67aa20a 100644 --- a/src/main/webapp/app/shared/metis/posting-create-edit-modal/answer-post-create-edit-modal/answer-post-create-edit-modal.component.ts +++ b/src/main/webapp/app/shared/metis/posting-create-edit-modal/answer-post-create-edit-modal/answer-post-create-edit-modal.component.ts @@ -1,10 +1,11 @@ -import { Component, Input, ViewContainerRef, ViewEncapsulation } from '@angular/core'; +import { Component, EventEmitter, Input, Output, ViewContainerRef, ViewEncapsulation } from '@angular/core'; import { PostingCreateEditModalDirective } from 'app/shared/metis/posting-create-edit-modal/posting-create-edit-modal.directive'; import { AnswerPost } from 'app/entities/metis/answer-post.model'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { MetisService } from 'app/shared/metis/metis.service'; import { FormBuilder, Validators } from '@angular/forms'; import { PostContentValidationPattern } from 'app/shared/metis/metis.util'; +import { Posting } from 'app/entities/metis/posting.model'; @Component({ selector: 'jhi-answer-post-create-edit-modal', @@ -15,6 +16,7 @@ import { PostContentValidationPattern } from 'app/shared/metis/metis.util'; export class AnswerPostCreateEditModalComponent extends PostingCreateEditModalDirective { @Input() createEditAnswerPostContainerRef: ViewContainerRef; isInputOpen = false; + @Output() postingUpdated = new EventEmitter(); constructor( protected metisService: MetisService, @@ -46,6 +48,7 @@ export class AnswerPostCreateEditModalComponent extends PostingCreateEditModalDi * resets the answer post content */ resetFormGroup(): void { + this.posting = this.posting || { content: '' }; this.formGroup = this.formBuilder.group({ // the pattern ensures that the content must include at least one non-whitespace character content: [this.posting.content, [Validators.required, Validators.maxLength(this.maxContentLength), PostContentValidationPattern]], @@ -78,7 +81,8 @@ export class AnswerPostCreateEditModalComponent extends PostingCreateEditModalDi updatePosting(): void { this.posting.content = this.formGroup.get('content')?.value; this.metisService.updateAnswerPost(this.posting).subscribe({ - next: () => { + next: (updatedPost: AnswerPost) => { + this.postingUpdated.emit(updatedPost); this.isLoading = false; this.isInputOpen = false; this.createEditAnswerPostContainerRef?.clear(); diff --git a/src/main/webapp/app/shared/metis/posting-footer/answer-post-footer/answer-post-footer.component.html b/src/main/webapp/app/shared/metis/posting-footer/answer-post-footer/answer-post-footer.component.html deleted file mode 100644 index 2f0f537dece3..000000000000 --- a/src/main/webapp/app/shared/metis/posting-footer/answer-post-footer/answer-post-footer.component.html +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/src/main/webapp/app/shared/metis/posting-footer/answer-post-footer/answer-post-footer.component.scss b/src/main/webapp/app/shared/metis/posting-footer/answer-post-footer/answer-post-footer.component.scss deleted file mode 100644 index 3ae49fc0e694..000000000000 --- a/src/main/webapp/app/shared/metis/posting-footer/answer-post-footer/answer-post-footer.component.scss +++ /dev/null @@ -1,13 +0,0 @@ -.approved-badge { - max-width: 130px; - overflow: hidden; - text-overflow: ellipsis; - background-color: green; -} - -.not-approved-badge { - max-width: 130px; - overflow: hidden; - text-overflow: ellipsis; - background-color: lightgray; -} diff --git a/src/main/webapp/app/shared/metis/posting-footer/answer-post-footer/answer-post-footer.component.ts b/src/main/webapp/app/shared/metis/posting-footer/answer-post-footer/answer-post-footer.component.ts deleted file mode 100644 index 52d8bee21ca5..000000000000 --- a/src/main/webapp/app/shared/metis/posting-footer/answer-post-footer/answer-post-footer.component.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; -import { PostingFooterDirective } from 'app/shared/metis/posting-footer/posting-footer.directive'; -import { AnswerPost } from 'app/entities/metis/answer-post.model'; - -@Component({ - selector: 'jhi-answer-post-footer', - templateUrl: './answer-post-footer.component.html', - styleUrls: ['./answer-post-footer.component.scss'], -}) -export class AnswerPostFooterComponent extends PostingFooterDirective { - @Input() - isReadOnlyMode = false; - @Input() isLastAnswer = false; - @Output() openPostingCreateEditModal = new EventEmitter(); -} diff --git a/src/main/webapp/app/shared/metis/posting-footer/post-footer/post-footer.component.html b/src/main/webapp/app/shared/metis/posting-footer/post-footer/post-footer.component.html index c04bbd8392e7..6a980a907348 100644 --- a/src/main/webapp/app/shared/metis/posting-footer/post-footer/post-footer.component.html +++ b/src/main/webapp/app/shared/metis/posting-footer/post-footer/post-footer.component.html @@ -12,19 +12,22 @@
- @for (answerPost of sortedAnswerPosts; track postsTrackByFn($index, answerPost); let isLastAnswer = $last) { - + @for (group of groupedAnswerPosts; track trackGroupByFn($index, group)) { + @for (answerPost of group.posts; track trackPostByFn($index, answerPost)) { + + } } } diff --git a/src/main/webapp/app/shared/metis/posting-footer/post-footer/post-footer.component.ts b/src/main/webapp/app/shared/metis/posting-footer/post-footer/post-footer.component.ts index 6b1774853be9..7f6d3b0531a5 100644 --- a/src/main/webapp/app/shared/metis/posting-footer/post-footer/post-footer.component.ts +++ b/src/main/webapp/app/shared/metis/posting-footer/post-footer/post-footer.component.ts @@ -1,4 +1,17 @@ -import { AfterContentChecked, ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild, ViewContainerRef } from '@angular/core'; +import { + AfterContentChecked, + ChangeDetectorRef, + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, + ViewChild, + ViewContainerRef, +} from '@angular/core'; import { PostingFooterDirective } from 'app/shared/metis/posting-footer/posting-footer.directive'; import { Post } from 'app/entities/metis/post.model'; import { MetisService } from 'app/shared/metis/metis.service'; @@ -6,38 +19,40 @@ import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { AnswerPostCreateEditModalComponent } from 'app/shared/metis/posting-create-edit-modal/answer-post-create-edit-modal/answer-post-create-edit-modal.component'; import { AnswerPost } from 'app/entities/metis/answer-post.model'; import dayjs from 'dayjs/esm'; +import { User } from 'app/core/user/user.model'; + +interface PostGroup { + author: User | undefined; + posts: AnswerPost[]; +} @Component({ selector: 'jhi-post-footer', templateUrl: './post-footer.component.html', styleUrls: ['./post-footer.component.scss'], }) -export class PostFooterComponent extends PostingFooterDirective implements OnInit, OnDestroy, AfterContentChecked { +export class PostFooterComponent extends PostingFooterDirective implements OnInit, OnDestroy, AfterContentChecked, OnChanges { @Input() lastReadDate?: dayjs.Dayjs; @Input() readOnlyMode = false; - @Input() previewMode: boolean; - // if the post is previewed in the create/edit modal, - // we need to pass the ref in order to close it when navigating to the previewed post via post context + @Input() previewMode = false; @Input() modalRef?: NgbModalRef; - tags: string[]; - courseId: number; - @Input() - hasChannelModerationRights = false; + @Input() hasChannelModerationRights = false; + @Input() showAnswers = false; + @Input() isCommunicationPage = false; + @Input() sortedAnswerPosts: AnswerPost[] = []; - @ViewChild(AnswerPostCreateEditModalComponent) answerPostCreateEditModal?: AnswerPostCreateEditModalComponent; - @Input() showAnswers: boolean; - @Input() isCommunicationPage: boolean; - @Input() sortedAnswerPosts: AnswerPost[]; @Output() openThread = new EventEmitter(); @Output() userReferenceClicked = new EventEmitter(); @Output() channelReferenceClicked = new EventEmitter(); - createdAnswerPost: AnswerPost; - isAtLeastTutorInCourse: boolean; + @ViewChild(AnswerPostCreateEditModalComponent) answerPostCreateEditModal?: AnswerPostCreateEditModalComponent; + @ViewChild('createEditAnswerPostContainer', { read: ViewContainerRef }) containerRef!: ViewContainerRef; + @ViewChild('createAnswerPostModal') createAnswerPostModalComponent!: AnswerPostCreateEditModalComponent; - // ng-container to render createEditAnswerPostComponent - @ViewChild('createEditAnswerPostContainer', { read: ViewContainerRef }) containerRef: ViewContainerRef; - @ViewChild('createAnswerPostModal') createAnswerPostModalComponent: AnswerPostCreateEditModalComponent; + createdAnswerPost: AnswerPost; + isAtLeastTutorInCourse = false; + courseId!: number; + groupedAnswerPosts: PostGroup[] = []; constructor( private metisService: MetisService, @@ -46,18 +61,20 @@ export class PostFooterComponent extends PostingFooterDirective implements super(); } - /** - * on initialization: updates the post tags and the context information - */ ngOnInit(): void { this.courseId = this.metisService.getCourse().id!; this.isAtLeastTutorInCourse = this.metisService.metisUserIsAtLeastTutorInCourse(); this.createdAnswerPost = this.createEmptyAnswerPost(); + this.groupAnswerPosts(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['sortedAnswerPosts']) { + this.groupAnswerPosts(); + this.changeDetector.detectChanges(); + } } - /** - * on leaving the page, the container for answerPost creation or editing should be cleared - */ ngOnDestroy(): void { this.answerPostCreateEditModal?.createEditAnswerPostContainerRef?.clear(); } @@ -82,6 +99,73 @@ export class PostFooterComponent extends PostingFooterDirective implements return answerPost; } + groupAnswerPosts(): void { + if (!this.sortedAnswerPosts || this.sortedAnswerPosts.length === 0) { + this.groupedAnswerPosts = []; + return; + } + + this.sortedAnswerPosts = this.sortedAnswerPosts + .slice() + .reverse() + .map((post) => { + (post as any).creationDateDayjs = post.creationDate ? dayjs(post.creationDate) : undefined; + return post; + }); + + const sortedPosts = this.sortedAnswerPosts.sort((a, b) => { + const aDate = (a as any).creationDateDayjs; + const bDate = (b as any).creationDateDayjs; + return aDate?.valueOf() - bDate?.valueOf(); + }); + + const groups: PostGroup[] = []; + let currentGroup: PostGroup = { + author: sortedPosts[0].author, + posts: [{ ...sortedPosts[0], isConsecutive: false }], + }; + + for (let i = 1; i < sortedPosts.length; i++) { + const currentPost = sortedPosts[i]; + const lastPostInGroup = currentGroup.posts[currentGroup.posts.length - 1]; + + const currentDate = (currentPost as any).creationDateDayjs; + const lastDate = (lastPostInGroup as any).creationDateDayjs; + + let timeDiff = Number.MAX_SAFE_INTEGER; + if (currentDate && lastDate) { + timeDiff = currentDate.diff(lastDate, 'minute'); + } + + if (currentPost.author?.id === currentGroup.author?.id && timeDiff < 1 && timeDiff >= 0) { + currentGroup.posts.push({ ...currentPost, isConsecutive: true }); // consecutive post + } else { + groups.push(currentGroup); + currentGroup = { + author: currentPost.author, + posts: [{ ...currentPost, isConsecutive: false }], + }; + } + } + + groups.push(currentGroup); + this.groupedAnswerPosts = groups; + this.changeDetector.detectChanges(); + } + + trackGroupByFn(_: number, group: PostGroup): number { + return group.posts[0].id!; + } + + trackPostByFn(_: number, post: AnswerPost): number { + return post.id!; + } + + isLastPost(group: PostGroup, answerPost: AnswerPost): boolean { + const lastPostInGroup = group.posts[group.posts.length - 1]; + return lastPostInGroup.id === answerPost.id; + } + /** * Open create answer modal */ diff --git a/src/main/webapp/app/shared/metis/posting-header/answer-post-header/answer-post-header.component.html b/src/main/webapp/app/shared/metis/posting-header/answer-post-header/answer-post-header.component.html index 7ff859bcdbd7..a03fb20cced3 100644 --- a/src/main/webapp/app/shared/metis/posting-header/answer-post-header/answer-post-header.component.html +++ b/src/main/webapp/app/shared/metis/posting-header/answer-post-header/answer-post-header.component.html @@ -38,47 +38,4 @@ }
- @if (!isDeleted()) { -
- @if (mayEditOrDelete) { - - } - @if (mayEditOrDelete) { - - } - @if (!isAnswerOfAnnouncement) { -
- @if (posting.resolvesPost) { -
- -
- } @else if (isAtLeastTutorInCourse || isAuthorOfOriginalPost) { -
- -
- } -
- } -
- }
diff --git a/src/main/webapp/app/shared/metis/posting-header/answer-post-header/answer-post-header.component.ts b/src/main/webapp/app/shared/metis/posting-header/answer-post-header/answer-post-header.component.ts index 1d70cdd34bc6..9871b3bbee8f 100644 --- a/src/main/webapp/app/shared/metis/posting-header/answer-post-header/answer-post-header.component.ts +++ b/src/main/webapp/app/shared/metis/posting-header/answer-post-header/answer-post-header.component.ts @@ -4,7 +4,6 @@ import { PostingHeaderDirective } from 'app/shared/metis/posting-header/posting- import { MetisService } from 'app/shared/metis/metis.service'; import { faCheck, faCog, faPencilAlt } from '@fortawesome/free-solid-svg-icons'; import dayjs from 'dayjs/esm'; -import { getAsChannelDTO } from 'app/entities/metis/conversation/channel.model'; import { AccountService } from 'app/core/auth/account.service'; @Component({ @@ -21,7 +20,6 @@ export class AnswerPostHeaderComponent extends PostingHeaderDirective }
- @if (!isDeleted()) { -
- @if (mayEditOrDelete) { - - } - - @if (mayEditOrDelete) { - - } -
- }
diff --git a/src/main/webapp/app/shared/metis/posting-header/post-header/post-header.component.ts b/src/main/webapp/app/shared/metis/posting-header/post-header/post-header.component.ts index cc05f6ed7b29..3463b66456fc 100644 --- a/src/main/webapp/app/shared/metis/posting-header/post-header/post-header.component.ts +++ b/src/main/webapp/app/shared/metis/posting-header/post-header/post-header.component.ts @@ -5,7 +5,6 @@ import { MetisService } from 'app/shared/metis/metis.service'; import { PostCreateEditModalComponent } from 'app/shared/metis/posting-create-edit-modal/post-create-edit-modal/post-create-edit-modal.component'; import { faCheckSquare, faCog, faPencilAlt } from '@fortawesome/free-solid-svg-icons'; import dayjs from 'dayjs/esm'; -import { getAsChannelDTO } from 'app/entities/metis/conversation/channel.model'; import { CachingStrategy } from 'app/shared/image/secured-image.component'; import { AccountService } from 'app/core/auth/account.service'; @@ -21,7 +20,6 @@ export class PostHeaderComponent extends PostingHeaderDirective implements @Input() previewMode: boolean; @ViewChild(PostCreateEditModalComponent) postCreateEditModal?: PostCreateEditModalComponent; isAtLeastInstructorInCourse: boolean; - mayEditOrDelete = false; // Icons faPencilAlt = faPencilAlt; @@ -37,7 +35,6 @@ export class PostHeaderComponent extends PostingHeaderDirective implements ngOnInit() { super.ngOnInit(); - this.setMayEditOrDelete(); } /** @@ -45,7 +42,6 @@ export class PostHeaderComponent extends PostingHeaderDirective implements */ ngOnChanges() { this.setUserProperties(); - this.setMayEditOrDelete(); this.setUserAuthorityIconAndTooltip(); } @@ -56,20 +52,5 @@ export class PostHeaderComponent extends PostingHeaderDirective implements this.postCreateEditModal?.modalRef?.close(); } - /** - * invokes the metis service to delete a post - */ - deletePosting(): void { - this.isDeleteEvent.emit(true); - } - - setMayEditOrDelete(): void { - this.isAtLeastInstructorInCourse = this.metisService.metisUserIsAtLeastInstructorInCourse(); - const isCourseWideChannel = getAsChannelDTO(this.posting.conversation)?.isCourseWide ?? false; - const mayEditOrDeleteOtherUsersAnswer = - (isCourseWideChannel && this.isAtLeastInstructorInCourse) || (getAsChannelDTO(this.metisService.getCurrentConversation())?.hasChannelModerationRights ?? false); - this.mayEditOrDelete = !this.readOnlyMode && !this.previewMode && (this.isAuthorOfPosting || mayEditOrDeleteOtherUsersAnswer); - } - protected readonly CachingStrategy = CachingStrategy; } diff --git a/src/main/webapp/app/shared/metis/posting-header/posting-header.directive.ts b/src/main/webapp/app/shared/metis/posting-header/posting-header.directive.ts index 7892a6df0f09..e6deddb407b8 100644 --- a/src/main/webapp/app/shared/metis/posting-header/posting-header.directive.ts +++ b/src/main/webapp/app/shared/metis/posting-header/posting-header.directive.ts @@ -1,5 +1,5 @@ import { Posting } from 'app/entities/metis/posting.model'; -import { Directive, EventEmitter, Input, OnInit, Output, input, output } from '@angular/core'; +import { Directive, EventEmitter, Input, OnInit, Output, input } from '@angular/core'; import dayjs from 'dayjs/esm'; import { MetisService } from 'app/shared/metis/metis.service'; import { UserRole } from 'app/shared/metis/metis.util'; @@ -18,7 +18,7 @@ export abstract class PostingHeaderDirective implements OnIni @Output() isModalOpen = new EventEmitter(); isDeleted = input(false); - isDeleteEvent = output(); + isAtLeastTutorInCourse: boolean; isAuthorOfPosting: boolean; postingIsOfToday: boolean; @@ -97,6 +97,4 @@ export abstract class PostingHeaderDirective implements OnIni this.userAuthorityTooltip = toolTipTranslationPath + this.userAuthority; } } - - abstract deletePosting(): void; } 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 index b262d884fd29..86a4a499a599 100644 --- 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 @@ -1,50 +1,94 @@ -
+
@for (reactionMetaData of reactionMetaDataMap | keyvalue; track reactionMetaData) { -
- -
+ @if (isEmojiCount) { +
+ +
+ } }
- -
- @if (!isReadOnlyMode) { - - @if (!isReadOnlyMode) { - + @if ((isAnyReactionCountAboveZero() && isEmojiCount) || !isEmojiCount) { + + + @if (!isReadOnlyMode) { + + + + } + + } + @if (!isEmojiCount) { + @if (!isAnswerOfAnnouncement && (isAtLeastTutorInCourse || isAuthorOfOriginalPost)) { + } - - } + @if (mayEditOrDelete) { + + + + } + } +
@if (isLastAnswer && !isThreadSidebar) {
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 index bbd67d6324a8..9023089670d5 100644 --- 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 @@ -1,9 +1,10 @@ -import { Component, Input, OnChanges, OnInit } from '@angular/core'; +import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'; import { Reaction } from 'app/entities/metis/reaction.model'; -import { PostingsReactionsBarDirective } from 'app/shared/metis/posting-reactions-bar/posting-reactions-bar.component'; +import { PostingsReactionsBarDirective } from 'app/shared/metis/posting-reactions-bar/posting-reactions-bar.directive'; import { AnswerPost } from 'app/entities/metis/answer-post.model'; -import { faSmile } from '@fortawesome/free-regular-svg-icons'; +import { faCheck, faPencilAlt, faSmile } from '@fortawesome/free-solid-svg-icons'; import { MetisService } from 'app/shared/metis/metis.service'; +import { getAsChannelDTO } from 'app/entities/metis/conversation/channel.model'; @Component({ selector: 'jhi-answer-post-reactions-bar', @@ -16,12 +17,42 @@ export class AnswerPostReactionsBarComponent extends PostingsReactionsBarDirecti @Input() isLastAnswer = false; // Icons - farSmile = faSmile; + readonly farSmile = faSmile; + readonly faCheck = faCheck; + @Output() openPostingCreateEditModal = new EventEmitter(); + isAuthorOfOriginalPost: boolean; + isAnswerOfAnnouncement: boolean; + @Output() mayEditOrDeleteOutput = new EventEmitter(); + mayEditOrDelete: boolean; + readonly faPencilAlt = faPencilAlt; + @Input() isEmojiCount: boolean = false; + @Output() postingUpdated = new EventEmitter(); constructor(metisService: MetisService) { super(metisService); } + ngOnInit() { + super.ngOnInit(); + this.setMayEditOrDelete(); + } + + ngOnChanges() { + super.ngOnChanges(); + this.setMayEditOrDelete(); + } + + 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 @@ -32,4 +63,31 @@ export class AnswerPostReactionsBarComponent extends PostingsReactionsBarDirecti reaction.answerPost = this.posting; return reaction; } + + setMayEditOrDelete(): 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 isAtLeastInstructorInCourse = this.metisService.metisUserIsAtLeastInstructorInCourse(); + const mayEditOrDeleteOtherUsersAnswer = + (isCourseWideChannel && isAtLeastInstructorInCourse) || (getAsChannelDTO(this.metisService.getCurrentConversation())?.hasChannelModerationRights ?? false); + this.mayEditOrDelete = !this.isReadOnlyMode && (this.isAuthorOfPosting || mayEditOrDeleteOtherUsersAnswer); + this.mayEditOrDeleteOutput.emit(this.mayEditOrDelete); + } + + 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 index b17e24a46149..5ba32fbe573e 100644 --- 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 @@ -1,4 +1,4 @@ -
+
@if (!isCommunicationPage) { @if (sortedAnswerPosts.length) { @@ -34,7 +34,7 @@ } @else { @if (!isThreadSidebar) { - @if (sortedAnswerPosts.length === 0) { + @if (hoverBar && sortedAnswerPosts.length === 0) {
+
+ } + } +
+ @if ((isAnyReactionCountAboveZero() && isEmojiCount) || !isEmojiCount) { + + + + + @if (!readOnlyMode) { + + } + + + } + + @if (!isEmojiCount && mayEditOrDelete) { + } + + @if (!isEmojiCount && mayEditOrDelete) { + -
- } -
- -
- - @if (!readOnlyMode) { - } - - @if (displayPriority === DisplayPriority.PINNED || canPin) { -
+ @if (!isEmojiCount && (displayPriority === DisplayPriority.PINNED || canPin)) { -
- } - + } +
@if (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 index abc4b835be61..1d039326b20b 100644 --- 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 @@ -1,33 +1,36 @@ -import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'; import { Reaction } from 'app/entities/metis/reaction.model'; import { Post } from 'app/entities/metis/post.model'; -import { PostingsReactionsBarDirective } from 'app/shared/metis/posting-reactions-bar/posting-reactions-bar.component'; +import { PostingsReactionsBarDirective } from 'app/shared/metis/posting-reactions-bar/posting-reactions-bar.directive'; import { DisplayPriority } from 'app/shared/metis/metis.util'; import { MetisService } from 'app/shared/metis/metis.service'; -import { faSmile } from '@fortawesome/free-regular-svg-icons'; -import { faArrowRight } from '@fortawesome/free-solid-svg-icons'; +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 dayjs from 'dayjs/esm'; -import { isChannelDTO } from 'app/entities/metis/conversation/channel.model'; +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'; @Component({ selector: 'jhi-post-reactions-bar', templateUrl: './post-reactions-bar.component.html', styleUrls: ['../posting-reactions-bar.component.scss'], }) -export class PostReactionsBarComponent extends PostingsReactionsBarDirective implements OnInit, OnChanges { +export class PostReactionsBarComponent extends PostingsReactionsBarDirective implements OnInit, OnChanges, OnDestroy { pinTooltip: string; displayPriority: DisplayPriority; canPin = false; readonly DisplayPriority = DisplayPriority; // Icons - farSmile = faSmile; + faSmile = faSmile; faArrowRight = faArrowRight; + faPencilAlt = faPencilAlt; + faTrash = faTrashAlt; @Input() readOnlyMode = false; @@ -39,6 +42,15 @@ export class PostReactionsBarComponent extends PostingsReactionsBarDirective(); @Output() openPostingCreateEditModal = new EventEmitter(); @Output() openThread = new EventEmitter(); + @Input() previewMode: boolean; + isAtLeastInstructorInCourse: boolean; + @Output() mayEditOrDeleteOutput = new EventEmitter(); + @Output() canPinOutput = new EventEmitter(); + mayEditOrDelete: boolean; + @ViewChild(PostCreateEditModalComponent) postCreateEditModal?: PostCreateEditModalComponent; + @Input() isEmojiCount = false; + @Input() hoverBar: boolean = true; + @ViewChild('createEditModal') createEditModal!: PostCreateEditModalComponent; constructor( metisService: MetisService, @@ -47,6 +59,10 @@ export class PostReactionsBarComponent extends PostingsReactionsBarDirective reaction.count >= 1); + } + /** * on initialization: call resetTooltipsAndPriority */ @@ -56,6 +72,11 @@ export class PostReactionsBarComponent extends PostingsReactionsBarDirective implement @Input() posting: T; @Input() isThreadSidebar: boolean; @Output() openPostingCreateEditModal = new EventEmitter(); + @Output() reactionsUpdated = new EventEmitter(); showReactionSelector = false; currentUserIsAtLeastTutor: boolean; + isAtLeastTutorInCourse: boolean; + isAuthorOfPosting: boolean; + @Output() isModalOpen = new EventEmitter(); + isDeleteEvent = output(); /* * icons (as svg paths) to be used as category preview image in emoji mart selector @@ -101,6 +106,12 @@ export abstract class PostingsReactionsBarDirective implement 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); } /** @@ -153,12 +164,17 @@ export abstract class PostingsReactionsBarDirective implement 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); }); } } diff --git a/src/main/webapp/app/shared/metis/posting-thread/posting-thread.component.html b/src/main/webapp/app/shared/metis/posting-thread/posting-thread.component.html index 4c436cea23e8..04528ab6ce4e 100644 --- a/src/main/webapp/app/shared/metis/posting-thread/posting-thread.component.html +++ b/src/main/webapp/app/shared/metis/posting-thread/posting-thread.component.html @@ -3,6 +3,7 @@ [readOnlyMode]="readOnlyMode" [lastReadDate]="lastReadDate" [posting]="post" + [isConsecutive]="isConsecutive || false" [showAnswers]="showAnswers" [isCommunicationPage]="isCommunicationPage" [showChannelReference]="showChannelReference" diff --git a/src/main/webapp/app/shared/metis/posting-thread/posting-thread.component.ts b/src/main/webapp/app/shared/metis/posting-thread/posting-thread.component.ts index d78673273d41..776d29facdd1 100644 --- a/src/main/webapp/app/shared/metis/posting-thread/posting-thread.component.ts +++ b/src/main/webapp/app/shared/metis/posting-thread/posting-thread.component.ts @@ -17,4 +17,5 @@ export class PostingThreadComponent { @Input() showChannelReference?: boolean; @Input() hasChannelModerationRights = false; @Output() openThread = new EventEmitter(); + @Input() isConsecutive: boolean | undefined = false; } diff --git a/src/main/webapp/app/shared/metis/posting.directive.ts b/src/main/webapp/app/shared/metis/posting.directive.ts index a6bfc936e855..7e601e30d3ca 100644 --- a/src/main/webapp/app/shared/metis/posting.directive.ts +++ b/src/main/webapp/app/shared/metis/posting.directive.ts @@ -1,6 +1,7 @@ import { Posting } from 'app/entities/metis/posting.model'; import { ChangeDetectorRef, Directive, Input, OnDestroy, OnInit, inject } from '@angular/core'; import { MetisService } from 'app/shared/metis/metis.service'; +import { DisplayPriority } from 'app/shared/metis/metis.util'; @Directive() export abstract class PostingDirective implements OnInit, OnDestroy { @@ -10,6 +11,11 @@ export abstract class PostingDirective implements OnInit, OnD @Input() hasChannelModerationRights = false; @Input() isThreadSidebar: boolean; + abstract get reactionsBar(): any; + showDropdown = false; + dropdownPosition = { x: 0, y: 0 }; + showReactionSelector = false; + clickPosition = { x: 0, y: 0 }; isAnswerPost = false; isDeleted = false; @@ -69,4 +75,44 @@ export abstract class PostingDirective implements OnInit, OnD }, 1000); } } + + editPosting() { + this.reactionsBar.editPosting(); + this.showDropdown = false; + } + + togglePin() { + this.reactionsBar.togglePin(); + this.showDropdown = false; + } + + deletePost() { + this.reactionsBar.deletePosting(); + this.showDropdown = false; + } + + checkIfPinned(): DisplayPriority { + return this.reactionsBar.checkIfPinned(); + } + + selectReaction(event: any) { + this.reactionsBar.selectReaction(event); + this.showReactionSelector = false; + } + + addReaction(event: MouseEvent) { + event.preventDefault(); + this.showDropdown = false; + + this.clickPosition = { + x: event.clientX, + y: event.clientY, + }; + + this.showReactionSelector = true; + } + + toggleEmojiSelect() { + this.showReactionSelector = !this.showReactionSelector; + } } diff --git a/src/main/webapp/i18n/de/metis.json b/src/main/webapp/i18n/de/metis.json index 7b278eeee22e..d7fa19710c0d 100644 --- a/src/main/webapp/i18n/de/metis.json +++ b/src/main/webapp/i18n/de/metis.json @@ -126,6 +126,12 @@ "showAllPosts": "Zeige alle Nachrichten", "showContent": "Inhalt ausklappen", "collapseContent": "Inhalt einklappen", + "addReaction": "Reaktion hinzufügen", + "pinMessage": "Nachricht anheften", + "unpinMessage": "Nachricht lösen", + "editMessage": "Nachricht bearbeiten", + "deleteMessage": "Nachricht löschen", + "replyMessage": "Antworten im Thread", "deletedContent": "Beitrag wird in {{ progress }} Sekunde(n) gelöscht.", "undoDelete": "Löschen umkehren" }, diff --git a/src/main/webapp/i18n/en/metis.json b/src/main/webapp/i18n/en/metis.json index 92a75b2da28d..bfb25d30252b 100644 --- a/src/main/webapp/i18n/en/metis.json +++ b/src/main/webapp/i18n/en/metis.json @@ -126,6 +126,12 @@ "showAllPosts": "Show all messages", "showContent": "Show content", "collapseContent": "Collapse content", + "addReaction": "Add reaction", + "pinMessage": "Pin message", + "unpinMessage": "Unpin message", + "editMessage": "Edit message", + "deleteMessage": "Delete message", + "replyMessage": "Reply in thread", "deletedContent": "Post is being deleted in {{ progress }} second(s).", "undoDelete": "Undo delete" }, diff --git a/src/test/javascript/spec/component/overview/course-conversations/layout/conversation-messages/conversation-messages.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/layout/conversation-messages/conversation-messages.component.spec.ts index cb41b848f929..ee1487e6f86d 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/layout/conversation-messages/conversation-messages.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/layout/conversation-messages/conversation-messages.component.spec.ts @@ -19,6 +19,7 @@ import { By } from '@angular/platform-browser'; import { Course } from 'app/entities/course.model'; import { getAsChannelDTO } from 'app/entities/metis/conversation/channel.model'; import { PostCreateEditModalComponent } from 'app/shared/metis/posting-create-edit-modal/post-create-edit-modal/post-create-edit-modal.component'; +import dayjs from 'dayjs'; const examples: ConversationDTO[] = [ generateOneToOneChatDTO({}), @@ -140,6 +141,49 @@ examples.forEach((activeConversation) => { expect(conversation!.id).toEqual(activeConversation.id); })); + it('should set posts and group them correctly', () => { + const posts = [ + { id: 1, creationDate: dayjs().subtract(2, 'hours'), author: { id: 1 } } as Post, + { id: 4, creationDate: dayjs().subtract(3, 'minutes'), author: { id: 1 } } as Post, + { id: 2, creationDate: dayjs().subtract(1, 'minutes'), author: { id: 1 } } as Post, + { id: 3, creationDate: dayjs(), author: { id: 2 } } as Post, + ]; + + component.setPosts(posts); + + expect(component.posts).toHaveLength(4); + expect(component.groupedPosts).toHaveLength(3); + + expect(component.groupedPosts[0].posts).toHaveLength(1); + expect(component.groupedPosts[0].posts[0].id).toBe(1); + expect(component.groupedPosts[0].posts[0].isConsecutive).toBeFalse(); + + expect(component.groupedPosts[1].posts).toHaveLength(2); + expect(component.groupedPosts[1].posts[0].id).toBe(4); + expect(component.groupedPosts[1].posts[0].isConsecutive).toBeFalse(); + expect(component.groupedPosts[1].posts[1].id).toBe(2); + expect(component.groupedPosts[1].posts[1].isConsecutive).toBeTrue(); + + expect(component.groupedPosts[2].posts).toHaveLength(1); + expect(component.groupedPosts[2].posts[0].id).toBe(3); + expect(component.groupedPosts[2].posts[0].isConsecutive).toBeFalse(); + }); + + it('should not group posts that are exactly 5 minutes apart', () => { + const posts = [ + { id: 1, creationDate: dayjs().subtract(10, 'minutes'), author: { id: 1 } } as Post, + { id: 2, creationDate: dayjs().subtract(5, 'minutes'), author: { id: 1 } } as Post, + { id: 3, creationDate: dayjs(), author: { id: 1 } } as Post, + ]; + + component.setPosts(posts); + + expect(component.groupedPosts).toHaveLength(3); + expect(component.groupedPosts[0].posts).toHaveLength(1); + expect(component.groupedPosts[1].posts).toHaveLength(1); + expect(component.groupedPosts[2].posts).toHaveLength(1); + }); + if (getAsChannelDTO(activeConversation)?.isAnnouncementChannel) { it('should display the "new announcement" button when the conversation is an announcement channel', fakeAsync(() => { const announcementButton = fixture.debugElement.query(By.css('.btn.btn-md.btn-primary')); 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 b4b9eea75fd7..247786e4fc12 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 @@ -1,14 +1,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AnswerPostComponent } from 'app/shared/metis/answer-post/answer-post.component'; -import { MockComponent, MockModule, MockPipe } from 'ng-mocks'; -import { DebugElement } from '@angular/core'; +import { DebugElement, input, runInInjectionContext } from '@angular/core'; +import { MockComponent, MockModule, MockPipe, ngMocks } from 'ng-mocks'; import { HtmlForMarkdownPipe } from 'app/shared/pipes/html-for-markdown.pipe'; -import { getElement } from '../../../../helpers/utils/general.utils'; +import { By } from '@angular/platform-browser'; import { AnswerPostHeaderComponent } from 'app/shared/metis/posting-header/answer-post-header/answer-post-header.component'; -import { AnswerPostFooterComponent } from 'app/shared/metis/posting-footer/answer-post-footer/answer-post-footer.component'; +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 { metisResolvingAnswerPostUser1 } from '../../../../helpers/sample/metis-sample-data'; +import { OverlayModule } from '@angular/cdk/overlay'; import { AnswerPostCreateEditModalComponent } from 'app/shared/metis/posting-create-edit-modal/answer-post-create-edit-modal/answer-post-create-edit-modal.component'; +import { DOCUMENT } from '@angular/common'; +import { Reaction } from '../../../../../../../main/webapp/app/entities/metis/reaction.model'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { MetisService } from 'app/shared/metis/metis.service'; import { MockMetisService } from '../../../../helpers/mocks/service/mock-metis-service.service'; @@ -17,19 +20,27 @@ describe('AnswerPostComponent', () => { let component: AnswerPostComponent; let fixture: ComponentFixture; let debugElement: DebugElement; + let mainContainer: HTMLElement; + + beforeEach(async () => { + mainContainer = document.createElement('div'); + mainContainer.classList.add('thread-answer-post'); + document.body.appendChild(mainContainer); - beforeEach(() => { return TestBed.configureTestingModule({ - imports: [MockModule(BrowserAnimationsModule)], + imports: [OverlayModule, MockModule(BrowserAnimationsModule)], declarations: [ AnswerPostComponent, MockPipe(HtmlForMarkdownPipe), MockComponent(AnswerPostHeaderComponent), MockComponent(PostingContentComponent), MockComponent(AnswerPostCreateEditModalComponent), - MockComponent(AnswerPostFooterComponent), + MockComponent(AnswerPostReactionsBarComponent), + ], + providers: [ + { provide: DOCUMENT, useValue: document }, + { provide: MetisService, useClass: MockMetisService }, ], - providers: [{ provide: MetisService, useClass: MockMetisService }], }) .compileComponents() .then(() => { @@ -39,23 +50,129 @@ describe('AnswerPostComponent', () => { }); }); - it('should contain an answer post header', () => { - const header = getElement(debugElement, 'jhi-answer-post-header'); + it('should contain an answer post header when isConsecutive is false', () => { + runInInjectionContext(fixture.debugElement.injector, () => { + component.isConsecutive = input(false); + component.posting = metisResolvingAnswerPostUser1; + }); + + fixture.detectChanges(); + const header = debugElement.query(By.css('jhi-answer-post-header')); expect(header).not.toBeNull(); }); + it('should not contain an answer post header when isConsecutive is true', () => { + runInInjectionContext(fixture.debugElement.injector, () => { + component.isConsecutive = input(true); + component.posting = metisResolvingAnswerPostUser1; + }); + + fixture.detectChanges(); + const header = debugElement.query(By.css('jhi-answer-post-header')); + expect(header).toBeNull(); + }); + it('should contain reference to container for rendering answerPostCreateEditModal component', () => { + component.posting = metisResolvingAnswerPostUser1; + + fixture.detectChanges(); expect(component.containerRef).not.toBeNull(); }); it('should contain component to edit answer post', () => { - const answerPostCreateEditModal = getElement(debugElement, 'jhi-answer-post-create-edit-modal'); + component.posting = metisResolvingAnswerPostUser1; + + fixture.detectChanges(); + const answerPostCreateEditModal = debugElement.query(By.css('jhi-answer-post-create-edit-modal')); expect(answerPostCreateEditModal).not.toBeNull(); }); - it('should have correct content', () => { + it('should contain an answer post reactions bar', () => { component.posting = metisResolvingAnswerPostUser1; - component.ngOnInit(); - expect(component.content).toEqual(metisResolvingAnswerPostUser1.content); + + fixture.detectChanges(); + const reactionsBar = debugElement.query(By.css('jhi-answer-post-reactions-bar')); + expect(reactionsBar).not.toBeNull(); + }); + + it('should have correct content in posting-content component', () => { + component.posting = metisResolvingAnswerPostUser1; + + fixture.detectChanges(); + const postingContentDebugElement = debugElement.query(By.directive(PostingContentComponent)); + expect(postingContentDebugElement).not.toBeNull(); + const content = ngMocks.input(postingContentDebugElement, 'content'); + expect(content).toEqual(metisResolvingAnswerPostUser1.content); + }); + + it('should close previous dropdown when another is opened', () => { + const previousComponent = { + showDropdown: true, + enableBodyScroll: jest.fn(), + changeDetector: { detectChanges: jest.fn() }, + } as any as AnswerPostComponent; + + AnswerPostComponent.activeDropdownPost = previousComponent; + + const event = new MouseEvent('contextmenu', { clientX: 100, clientY: 200 }); + component.onRightClick(event); + + expect(previousComponent.showDropdown).toBeFalse(); + expect(previousComponent.enableBodyScroll).toHaveBeenCalled(); + expect(previousComponent.changeDetector.detectChanges).toHaveBeenCalled(); + expect(AnswerPostComponent.activeDropdownPost).toBe(component); + expect(component.showDropdown).toBeTrue(); + }); + + it('should handle click outside and hide dropdown', () => { + component.showDropdown = true; + const enableBodyScrollSpy = jest.spyOn(component, 'enableBodyScroll' as any); + component.onClickOutside(); + expect(component.showDropdown).toBeFalse(); + expect(enableBodyScrollSpy).toHaveBeenCalled(); + }); + + it('should disable body scroll', () => { + const setStyleSpy = jest.spyOn(component.renderer, 'setStyle'); + (component as any).disableBodyScroll(); + expect(setStyleSpy).toHaveBeenCalledWith(expect.objectContaining({ className: 'thread-answer-post' }), 'overflow', 'hidden'); + }); + + it('should enable body scroll', () => { + const setStyleSpy = jest.spyOn(component.renderer, 'setStyle'); + (component as any).enableBodyScroll(); + expect(setStyleSpy).toHaveBeenCalledWith(expect.objectContaining({ className: 'thread-answer-post' }), 'overflow-y', 'auto'); + }); + + it('should adjust dropdown position if it overflows the screen width', () => { + const dropdownWidth = 200; + const screenWidth = window.innerWidth; + + component.dropdownPosition = { x: screenWidth - 50, y: 100 }; + component.adjustDropdownPosition(); + + expect(component.dropdownPosition.x).toBe(screenWidth - dropdownWidth - 10); + }); + + it('should not adjust dropdown position if it does not overflow the screen width', () => { + const initialX = 100; + component.dropdownPosition = { x: initialX, y: 100 }; + component.adjustDropdownPosition(); + + expect(component.dropdownPosition.x).toBe(initialX); + }); + + it('should update the posting when onPostingUpdated is called', () => { + const updatedPosting = { ...metisResolvingAnswerPostUser1, content: 'Updated content' }; + component.onPostingUpdated(updatedPosting); + + expect(component.posting).toEqual(updatedPosting); + }); + + it('should update reactions when onReactionsUpdated is called', () => { + const updatedReactions = [{ id: 1, emojiId: 'smile', userId: 2 } as Reaction]; + component.onReactionsUpdated(updatedReactions); + + expect(component.posting.reactions).toEqual(updatedReactions); }); }); 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 f2baf5576b7b..cdcc598b7988 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 @@ -10,8 +10,9 @@ import { PostingContentComponent } from 'app/shared/metis/posting-content/postin import { MockMetisService } from '../../../../helpers/mocks/service/mock-metis-service.service'; import { MetisService } from 'app/shared/metis/metis.service'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { PageType } from 'app/shared/metis/metis.util'; +import { DisplayPriority, PageType } from 'app/shared/metis/metis.util'; import { TranslatePipeMock } from '../../../../helpers/mocks/service/mock-translate.service'; +import { OverlayModule } from '@angular/cdk/overlay'; import { metisChannel, metisCourse, @@ -34,6 +35,7 @@ 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'; describe('PostComponent', () => { let component: PostComponent; @@ -44,14 +46,20 @@ describe('PostComponent', () => { let metisServiceGetQueryParamsSpy: jest.SpyInstance; let metisServiceGetPageTypeStub: jest.SpyInstance; let router: MockRouter; + let mainContainer: HTMLElement; beforeEach(() => { + mainContainer = document.createElement('div'); + mainContainer.classList.add('posting-infinite-scroll-container'); + document.body.appendChild(mainContainer); + return TestBed.configureTestingModule({ - imports: [MockDirective(NgbTooltip), MockModule(BrowserAnimationsModule)], + imports: [MockDirective(NgbTooltip), OverlayModule, MockModule(BrowserAnimationsModule)], providers: [ provideRouter([]), { provide: MetisService, useClass: MockMetisService }, { provide: Router, useClass: MockRouter }, + { provide: DOCUMENT, useValue: document }, MockProvider(MetisConversationService), MockProvider(OneToOneChatService), ], @@ -106,11 +114,6 @@ describe('PostComponent', () => { expect(component.sortedAnswerPosts).toEqual([]); }); - it('should contain a post header', () => { - const header = getElement(debugElement, 'jhi-post-header'); - expect(header).not.toBeNull(); - }); - it('should set router link and query params', () => { metisServiceGetLinkSpy = jest.spyOn(metisService, 'getLinkForPost'); metisServiceGetQueryParamsSpy = jest.spyOn(metisService, 'getQueryParamsForPost'); @@ -259,4 +262,53 @@ describe('PostComponent', () => { expect(component.deleteTimer).toBeUndefined(); expect(component.deleteInterval).toBeUndefined(); }); + + it('should return true if the post is pinned', () => { + component.posting = { ...post, displayPriority: DisplayPriority.PINNED }; + expect(component.isPinned()).toBeTrue(); + }); + + it('should return false if the post is not pinned', () => { + component.posting = { ...post, displayPriority: DisplayPriority.NONE }; + expect(component.isPinned()).toBeFalse(); + }); + + it('should close previous dropdown when another is opened', () => { + const previousComponent = { + showDropdown: true, + enableBodyScroll: jest.fn(), + changeDetector: { detectChanges: jest.fn() }, + } as any as PostComponent; + + PostComponent.activeDropdownPost = previousComponent; + + const event = new MouseEvent('contextmenu', { clientX: 100, clientY: 200 }); + component.onRightClick(event); + + expect(previousComponent.showDropdown).toBeFalse(); + expect(previousComponent.enableBodyScroll).toHaveBeenCalled(); + expect(previousComponent.changeDetector.detectChanges).toHaveBeenCalled(); + expect(PostComponent.activeDropdownPost).toBe(component); + expect(component.showDropdown).toBeTrue(); + }); + + it('should disable body scroll', () => { + const setStyleSpy = jest.spyOn(component.renderer, 'setStyle'); + (component as any).disableBodyScroll(); + expect(setStyleSpy).toHaveBeenCalledWith(expect.objectContaining({ className: 'posting-infinite-scroll-container' }), 'overflow', 'hidden'); + }); + + it('should enable body scroll', () => { + const setStyleSpy = jest.spyOn(component.renderer, 'setStyle'); + (component as any).enableBodyScroll(); + expect(setStyleSpy).toHaveBeenCalledWith(expect.objectContaining({ className: 'posting-infinite-scroll-container' }), 'overflow-y', 'auto'); + }); + + it('should handle click outside and hide dropdown', () => { + component.showDropdown = true; + const enableBodyScrollSpy = jest.spyOn(component, 'enableBodyScroll' as any); + component.onClickOutside(); + expect(component.showDropdown).toBeFalse(); + expect(enableBodyScrollSpy).toHaveBeenCalled(); + }); }); diff --git a/src/test/javascript/spec/component/shared/metis/postings-footer/post-footer/post-footer.component.spec.ts b/src/test/javascript/spec/component/shared/metis/postings-footer/post-footer/post-footer.component.spec.ts index a6cffee4a26d..45c38925db52 100644 --- a/src/test/javascript/spec/component/shared/metis/postings-footer/post-footer/post-footer.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/postings-footer/post-footer/post-footer.component.spec.ts @@ -15,6 +15,13 @@ import { AnswerPostCreateEditModalComponent } from 'app/shared/metis/posting-cre import { TranslatePipeMock } from '../../../../../helpers/mocks/service/mock-translate.service'; import { MockMetisService } from '../../../../../helpers/mocks/service/mock-metis-service.service'; import { metisPostExerciseUser1, post, unsortedAnswerArray } from '../../../../../helpers/sample/metis-sample-data'; +import { AnswerPost } from 'app/entities/metis/answer-post.model'; +import { User } from 'app/core/user/user.model'; + +interface PostGroup { + author: User | undefined; + posts: AnswerPost[]; +} describe('PostFooterComponent', () => { let component: PostFooterComponent; @@ -62,6 +69,71 @@ describe('PostFooterComponent', () => { expect(component.createdAnswerPost.resolvesPost).toBeTrue(); }); + it('should group answer posts correctly', () => { + component.sortedAnswerPosts = unsortedAnswerArray; + component.groupAnswerPosts(); + expect(component.groupedAnswerPosts.length).toBeGreaterThan(0); // Ensure groups are created + expect(component.groupedAnswerPosts[0].posts.length).toBeGreaterThan(0); // Ensure posts exist in groups + }); + + it('should group answer posts and detect changes on changes to sortedAnswerPosts input', () => { + const changeDetectorSpy = jest.spyOn(component['changeDetector'], 'detectChanges'); + component.sortedAnswerPosts = unsortedAnswerArray; + component.ngOnChanges({ sortedAnswerPosts: { currentValue: unsortedAnswerArray, previousValue: [], firstChange: true, isFirstChange: () => true } }); + expect(component.groupedAnswerPosts.length).toBeGreaterThan(0); + expect(changeDetectorSpy).toHaveBeenCalled(); + }); + + it('should clear answerPostCreateEditModal container on destroy', () => { + const mockContainerRef = { clear: jest.fn() } as any; + component.answerPostCreateEditModal = { + createEditAnswerPostContainerRef: mockContainerRef, + } as AnswerPostCreateEditModalComponent; + + const clearSpy = jest.spyOn(mockContainerRef, 'clear'); + component.ngOnDestroy(); + expect(clearSpy).toHaveBeenCalled(); + }); + + it('should return the ID of the post in trackPostByFn', () => { + const mockPost: AnswerPost = { id: 200 } as AnswerPost; + + const result = component.trackPostByFn(0, mockPost); + expect(result).toBe(200); + }); + + it('should return the ID of the first post in the group in trackGroupByFn', () => { + const mockGroup: PostGroup = { + author: { id: 1, login: 'user1' } as User, + posts: [{ id: 100, author: { id: 1 } } as AnswerPost], + }; + + const result = component.trackGroupByFn(0, mockGroup); + expect(result).toBe(100); + }); + + it('should return true if the post is the last post in the group in isLastPost', () => { + const mockPost: AnswerPost = { id: 300 } as AnswerPost; + const mockGroup: PostGroup = { + author: { id: 1, login: 'user1' } as User, + posts: [{ id: 100, author: { id: 1 } } as AnswerPost, mockPost], + }; + + const result = component.isLastPost(mockGroup, mockPost); + expect(result).toBeTrue(); + }); + + it('should return false if the post is not the last post in the group in isLastPost', () => { + const mockPost: AnswerPost = { id: 100 } as AnswerPost; + const mockGroup: PostGroup = { + author: { id: 1, login: 'user1' } as User, + posts: [mockPost, { id: 300, author: { id: 1 } } as AnswerPost], + }; + + const result = component.isLastPost(mockGroup, mockPost); + expect(result).toBeFalse(); + }); + it('should be initialized correctly for users that are not at least tutors in course', () => { component.posting = post; component.posting.answers = unsortedAnswerArray; @@ -71,24 +143,6 @@ describe('PostFooterComponent', () => { expect(component.createdAnswerPost.resolvesPost).toBeFalse(); }); - it('should not contain an answer post', () => { - component.posting = post; - component.posting.answers = unsortedAnswerArray; - component.showAnswers = false; - fixture.detectChanges(); - const answerPostComponent = fixture.debugElement.nativeElement.querySelector('jhi-answer-post'); - expect(answerPostComponent).toBeNull(); - }); - - it('should contain reference to container for rendering answerPostCreateEditModal component', () => { - expect(component.containerRef).not.toBeNull(); - }); - - it('should contain component to create a new answer post', () => { - const answerPostCreateEditModal = fixture.debugElement.nativeElement.querySelector('jhi-answer-post-create-edit-modal'); - expect(answerPostCreateEditModal).not.toBeNull(); - }); - it('should open create answer post modal', () => { component.posting = metisPostExerciseUser1; component.ngOnInit(); diff --git a/src/test/javascript/spec/component/shared/metis/postings-header/answer-post-header/answer-post-header.component.spec.ts b/src/test/javascript/spec/component/shared/metis/postings-header/answer-post-header/answer-post-header.component.spec.ts index dc4d6d491b06..20bcc78bd7e8 100644 --- a/src/test/javascript/spec/component/shared/metis/postings-header/answer-post-header/answer-post-header.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/postings-header/answer-post-header/answer-post-header.component.spec.ts @@ -18,8 +18,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { ConfirmIconComponent } from 'app/shared/confirm-icon/confirm-icon.component'; import { PostingMarkdownEditorComponent } from 'app/shared/metis/posting-markdown-editor/posting-markdown-editor.component'; import { PostingButtonComponent } from 'app/shared/metis/posting-button/posting-button.component'; -import { metisAnswerPostUser2, metisPostInChannel, metisResolvingAnswerPostUser1, metisUser1 } from '../../../../../helpers/sample/metis-sample-data'; -import { UserRole } from 'app/shared/metis/metis.util'; +import { metisAnswerPostUser2, metisResolvingAnswerPostUser1, metisUser1 } from '../../../../../helpers/sample/metis-sample-data'; import { AccountService } from 'app/core/auth/account.service'; import { MockAccountService } from '../../../../../helpers/mocks/service/mock-account.service'; import { ProfilePictureComponent } from 'app/shared/profile-picture/profile-picture.component'; @@ -30,9 +29,7 @@ describe('AnswerPostHeaderComponent', () => { let debugElement: DebugElement; let metisService: MetisService; let metisServiceUserIsAtLeastTutorMock: jest.SpyInstance; - let metisServiceUserIsAtLeastInstructorMock: jest.SpyInstance; let metisServiceUserPostingAuthorMock: jest.SpyInstance; - let metisServiceUpdateAnswerPostMock: jest.SpyInstance; const yesterday: dayjs.Dayjs = dayjs().subtract(1, 'day'); @@ -67,9 +64,7 @@ describe('AnswerPostHeaderComponent', () => { component = fixture.componentInstance; metisService = TestBed.inject(MetisService); metisServiceUserIsAtLeastTutorMock = jest.spyOn(metisService, 'metisUserIsAtLeastTutorInCourse'); - metisServiceUserIsAtLeastInstructorMock = jest.spyOn(metisService, 'metisUserIsAtLeastInstructorInCourse'); metisServiceUserPostingAuthorMock = jest.spyOn(metisService, 'metisUserIsAuthorOfPosting'); - metisServiceUpdateAnswerPostMock = jest.spyOn(metisService, 'updateAnswerPost'); debugElement = fixture.debugElement; component.posting = metisResolvingAnswerPostUser1; component.posting.creationDate = yesterday; @@ -97,55 +92,6 @@ describe('AnswerPostHeaderComponent', () => { expect(getElement(debugElement, '#today-flag')).toBeNull(); }); - it('should display edit and delete options to post author', () => { - metisServiceUserPostingAuthorMock.mockReturnValue(true); - fixture.detectChanges(); - expect(getElement(debugElement, '.editIcon')).not.toBeNull(); - expect(getElement(debugElement, '.deleteIcon')).not.toBeNull(); - }); - - it('should display edit and delete options to instructor if posting is in course-wide channel from a student', () => { - metisServiceUserIsAtLeastInstructorMock.mockReturnValue(true); - metisServiceUserPostingAuthorMock.mockReturnValue(false); - component.posting = { ...metisResolvingAnswerPostUser1, post: { ...metisPostInChannel } }; - component.posting.authorRole = UserRole.USER; - component.ngOnInit(); - fixture.detectChanges(); - expect(getElement(debugElement, '.editIcon')).not.toBeNull(); - expect(getElement(debugElement, '.deleteIcon')).not.toBeNull(); - }); - - it('should not 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(getElement(debugElement, '.editIcon')).toBeNull(); - expect(getElement(debugElement, '.deleteIcon')).toBeNull(); - }); - - it('should initialize answer post as marked as resolved', () => { - metisServiceUserIsAtLeastTutorMock.mockReturnValue(false); - fixture.detectChanges(); - expect(component.posting.resolvesPost).toBeTruthy(); - expect(getElement(debugElement, '.resolved')).not.toBeNull(); - expect(getElement(debugElement, '.notResolved')).toBeNull(); - }); - - it('should initialize answer post not marked as resolved but show the check to mark it as such', () => { - // tutors should see the check to mark an answer post as resolving - metisServiceUserIsAtLeastTutorMock.mockReturnValue(true); - // answer post that is not resolving original post - component.posting = metisAnswerPostUser2; - component.ngOnInit(); - fixture.detectChanges(); - expect(getElement(debugElement, '.resolved')).toBeNull(); - expect(getElement(debugElement, '.notResolved')).not.toBeNull(); - }); - it('should initialize answer post not marked as resolved and not show the check to mark it as such', () => { // user, that is not author of original post, should not see the check to mark an answer post as resolving metisServiceUserIsAtLeastTutorMock.mockReturnValue(false); @@ -157,54 +103,4 @@ describe('AnswerPostHeaderComponent', () => { expect(getElement(debugElement, '.resolved')).toBeNull(); expect(getElement(debugElement, '.notResolved')).toBeNull(); }); - - it('should initialize as tutor answer post not marked as resolved but show the check to mark it as such', () => { - // user, that is the author of original post, should not see the check to mark an answer post as resolving - metisServiceUserIsAtLeastTutorMock.mockReturnValue(false); - metisServiceUserPostingAuthorMock.mockReturnValue(true); - // answer post that is not resolving original post - component.posting = metisAnswerPostUser2; - component.ngOnInit(); - fixture.detectChanges(); - expect(getElement(debugElement, '.resolved')).toBeNull(); - expect(getElement(debugElement, '.notResolved')).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(getElement(debugElement, '.editIcon')).toBeNull(); - expect(getElement(debugElement, '.deleteIcon')).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, '.editIcon').click(); - expect(openPostingCreateEditModalEmitSpy).toHaveBeenCalledOnce(); - }); - - it('should not display edit and delete options when post is deleted', () => { - fixture.componentRef.setInput('isDeleted', true); - component.ngOnInit(); - fixture.detectChanges(); - expect(getElement(debugElement, '.editIcon')).toBeNull(); - expect(getElement(debugElement, '.deleteIcon')).toBeNull(); - fixture.componentRef.setInput('isDeleted', false); - }); - - it('should invoke metis service when toggle resolve is clicked as tutor', () => { - metisServiceUserIsAtLeastTutorMock.mockReturnValue(true); - fixture.detectChanges(); - const resolveIcon = getElement(debugElement, '.resolved'); - expect(resolveIcon).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-header/post-header/post-header.component.spec.ts b/src/test/javascript/spec/component/shared/metis/postings-header/post-header/post-header.component.spec.ts index f599c236354d..1074da5a09fa 100644 --- a/src/test/javascript/spec/component/shared/metis/postings-header/post-header/post-header.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/postings-header/post-header/post-header.component.spec.ts @@ -14,7 +14,7 @@ import { ConfirmIconComponent } from 'app/shared/confirm-icon/confirm-icon.compo import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { PostingMarkdownEditorComponent } from 'app/shared/metis/posting-markdown-editor/posting-markdown-editor.component'; import { PostingButtonComponent } from 'app/shared/metis/posting-button/posting-button.component'; -import { metisAnnouncement, metisPostExerciseUser1, metisPostInChannel, metisPostLectureUser1 } from '../../../../../helpers/sample/metis-sample-data'; +import { metisAnnouncement, metisPostExerciseUser1, metisPostLectureUser1 } from '../../../../../helpers/sample/metis-sample-data'; import { UserRole } from 'app/shared/metis/metis.util'; import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import { AccountService } from 'app/core/auth/account.service'; @@ -25,10 +25,6 @@ describe('PostHeaderComponent', () => { let component: PostHeaderComponent; let fixture: ComponentFixture; let debugElement: DebugElement; - let metisService: MetisService; - let metisServiceUserIsAtLeastTutorStub: jest.SpyInstance; - let metisServiceUserIsAtLeastInstructorStub: jest.SpyInstance; - let metisServiceUserIsAuthorOfPostingStub: jest.SpyInstance; beforeEach(() => { return TestBed.configureTestingModule({ imports: [MockModule(FormsModule), MockModule(ReactiveFormsModule), MockDirective(NgbTooltip), MockModule(MetisModule)], @@ -49,10 +45,6 @@ describe('PostHeaderComponent', () => { .then(() => { fixture = TestBed.createComponent(PostHeaderComponent); component = fixture.componentInstance; - metisService = TestBed.inject(MetisService); - metisServiceUserIsAtLeastTutorStub = jest.spyOn(metisService, 'metisUserIsAtLeastTutorInCourse'); - metisServiceUserIsAtLeastInstructorStub = jest.spyOn(metisService, 'metisUserIsAtLeastInstructorInCourse'); - metisServiceUserIsAuthorOfPostingStub = jest.spyOn(metisService, 'metisUserIsAuthorOfPosting'); debugElement = fixture.debugElement; component.posting = metisPostLectureUser1; component.ngOnInit(); @@ -78,64 +70,6 @@ describe('PostHeaderComponent', () => { expect(getElement(debugElement, '.resolved')).not.toBeNull(); }); - it('should display edit and delete options to tutor if posting is not announcement', () => { - metisServiceUserIsAtLeastTutorStub.mockReturnValue(true); - component.ngOnInit(); - fixture.detectChanges(); - expect(getElement(debugElement, '.editIcon')).not.toBeNull(); - expect(getElement(debugElement, '.deleteIcon')).not.toBeNull(); - }); - - it('should display edit and delete options to instructor if posting is in course-wide channel from a student', () => { - metisServiceUserIsAtLeastInstructorStub.mockReturnValue(true); - metisServiceUserIsAuthorOfPostingStub.mockReturnValue(false); - component.posting = { ...metisPostInChannel }; - component.posting.authorRole = UserRole.USER; - component.ngOnInit(); - fixture.detectChanges(); - expect(getElement(debugElement, '.editIcon')).not.toBeNull(); - expect(getElement(debugElement, '.deleteIcon')).not.toBeNull(); - }); - - it('should not display edit and delete options to tutor if posting is in course-wide channel', () => { - metisServiceUserIsAtLeastInstructorStub.mockReturnValue(false); - metisServiceUserIsAtLeastTutorStub.mockReturnValue(true); - metisServiceUserIsAuthorOfPostingStub.mockReturnValue(false); - component.posting = { ...metisPostInChannel }; - component.posting.authorRole = UserRole.USER; - component.ngOnInit(); - fixture.detectChanges(); - expect(getElement(debugElement, '.editIcon')).toBeNull(); - expect(getElement(debugElement, '.deleteIcon')).toBeNull(); - }); - - it('should not display edit and delete options to tutor if posting is announcement', () => { - metisServiceUserIsAtLeastInstructorStub.mockReturnValue(false); - component.posting = metisAnnouncement; - component.ngOnInit(); - fixture.detectChanges(); - expect(getElement(debugElement, '.editIcon')).not.toBeNull(); - expect(getElement(debugElement, '.deleteIcon')).not.toBeNull(); - }); - - it('should display edit and delete options to instructor if posting is announcement', () => { - metisServiceUserIsAtLeastInstructorStub.mockReturnValue(true); - component.posting = metisAnnouncement; - component.ngOnInit(); - fixture.detectChanges(); - expect(getElement(debugElement, '.editIcon')).not.toBeNull(); - expect(getElement(debugElement, '.deleteIcon')).not.toBeNull(); - }); - - it('should not display edit and delete options when post is deleted', () => { - fixture.componentRef.setInput('isDeleted', true); - component.ngOnInit(); - fixture.detectChanges(); - expect(getElement(debugElement, '.editIcon')).toBeNull(); - expect(getElement(debugElement, '.deleteIcon')).toBeNull(); - fixture.componentRef.setInput('isDeleted', false); - }); - it.each` input | expect ${UserRole.INSTRUCTOR} | ${'post-authority-icon-instructor'} 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 index 0557ae671297..8e5b51590166 100644 --- 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 @@ -22,24 +22,40 @@ import { MockLocalStorageService } from '../../../../../helpers/mocks/service/mo 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, metisUser1, post } from '../../../../../helpers/sample/metis-sample-data'; +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)], + declarations: [ + AnswerPostReactionsBarComponent, + TranslatePipeMock, + MockPipe(ReactingUsersOnPostingPipe), + MockComponent(FaIconComponent), + MockComponent(EmojiComponent), + MockComponent(ConfirmIconComponent), + ], providers: [ provideHttpClient(), provideHttpClientTesting(), @@ -57,6 +73,11 @@ describe('AnswerPostReactionsBarComponent', () => { .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; @@ -76,6 +97,18 @@ describe('AnswerPostReactionsBarComponent', () => { 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(); @@ -88,6 +121,54 @@ describe('AnswerPostReactionsBarComponent', () => { 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 edit and delete options to instructor if posting is in course-wide channel from a student', () => { + metisServiceUserIsAtLeastInstructorMock.mockReturnValue(true); + metisServiceUserPostingAuthorMock.mockReturnValue(false); + component.posting = { ...metisResolvingAnswerPostUser1, post: { ...metisPostInChannel } }; + component.posting.authorRole = UserRole.USER; + component.ngOnInit(); + fixture.detectChanges(); + expect(getEditButton()).not.toBeNull(); + expect(getDeleteButton()).not.toBeNull(); + }); + + it('should not 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()).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(); @@ -120,4 +201,14 @@ describe('AnswerPostReactionsBarComponent', () => { const answerNowButton = fixture.debugElement.query(By.css('.reply-btn')).nativeElement; expect(answerNowButton.innerHTML).toContain('reply'); }); + + 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/post-reactions-bar/post-reactions-bar.component.spec.ts index ac6dd8bcbd9c..f5ccda3ada15 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/post-reactions-bar/post-reactions-bar.component.spec.ts @@ -15,7 +15,7 @@ import { MockAccountService } from '../../../../../helpers/mocks/service/mock-ac import { EmojiData, EmojiModule } from '@ctrl/ngx-emoji-mart/ngx-emoji'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { PickerModule } from '@ctrl/ngx-emoji-mart'; -import { DisplayPriority } from 'app/shared/metis/metis.util'; +import { DisplayPriority, UserRole } from 'app/shared/metis/metis.util'; import { MockTranslateService, TranslatePipeMock } from '../../../../../helpers/mocks/service/mock-translate.service'; import { TranslateService } from '@ngx-translate/core'; import { Router } from '@angular/router'; @@ -24,7 +24,7 @@ import { MockLocalStorageService } from '../../../../../helpers/mocks/service/mo 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 { metisCourse, metisPostExerciseUser1, metisUser1, sortedAnswerArray } from '../../../../../helpers/sample/metis-sample-data'; +import { metisAnnouncement, metisCourse, metisPostExerciseUser1, metisPostInChannel, metisUser1, sortedAnswerArray } 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'; @@ -33,6 +33,8 @@ import { ConversationDTO, ConversationType } from 'app/entities/metis/conversati 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'; describe('PostReactionsBarComponent', () => { let component: PostReactionsBarComponent; @@ -41,6 +43,9 @@ describe('PostReactionsBarComponent', () => { let metisService: MetisService; let accountService: AccountService; let metisServiceUpdateDisplayPriorityMock: jest.SpyInstance; + let metisServiceUserIsAtLeastTutorStub: jest.SpyInstance; + let metisServiceUserIsAtLeastInstructorStub: jest.SpyInstance; + let metisServiceUserIsAuthorOfPostingStub: jest.SpyInstance; let post: Post; let reactionToCreate: Reaction; let reactionToDelete: Reaction; @@ -53,7 +58,15 @@ describe('PostReactionsBarComponent', () => { beforeEach(() => { return TestBed.configureTestingModule({ imports: [MockModule(OverlayModule), MockModule(EmojiModule), MockModule(PickerModule), MockDirective(NgbTooltip)], - declarations: [PostReactionsBarComponent, TranslatePipeMock, MockPipe(ReactingUsersOnPostingPipe), MockComponent(FaIconComponent), EmojiComponent], + declarations: [ + PostReactionsBarComponent, + TranslatePipeMock, + MockPipe(ReactingUsersOnPostingPipe), + MockComponent(FaIconComponent), + MockComponent(PostCreateEditModalComponent), + EmojiComponent, + MockComponent(ConfirmIconComponent), + ], providers: [ provideHttpClient(), provideHttpClientTesting(), @@ -75,6 +88,9 @@ describe('PostReactionsBarComponent', () => { debugElement = fixture.debugElement; component = fixture.componentInstance; metisServiceUpdateDisplayPriorityMock = jest.spyOn(metisService, 'updatePostDisplayPriority'); + metisServiceUserIsAtLeastTutorStub = jest.spyOn(metisService, 'metisUserIsAtLeastTutorInCourse'); + metisServiceUserIsAtLeastInstructorStub = jest.spyOn(metisService, 'metisUserIsAtLeastInstructorInCourse'); + metisServiceUserIsAuthorOfPostingStub = jest.spyOn(metisService, 'metisUserIsAuthorOfPosting'); post = new Post(); post.id = 1; post.author = metisUser1; @@ -95,6 +111,14 @@ describe('PostReactionsBarComponent', () => { 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('jhi-confirm-icon')); + } + it('should initialize user authority and reactions correctly', () => { metisCourse.isAtLeastTutor = false; metisService.setCourse(metisCourse); @@ -112,6 +136,95 @@ 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; + jest.spyOn(metisService, 'metisUserIsAuthorOfPosting').mockReturnValue(true); + component.posting = { id: 1, title: 'Test Post' } as Post; + + component.ngOnInit(); + fixture.detectChanges(); + + expect(getDeleteButton()).not.toBeNull(); + expect(getEditButton()).not.toBeNull(); + }); + + it('should display edit and delete options to user with channel moderation rights when not the author', () => { + component.readOnlyMode = false; + component.previewMode = false; + component.isEmojiCount = false; + + const channelConversation = { + type: ConversationType.CHANNEL, + hasChannelModerationRights: true, + } as ChannelDTO; + + jest.spyOn(metisService, 'metisUserIsAuthorOfPosting').mockReturnValue(false); + jest.spyOn(metisService, 'metisUserIsAtLeastInstructorInCourse').mockReturnValue(false); + jest.spyOn(metisService, 'getCurrentConversation').mockReturnValue(channelConversation); + + component.ngOnInit(); + fixture.detectChanges(); + + expect(getEditButton()).not.toBeNull(); + expect(getDeleteButton()).not.toBeNull(); + }); + + it('should not display edit and delete options when user is not the author and lacks permissions', () => { + component.readOnlyMode = false; + component.previewMode = false; + jest.spyOn(metisService, 'metisUserIsAuthorOfPosting').mockReturnValue(false); + jest.spyOn(metisService, 'metisUserIsAtLeastInstructorInCourse').mockReturnValue(false); + component.posting = { conversation: { isCourseWide: false } } as Post; + + component.ngOnInit(); + fixture.detectChanges(); + + expect(debugElement.query(By.css('fa-icon[icon="pencil-alt"]'))).toBeNull(); + expect(debugElement.query(By.directive(ConfirmIconComponent))).toBeNull(); + }); + + it('should not display edit and delete options to tutor if posting is in course-wide channel', () => { + metisServiceUserIsAtLeastInstructorStub.mockReturnValue(false); + metisServiceUserIsAtLeastTutorStub.mockReturnValue(true); + metisServiceUserIsAuthorOfPostingStub.mockReturnValue(false); + component.posting = { ...metisPostInChannel }; + component.posting.authorRole = UserRole.USER; + component.ngOnInit(); + fixture.detectChanges(); + expect(getEditButton()).toBeNull(); + expect(getDeleteButton()).toBeNull(); + }); + + it('should not display edit and delete options to tutor if posting is announcement', () => { + metisServiceUserIsAtLeastInstructorStub.mockReturnValue(false); + component.posting = metisAnnouncement; + component.ngOnInit(); + fixture.detectChanges(); + expect(getEditButton()).toBeNull(); + expect(getDeleteButton()).toBeNull(); + }); + + it('should display edit and delete options to instructor if posting is announcement', () => { + metisServiceUserIsAtLeastInstructorStub.mockReturnValue(true); + component.posting = metisAnnouncement; + component.ngOnInit(); + fixture.detectChanges(); + expect(getEditButton()).not.toBeNull(); + expect(getDeleteButton()).not.toBeNull(); + }); + + it('should display edit and delete options to instructor if posting is in course-wide channel from a student', () => { + metisServiceUserIsAtLeastInstructorStub.mockReturnValue(true); + metisServiceUserIsAuthorOfPostingStub.mockReturnValue(false); + component.posting = { ...metisPostInChannel }; + component.posting.authorRole = UserRole.USER; + component.ngOnInit(); + fixture.detectChanges(); + expect(getEditButton()).not.toBeNull(); + expect(getDeleteButton()).not.toBeNull(); + }); + it.each([ { type: ConversationType.CHANNEL, hasChannelModerationRights: true } as ChannelDTO, { type: ConversationType.GROUP_CHAT, creator: { id: 99 } }, @@ -120,11 +233,15 @@ describe('PostReactionsBarComponent', () => { 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; + component.ngOnInit(); fixture.detectChanges(); const reactions = getElements(debugElement, 'jhi-emoji'); - // emojis to be displayed it the user reaction, the pin, and the show answers toggle emoji - expect(reactions).toHaveLength(3); + expect(reactions).toHaveLength(2); expect(component.reactionMetaDataMap).toEqual({ smile: { count: 1, @@ -132,7 +249,6 @@ describe('PostReactionsBarComponent', () => { reactingUsers: [PLACEHOLDER_USER_REACTED], }, }); - // set correct tooltips for tutor and post that is not pinned and not archived expect(component.pinTooltip).toBe('artemisApp.metis.pinPostTooltip'); }); diff --git a/src/test/javascript/spec/directive/posting.directive.spec.ts b/src/test/javascript/spec/directive/posting.directive.spec.ts new file mode 100644 index 000000000000..ab17325e9b18 --- /dev/null +++ b/src/test/javascript/spec/directive/posting.directive.spec.ts @@ -0,0 +1,129 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Posting } from 'app/entities/metis/posting.model'; +import { DisplayPriority } from 'app/shared/metis/metis.util'; +import { PostingDirective } from 'app/shared/metis/posting.directive'; +import { MetisService } from 'app/shared/metis/metis.service'; + +class MockPosting implements Posting { + content: string; + + constructor(content: string) { + this.content = content; + } +} + +class MockReactionsBar { + editPosting = jest.fn(); + togglePin = jest.fn(); + deletePosting = jest.fn(); + checkIfPinned = jest.fn().mockReturnValue(DisplayPriority.NONE); + selectReaction = jest.fn(); +} + +class MockMetisService {} + +@Component({ + template: `
`, +}) +class TestPostingComponent extends PostingDirective { + reactionsBar: MockReactionsBar = new MockReactionsBar(); + + get reactionsBarInstance() { + return this.reactionsBar; + } + + get reactionsBarGetter() { + return this.reactionsBar; + } +} + +describe('PostingDirective', () => { + let component: TestPostingComponent; + let fixture: ComponentFixture; + let mockReactionsBar: MockReactionsBar; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TestPostingComponent], + providers: [{ provide: MetisService, useClass: MockMetisService }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TestPostingComponent); + component = fixture.componentInstance; + mockReactionsBar = new MockReactionsBar(); + component.reactionsBar = mockReactionsBar; + component.posting = new MockPosting('Test content'); + component.isCommunicationPage = false; + component.isThreadSidebar = false; + fixture.detectChanges(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should initialize content on ngOnInit', () => { + component.ngOnInit(); + expect(component.content).toBe('Test content'); + }); + + it('should call editPosting on reactionsBar and hide dropdown', () => { + component.showDropdown = true; + component.editPosting(); + expect(mockReactionsBar.editPosting).toHaveBeenCalled(); + expect(component.showDropdown).toBeFalse(); + }); + + it('should call togglePin on reactionsBar and hide dropdown', () => { + component.showDropdown = true; + component.togglePin(); + expect(mockReactionsBar.togglePin).toHaveBeenCalled(); + expect(component.showDropdown).toBeFalse(); + }); + + it('should call deletePosting on reactionsBar and hide dropdown', () => { + component.showDropdown = true; + component.deletePost(); + expect(mockReactionsBar.deletePosting).toHaveBeenCalled(); + expect(component.showDropdown).toBeFalse(); + }); + + it('should return display priority from reactionsBar', () => { + const priority = component.checkIfPinned(); + expect(mockReactionsBar.checkIfPinned).toHaveBeenCalled(); + expect(priority).toBe(DisplayPriority.NONE); + }); + + it('should call selectReaction on reactionsBar and hide reaction selector', () => { + const event = { reaction: 'like' }; + component.showReactionSelector = true; + component.selectReaction(event); + expect(mockReactionsBar.selectReaction).toHaveBeenCalledWith(event); + expect(component.showReactionSelector).toBeFalse(); + }); + + it('should add reaction and set click position', () => { + const mouseEvent = new MouseEvent('click', { + clientX: 100, + clientY: 200, + }); + const preventDefaultSpy = jest.spyOn(mouseEvent, 'preventDefault'); + component.addReaction(mouseEvent); + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(component.showDropdown).toBeFalse(); + expect(component.clickPosition).toEqual({ x: 100, y: 200 }); + expect(component.showReactionSelector).toBeTrue(); + }); + + it('should toggle reaction selector visibility', () => { + component.showReactionSelector = false; + component.toggleEmojiSelect(); + expect(component.showReactionSelector).toBeTrue(); + + component.toggleEmojiSelect(); + expect(component.showReactionSelector).toBeFalse(); + }); +});