diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/api-revision-options/api-revision-options.component.html b/src/dotnet/APIView/ClientSPA/src/app/_components/api-revision-options/api-revision-options.component.html index 06876b0393a..71cb5bfd29e 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/api-revision-options/api-revision-options.component.html +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/api-revision-options/api-revision-options.component.html @@ -40,7 +40,7 @@ {{ selectedActiveAPIRevision.prNo }} version: {{ selectedActiveAPIRevision.version }} - {{ selectedActiveAPIRevision.creatorBy }} + {{ selectedActiveAPIRevision.createdBy }} released: {{ selectedActiveAPIRevision.releasedOn | timeago }} @@ -50,14 +50,14 @@ {{ apiRevision.prNo }} version: {{ apiRevision.version }} - {{ apiRevision.creatorBy }} + {{ apiRevision.createdBy }} Latest GA Latest Approved Latest Main Latest Released
created: {{ apiRevision.createdOn | timeago }} - lastUpdated: {{ apiRevision | lastUpdatedOn | timeago }} + last updated: {{ apiRevision | lastUpdatedOn | timeago }} released: {{ apiRevision.releasedOn | timeago }}
{{ apiRevision.label }}
@@ -110,7 +110,7 @@ {{ selectedDiffAPIRevision.prNo }} version: {{ selectedDiffAPIRevision.version }} - {{ selectedDiffAPIRevision.creatorBy }} + {{ selectedDiffAPIRevision.createdBy }} released: {{ selectedDiffAPIRevision.releasedOn | timeago }} @@ -120,13 +120,13 @@ {{ apiRevision.prNo }} version: {{ apiRevision.version }} - {{ apiRevision.creatorBy }} + {{ apiRevision.createdBy }} Latest GA Latest Approved Latest Main Latest Released
created: {{ apiRevision.createdOn | timeago }} - lastUpdated: {{ apiRevision | lastUpdatedOn | timeago }} + last updated: {{ apiRevision | lastUpdatedOn | timeago }} released: {{ apiRevision.releasedOn | timeago }}
{{ apiRevision.label }}
diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/api-revision-options/api-revision-options.component.scss b/src/dotnet/APIView/ClientSPA/src/app/_components/api-revision-options/api-revision-options.component.scss index c9cd6425a69..c65f3b534d9 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/api-revision-options/api-revision-options.component.scss +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/api-revision-options/api-revision-options.component.scss @@ -76,6 +76,4 @@ border: 1px solid var(--alert-secondary-border-color); } } - - } \ No newline at end of file diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/api-revision-options/api-revision-options.component.ts b/src/dotnet/APIView/ClientSPA/src/app/_components/api-revision-options/api-revision-options.component.ts index d76cfe8dc38..57cdcf39ed1 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/api-revision-options/api-revision-options.component.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/api-revision-options/api-revision-options.component.ts @@ -1,5 +1,6 @@ import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { AUTOMATIC_ICON, getTypeClass, MANUAL_ICON, PR_ICON } from 'src/app/_helpers/common-helpers'; import { getQueryParams } from 'src/app/_helpers/router-helpers'; import { AzureEngSemanticVersion } from 'src/app/_models/azureEngSemanticVersion'; import { APIRevision } from 'src/app/_models/revision'; @@ -20,9 +21,9 @@ export class ApiRevisionOptionsComponent implements OnChanges { selectedActiveAPIRevision: any; selectedDiffAPIRevision: any = null; - manualIcon = "fa-solid fa-arrow-up-from-bracket"; - prIcon = "fa-solid fa-code-pull-request"; - automaticIcon = "fa-solid fa-robot"; + manualIcon = MANUAL_ICON; + prIcon = PR_ICON; + automaticIcon = AUTOMATIC_ICON; activeApiRevisionsSearchValue: string = ''; diffApiRevisionsSearchValue: string = ''; @@ -155,29 +156,17 @@ export class ApiRevisionOptionsComponent implements OnChanges { mapRevisionToMenu(apiRevisions: APIRevision[]) { return apiRevisions .map((apiRevision: APIRevision) => { - let typeClass = ''; - switch (apiRevision.apiRevisionType) { - case 'manual': - typeClass = this.manualIcon; - break; - case 'pullRequest': - typeClass = this.prIcon; - break; - case 'automatic': - typeClass = this.automaticIcon; - break; - } return { id : apiRevision.id, resolvedLabel: apiRevision.resolvedLabel, language: apiRevision.language, label: apiRevision.label, - typeClass: typeClass, + typeClass: getTypeClass(apiRevision.apiRevisionType), apiRevisionType: apiRevision.apiRevisionType, version: apiRevision.packageVersion, prNo: apiRevision.pullRequestNo, createdOn: apiRevision.createdOn, - creatorBy: apiRevision.createdBy, + createdBy: apiRevision.createdBy, lastUpdatedOn: apiRevision.lastUpdatedOn, isApproved: apiRevision.isApproved, isReleased: apiRevision.isReleased, diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/code-panel/code-panel.component.ts b/src/dotnet/APIView/ClientSPA/src/app/_components/code-panel/code-panel.component.ts index 07ffae8e113..bcf7198470e 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/code-panel/code-panel.component.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/code-panel/code-panel.component.ts @@ -30,7 +30,7 @@ export class CodePanelComponent implements OnChanges{ @Input() showLineNumbers: boolean = true; @Input() loadFailed : boolean = false; - @Output() hasActiveConversation : EventEmitter = new EventEmitter(); + @Output() hasActiveConversationEmitter : EventEmitter = new EventEmitter(); noDiffInContentMessage : Message[] = [{ severity: 'info', icon:'bi bi-info-circle', detail: 'There is no difference between the two API revisions.' }]; isLoading: boolean = true; @@ -524,16 +524,16 @@ export class CodePanelComponent implements OnChanges{ } private updateHasActiveConversations() { - let hasActiveConversations = false; + let hasActiveConversation = false; for (let row of this.codePanelRowData) { if (row.type === CodePanelRowDatatype.CommentThread) { if (row.comments && row.comments.length > 0 && row.isResolvedCommentThread === false) { - hasActiveConversations = true; + hasActiveConversation = true; break; } } } - this.hasActiveConversation.emit(hasActiveConversations); + this.hasActiveConversationEmitter.emit(hasActiveConversation); } private loadCodePanelViewPort() { diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/conversations/conversations.component.html b/src/dotnet/APIView/ClientSPA/src/app/_components/conversations/conversations.component.html new file mode 100644 index 00000000000..9964bbfc728 --- /dev/null +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/conversations/conversations.component.html @@ -0,0 +1,32 @@ +

Conversations

+ +

This Review has no comments

+ + + + + +
+ + + {{ apiRevision.pullRequestNo }} + version: {{ apiRevision.packageVersion }} + + {{ apiRevision.createdBy }} + released: {{ apiRevision.releasedOn | timeago }} + created: {{ apiRevision.createdOn | timeago }} + last updated: {{ apiRevision | lastUpdatedOn | timeago }} + {{ apiRevision.label }} + + +
+
+
\ No newline at end of file diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/conversations/conversations.component.scss b/src/dotnet/APIView/ClientSPA/src/app/_components/conversations/conversations.component.scss new file mode 100644 index 00000000000..b910b800b1a --- /dev/null +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/conversations/conversations.component.scss @@ -0,0 +1,48 @@ +:host ::ng-deep { + .p-timeline-event-opposite { + flex: 0 0 auto !important; + } + + .p-timeline .p-timeline-event-connector { + background-color: var(--border-color) !important; + } + + .p-timeline.p-timeline-vertical .p-timeline-event-connector { + width: 2px; + } + + .emphasis-badge { + border-radius: 5px; + padding: 0px 5px 2px 5px; + font-size: smaller; + font-weight: bold; + + &.info { + background-color: var(--alert-info-bg); + color: var(--alert-info-color); + border: 1px solid var(--alert-info-border-color); + } + + &.warn { + background-color: var(--alert-warn-bg); + color: var(--alert-warn-color); + border: 1px solid var(--alert-warn-border-color); + } + + &.success { + background-color: var(--alert-success-bg); + color: var(--alert-success-color); + border: 1px solid var(--alert-success-border-color); + } + + &.secondary { + background-color: var(--alert-secondary-bg); + color: var(--alert-secondary-color); + border: 1px solid var(--alert-secondary-border-color); + } + } + + .conversation-group-element-id { + cursor: pointer; + } +} \ No newline at end of file diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/conversations/conversations.component.spec.ts b/src/dotnet/APIView/ClientSPA/src/app/_components/conversations/conversations.component.spec.ts new file mode 100644 index 00000000000..637f0ec8e84 --- /dev/null +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/conversations/conversations.component.spec.ts @@ -0,0 +1,123 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConversationsComponent } from './conversations.component'; +import { SharedAppModule } from 'src/app/_modules/shared/shared-app.module'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ReviewPageModule } from 'src/app/_modules/review-page/review-page.module'; +import { APIRevision } from 'src/app/_models/revision'; +import { CommentItemModel } from 'src/app/_models/commentItemModel'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { of } from 'rxjs'; + +describe('ConversationComponent', () => { + let component: ConversationsComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ConversationsComponent], + imports: [ + HttpClientTestingModule, + ReviewPageModule, + SharedAppModule + ], + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: { + paramMap: convertToParamMap({ reviewId: 'test' }), + }, + queryParams: of(convertToParamMap({ activeApiRevisionId: 'test', diffApiRevisionId: 'test' })) + } + } + ] + }); + fixture = TestBed.createComponent(ConversationsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('createCommentThreads', () => { + it('should group conversation by elementId and latest API revision of comments', () => { + const apiRevisions = [ + { + id: '1', + createdOn: '2021-10-01T00:00:00Z' + }, + { + id: '2', + createdOn: '2022-10-01T00:00:00Z' + }, + { + id: '3', + createdOn: '2023-10-01T00:00:00Z' + }, + { + id: '4', + createdOn: '2024-10-01T00:00:00Z' + } + ] as APIRevision[]; + + const comments = [ + { + id: '1', + elementId: '1', + apiRevisionId: '1' + }, + { + id: '2', + elementId: '2', + apiRevisionId: '1' + }, + { + id: '3', + elementId: '3', + apiRevisionId: '1' + }, + { + id: '4', + elementId: '1', + apiRevisionId: '2', + isResolved: true + }, + { + id: '5', + elementId: '2', + apiRevisionId: '2' + }, + { + id: '6', + elementId: '3', + apiRevisionId: '2', + isResolved: true + }, + { + id: '7', + elementId: '2', + apiRevisionId: '3' + }, + { + id: '8', + elementId: '2', + apiRevisionId: '4' + }, + ] as CommentItemModel[]; + + component.apiRevisions = apiRevisions; + component.comments = comments; + fixture.detectChanges(); + component.createCommentThreads(); + + expect(component.commentThreads.size).toBe(2); + + const keys = Array.from(component.commentThreads.keys()); + expect(keys).toEqual(['2', '4']); + expect(component.numberOfActiveThreads).toBe(1); + }); + }); +}); diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/conversations/conversations.component.ts b/src/dotnet/APIView/ClientSPA/src/app/_components/conversations/conversations.component.ts new file mode 100644 index 00000000000..f2d3e825330 --- /dev/null +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/conversations/conversations.component.ts @@ -0,0 +1,177 @@ +import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; +import { CodePanelRowData, CodePanelRowDatatype } from 'src/app/_models/codePanelModels'; +import { CommentItemModel, CommentType } from 'src/app/_models/commentItemModel'; +import { APIRevision } from 'src/app/_models/revision'; +import { getTypeClass, SCROLL_TO_NODE_QUERY_PARAM } from 'src/app/_helpers/common-helpers'; +import { CommentsService } from 'src/app/_services/comments/comments.service'; +import { take } from 'rxjs'; +import { Review } from 'src/app/_models/review'; +import { UserProfile } from 'src/app/_models/userProfile'; +import { ActivatedRoute, Router } from '@angular/router'; + +@Component({ + selector: 'app-conversations', + templateUrl: './conversations.component.html', + styleUrls: ['./conversations.component.scss'] +}) +export class ConversationsComponent implements OnChanges { + @Input() apiRevisions: APIRevision[] = []; + @Input() activeApiRevisionId: string | null = null; + @Input() comments: CommentItemModel[] = []; + @Input() review : Review | undefined = undefined; + @Input() userProfile : UserProfile | undefined; + + @Output() scrollToNodeEmitter : EventEmitter = new EventEmitter(); + @Output() numberOfActiveThreadsEmitter : EventEmitter = new EventEmitter(); + + commentThreads: Map = new Map(); + numberOfActiveThreads: number = 0; + + constructor(private commentsService: CommentsService, private route: ActivatedRoute, private router: Router) { } + + ngOnChanges(changes: SimpleChanges) { + if (changes['apiRevisions'] || changes['comments']) { + if (this.apiRevisions.length > 0 && this.comments.length > 0) { + this.createCommentThreads(); + } + } + } + + createCommentThreads() { + this.commentThreads = new Map(); + this.numberOfActiveThreads = 0; + const apiRevisionInOrder = this.apiRevisions.sort((a, b) => (new Date(b.createdOn) as any) - (new Date(a.createdOn) as any)); + const groupedComments = this.comments + .reduce((acc: { [key: string]: CommentItemModel[] }, comment) => { + const key = comment.elementId; + if (!acc[key]) { + acc[key] = []; + } + acc[key].push(comment); + return acc; + }, {}); + + for (const elementId in groupedComments) { + if (groupedComments.hasOwnProperty(elementId)) { + const comments = groupedComments[elementId]; + const apiRevisionIds = comments.map(c => c.apiRevisionId); + + let apiRevisionPostion = Number.MAX_SAFE_INTEGER; + + for (const apiRevisionId of apiRevisionIds) { + const apiRevisionIdPosition = apiRevisionInOrder.findIndex(apiRevision => apiRevision.id === apiRevisionId); + if (apiRevisionIdPosition >= 0 && apiRevisionIdPosition < apiRevisionPostion) { + apiRevisionPostion = apiRevisionIdPosition; + } + } + + if (apiRevisionPostion >= 0 && apiRevisionPostion < apiRevisionInOrder.length) { + const apiRevisionIdForThread = apiRevisionInOrder[apiRevisionPostion].id; + const codePanelRowData = new CodePanelRowData(); + codePanelRowData.type = CodePanelRowDatatype.CommentThread; + codePanelRowData.comments = comments; + codePanelRowData.isResolvedCommentThread = comments.some(c => c.isResolved); + + if (!codePanelRowData.isResolvedCommentThread) { + this.numberOfActiveThreads++; + } + + if (this.commentThreads.has(apiRevisionIdForThread)) { + this.commentThreads.get(apiRevisionIdForThread)?.push(codePanelRowData); + } + else { + this.commentThreads.set(apiRevisionIdForThread, [codePanelRowData]); + } + } + } + } + this.numberOfActiveThreadsEmitter.emit(this.numberOfActiveThreads); + } + + getAPIRevisionWithComments() { + return this.apiRevisions.filter(apiRevision => this.commentThreads.has(apiRevision.id)); + } + + getAPIRevisionTypeClass(apiRevision: APIRevision) { + return getTypeClass(apiRevision.apiRevisionType); + } + + navigateToCommentThreadOnRevisionPage(event: Event) { + const target = event.target as Element; + const revisionIdForConversationGroup = target.closest(".conversation-group-revision-id")?.getAttribute("data-conversation-group-revision-id"); + const elementIdForConversationGroup = (target.closest(".conversation-group-threads")?.getElementsByClassName("conversation-group-element-id")[0] as HTMLElement).innerText; + + if (this.activeApiRevisionId && this.activeApiRevisionId === revisionIdForConversationGroup) { + this.scrollToNodeEmitter.emit(elementIdForConversationGroup); + } else { + window.open(`review/${this.review?.id}?activeApiRevisionId=${revisionIdForConversationGroup}&nId=${elementIdForConversationGroup}`, '_blank'); + } + } + + handleSaveCommentActionEmitter(data: any) { + if (data.commentId) { + this.commentsService.updateComment(this.review?.id!, data.commentId, data.commentText).pipe(take(1)).subscribe({ + next: () => { + this.comments.find(c => c.id === data.commentId)!.commentText = data.commentText; + } + }); + } + else { + this.commentsService.createComment(this.review?.id!, data.revisionIdForConversationGroup!, data.nodeId, data.commentText, CommentType.APIRevision, data.allowAnyOneToResolve) + .pipe(take(1)).subscribe({ + next: (response: CommentItemModel) => { + this.comments.push(response); + this.createCommentThreads(); + } + } + ); + } + } + + handleCommentUpvoteActionEmitter(data: any){ + this.commentsService.toggleCommentUpVote(this.review?.id!, data.commentId).pipe(take(1)).subscribe({ + next: () => { + const comment = this.comments.find(c => c.id === data.commentId) + if (comment) { + if (comment.upvotes.includes(this.userProfile?.userName!)) { + comment.upvotes.splice(comment.upvotes.indexOf(this.userProfile?.userName!), 1); + } else { + comment.upvotes.push(this.userProfile?.userName!); + } + } + } + }); + } + + handleDeleteCommentActionEmitter(data: any) { + this.commentsService.deleteComment(this.review?.id!, data.commentId).pipe(take(1)).subscribe({ + next: () => { + this.comments = this.comments.filter(c => c.id !== data.commentId); + this.createCommentThreads(); + } + }); + } + + handleCommentResolutionActionEmitter(data: any) { + if (data.action === "Resolve") { + this.commentsService.resolveComments(this.review?.id!, data.elementId).pipe(take(1)).subscribe({ + next: () => { + this.comments.filter(c => c.elementId === data.elementId).forEach(c => { + c.isResolved = true; + }); + this.createCommentThreads(); + } + }); + } + if (data.action === "Unresolve") { + this.commentsService.unresolveComments(this.review?.id!, data.elementId).pipe(take(1)).subscribe({ + next: () => { + this.comments.filter(c => c.elementId === data.elementId).forEach(c => { + c.isResolved = false; + }); + this.createCommentThreads(); + } + }); + } + } +} diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/review-page-options/review-page-options.component.ts b/src/dotnet/APIView/ClientSPA/src/app/_components/review-page-options/review-page-options.component.ts index d8730b70e6c..d543a36b7ea 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/review-page-options/review-page-options.component.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/review-page-options/review-page-options.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; +import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { InputSwitchOnChangeEvent } from 'primeng/inputswitch'; import { getQueryParams } from 'src/app/_helpers/router-helpers'; diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.html b/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.html index df63fef727e..5e0589773c7 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.html +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.html @@ -9,7 +9,14 @@ (pageOptionsEmitter)="handlePageOptionsEmitter($event)">
- + + + + + + + +
+ (hasActiveConversationEmitter)="handleHasActiveConversationEmitter($event)">
@@ -67,9 +74,18 @@
- + + [revisionSidePanel]="revisionSidePanel!"> + + + \ No newline at end of file diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.scss b/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.scss index 88abf90e894..e9b8f815d47 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.scss +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.scss @@ -11,6 +11,18 @@ display: block; min-width: 0; } + + .side-menu { + .p-menuitem-link { + font-size: x-large; + } + + p-badge { + position: relative; + left: -1.2rem; + top: -1.4rem; + } + } .p-menu { background: var(--base-fg-color); @@ -40,7 +52,7 @@ } } - .revisions-sidebar { + .revisions-sidebar, .conversation-sidebar { width: 75dvw; } } \ No newline at end of file diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.ts b/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.ts index 20a017ad612..7b07254b933 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/review-page/review-page.component.ts @@ -16,6 +16,7 @@ import { ACTIVE_API_REVISION_ID_QUERY_PARAM, DIFF_API_REVISION_ID_QUERY_PARAM, D import { CodePanelData, CodePanelRowData, CodePanelRowDatatype } from 'src/app/_models/codePanelModels'; import { UserProfile } from 'src/app/_models/userProfile'; import { ReviewPageWorkerMessageDirective } from 'src/app/_models/insertCodePanelRowDataMessage'; +import { CommentItemModel } from 'src/app/_models/commentItemModel'; @Component({ selector: 'app-review-page', @@ -33,9 +34,11 @@ export class ReviewPageComponent implements OnInit { userProfile : UserProfile | undefined; review : Review | undefined = undefined; apiRevisions: APIRevision[] = []; + comments: CommentItemModel[] = []; activeAPIRevision : APIRevision | undefined = undefined; diffAPIRevision : APIRevision | undefined = undefined; - revisionSideBarVisible : boolean = false; + revisionSidePanel : boolean | undefined = undefined; + conversationSidePanel : boolean | undefined = undefined; reviewPageNavigation : TreeNode[] = []; language: string | undefined; languageSafeName: string | undefined; @@ -45,6 +48,7 @@ export class ReviewPageComponent implements OnInit { preferredApprovers : string[] = []; hasFatalDiagnostics : boolean = false; hasActiveConversation : boolean = false; + numberOfActiveConversation : number = 0; hasHiddenAPIs : boolean = false; loadFailed : boolean = false; @@ -68,7 +72,7 @@ export class ReviewPageComponent implements OnInit { constructor(private route: ActivatedRoute, private router: Router, private apiRevisionsService: RevisionsService, private reviewsService: ReviewsService, private workerService: WorkerService, private changeDetectorRef: ChangeDetectorRef, - private userProfileService: UserProfileService) {} + private userProfileService: UserProfileService, private commentsService: CommentsService) {} ngOnInit() { this.userProfileService.getUserProfile().subscribe( @@ -100,11 +104,20 @@ export class ReviewPageComponent implements OnInit { this.loadReview(this.reviewId!); this.loadPreferredApprovers(this.reviewId!); this.loadAPIRevisions(0, this.apiRevisionPageSize); + this.loadComments(); + this.createSideMenu(); + } + createSideMenu() { this.sideMenu = [ { icon: 'bi bi-clock-history', - command: () => { this.revisionSideBarVisible = !this.revisionSideBarVisible; } + command: () => { this.revisionSidePanel = !this.revisionSidePanel; } + }, + { + icon: 'bi bi-chat-left-dots', + badge: (this.numberOfActiveConversation > 0) ? this.numberOfActiveConversation.toString() : undefined, + command: () => { this.conversationSidePanel = !this.conversationSidePanel; } } ]; } @@ -215,6 +228,15 @@ export class ReviewPageComponent implements OnInit { }); } + loadComments() { + this.commentsService.getComments(this.reviewId!) + .pipe(takeUntil(this.destroy$)).subscribe({ + next: (comments: CommentItemModel[]) => { + this.comments = comments; + } + }); + } + handlePageOptionsEmitter(showPageOptions: boolean) { this.userProfile!.preferences.hideReviewPageOptions = !showPageOptions; this.userProfileService.updateUserPrefernece(this.userProfile!.preferences).pipe(takeUntil(this.destroy$)).subscribe({ @@ -402,6 +424,16 @@ export class ReviewPageComponent implements OnInit { this.hasActiveConversation = value; } + handleNumberOfActiveThreadsEmitter(value: number) { + this.numberOfActiveConversation = value; + this.createSideMenu(); + } + + handleScrollToNodeEmitter (value: string) { + this.conversationSidePanel = false; + this.codePanelComponent.scrollToNode(undefined, value); + } + checkForFatalDiagnostics() { for (const rowData of this.codePanelRowData) { if (rowData.diagnostics && rowData.diagnostics.level === 'fatal') { diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/revisions-list/revisions-list.component.ts b/src/dotnet/APIView/ClientSPA/src/app/_components/revisions-list/revisions-list.component.ts index c37ab00567b..b5626dbc67f 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/revisions-list/revisions-list.component.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/revisions-list/revisions-list.component.ts @@ -21,7 +21,7 @@ import { environment } from 'src/environments/environment'; }) export class RevisionsListComponent implements OnInit, OnChanges { @Input() review : Review | undefined = undefined; - @Input() revisionSideBarVisible : boolean = false; + @Input() revisionSidePanel : boolean = false; @ViewChild("revisionCreationFileUpload") revisionCreationFileUpload!: FileUpload; @@ -110,7 +110,7 @@ export class RevisionsListComponent implements OnInit, OnChanges { this.showDiffButton = false; } - if (changes['revisionSideBarVisible'] && changes['revisionSideBarVisible'].currentValue == false) { + if (changes['revisionSidePanel'] && changes['revisionSidePanel'].currentValue == false) { this.createRevisionSidebarVisible = false; this.optionsSidebarVisible = false; } diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/shared/comment-thread/comment-thread.component.html b/src/dotnet/APIView/ClientSPA/src/app/_components/shared/comment-thread/comment-thread.component.html index 99cb19d8829..03e5f7b3ca7 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/shared/comment-thread/comment-thread.component.html +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/shared/comment-thread/comment-thread.component.html @@ -1,7 +1,7 @@
This thread is marked resolved by {{ threadResolvedBy }}  {{threadResolvedStateToggleText}} Resolved
-
+
diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/shared/comment-thread/comment-thread.component.scss b/src/dotnet/APIView/ClientSPA/src/app/_components/shared/comment-thread/comment-thread.component.scss index 285c4f75d8b..d0256c539c8 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/shared/comment-thread/comment-thread.component.scss +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/shared/comment-thread/comment-thread.component.scss @@ -1,5 +1,9 @@ :host ::ng-deep { font-family: var(--font-family); + + .comment-thread-container { + max-width: 1000px; + } .user-avartar { border: 2px solid var(--border-color); diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/shared/comment-thread/comment-thread.component.spec.ts b/src/dotnet/APIView/ClientSPA/src/app/_components/shared/comment-thread/comment-thread.component.spec.ts index 8f1d1b409f3..3f8cf2ccf42 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/shared/comment-thread/comment-thread.component.spec.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/shared/comment-thread/comment-thread.component.spec.ts @@ -3,8 +3,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CommentThreadComponent } from './comment-thread.component'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { SharedAppModule } from 'src/app/_modules/shared/shared-app.module'; -import { CodePanelRowData } from 'src/app/_models/codePanelModels'; import { ReviewPageModule } from 'src/app/_modules/review-page/review-page.module'; +import { CommentItemModel } from 'src/app/_models/commentItemModel'; +import { CodePanelRowData } from 'src/app/_models/codePanelModels'; describe('CommentThreadComponent', () => { let component: CommentThreadComponent; @@ -28,4 +29,35 @@ describe('CommentThreadComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + describe('setCommentResolutionState', () => { + it ('should select latest user to resolve comment thread', () => { + const comment1 = { + id: '1', + isResolved: true, + changeHistory: [ { + changeAction: 'resolved', + changedBy: 'test user 1', + }] + } as CommentItemModel; + const comment2 = { + id: '2', + isResolved: true, + changeHistory: [ { + changeAction: 'resolved', + changedBy: 'test user 1', + }, + { + changeAction: 'resolved', + changedBy: 'test user 2', + }] + } as CommentItemModel; + + component.codePanelRowData!.comments = [comment1, comment2]; + component.codePanelRowData!.isResolvedCommentThread = true; + fixture.detectChanges(); + component.setCommentResolutionState(); + expect(component.threadResolvedBy).toBe('test user 2'); + }); + }); }); diff --git a/src/dotnet/APIView/ClientSPA/src/app/_components/shared/comment-thread/comment-thread.component.ts b/src/dotnet/APIView/ClientSPA/src/app/_components/shared/comment-thread/comment-thread.component.ts index 4580f03808a..f9c5490e591 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_components/shared/comment-thread/comment-thread.component.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/_components/shared/comment-thread/comment-thread.component.ts @@ -18,6 +18,7 @@ import { UserProfile } from 'src/app/_models/userProfile'; export class CommentThreadComponent { @Input() codePanelRowData: CodePanelRowData | undefined = undefined; @Input() associatedCodeLine: CodePanelRowData | undefined; + @Input() instanceLocation: "code-panel" | "conversations" = "code-panel"; @Output() cancelCommentActionEmitter : EventEmitter = new EventEmitter(); @Output() saveCommentActionEmitter : EventEmitter = new EventEmitter(); @Output() deleteCommentActionEmitter : EventEmitter = new EventEmitter(); @@ -109,13 +110,19 @@ export class CommentThreadComponent { setCommentResolutionState() { if (this.codePanelRowData?.isResolvedCommentThread) { - this.threadResolvedBy = this.codePanelRowData?.commentThreadIsResolvedBy ?? this.codePanelRowData?.comments?.find(comment => comment.isResolved)?.changeHistory.find(ch => ch.changeAction === 'resolved')?.changedBy; - this.spacingBasedOnResolvedState = 'mb-2'; + this.threadResolvedBy = this.codePanelRowData?.commentThreadIsResolvedBy; + if (!this.threadResolvedBy) { + const lastestResolvedComment = Array.from(this.codePanelRowData?.comments || []).reverse().find(comment => comment.isResolved && comment.changeHistory && comment.changeHistory.some(ch => ch.changeAction === 'resolved')); + if (lastestResolvedComment) { + this.threadResolvedBy = lastestResolvedComment.changeHistory.reverse().find(ch => ch.changeAction === 'resolved')?.changedBy; + } + } + this.spacingBasedOnResolvedState = (this.instanceLocation === "code-panel") ? 'mb-2' : ""; this.resolveThreadButtonText = 'Unresolve'; } else { this.threadResolvedBy = ''; - this.spacingBasedOnResolvedState = 'my-2'; + this.spacingBasedOnResolvedState = (this.instanceLocation === "code-panel") ? 'my-2' : ""; this.resolveThreadButtonText = 'Resolve'; } } @@ -171,14 +178,16 @@ export class CommentThreadComponent { const commentId = target.getAttribute("data-item-id"); const commentData = this.codePanelRowData?.comments?.find(comment => comment.id === commentId)?.commentText.replace(/<[^>]*>/g, '').trim(); - console.log(this.associatedCodeLine); - - const codeLineContent = this.associatedCodeLine + let codeLineContent = this.associatedCodeLine ? this.associatedCodeLine.rowOfTokens .map(token => token.value) .join('') : ''; + if (!codeLineContent) { + codeLineContent = this.codePanelRowData?.comments[0].elementId!; + } + const nodeId: string = this.codePanelRowData?.nodeId ?? 'defaultNodeId'; const apiViewUrl = `${window.location.href.split("#")[0]}&nId=${encodeURIComponent(nodeId)}`; const issueBody = encodeURIComponent(`\`\`\`${event.item?.title}\n${codeLineContent}\n\`\`\`\n#\n${commentData}\n#\n[Created from ApiView comment](${apiViewUrl})`); @@ -226,17 +235,25 @@ export class CommentThreadComponent { saveCommentAction(event: Event) { const target = event.target as Element; const replyEditorContainer = target.closest(".reply-editor-container") as Element; + let revisionIdForConversationGroup: string | null | undefined = null; + let elementIdForConversationGroup: string | null | undefined = null; + + if (this.instanceLocation === "conversations") { + revisionIdForConversationGroup = target.closest(".conversation-group-revision-id")?.getAttribute("data-conversation-group-revision-id"); + elementIdForConversationGroup = (target.closest(".conversation-group-threads")?.getElementsByClassName("conversation-group-element-id")[0] as HTMLElement).innerText; + } if (replyEditorContainer) { const replyEditor = this.editor.find(e => e.editorId === "replyEditor"); const content = replyEditor?.getEditorContent(); this.saveCommentActionEmitter.emit( { - nodeId: this.codePanelRowData!.nodeId, + nodeId: (this.instanceLocation === "conversations") ? elementIdForConversationGroup : this.codePanelRowData!.nodeId, nodeIdHashed: this.codePanelRowData!.nodeIdHashed, commentText: content, allowAnyOneToResolve: this.allowAnyOneToResolve, - associatedRowPositionInGroup: this.codePanelRowData!.associatedRowPositionInGroup + associatedRowPositionInGroup: this.codePanelRowData!.associatedRowPositionInGroup, + revisionIdForConversationGroup: revisionIdForConversationGroup } ); this.codePanelRowData!.showReplyTextBox = false; @@ -247,11 +264,12 @@ export class CommentThreadComponent { const content = replyEditor?.getEditorContent(); this.saveCommentActionEmitter.emit( { - nodeId: this.codePanelRowData!.nodeId, + nodeId: (this.instanceLocation === "conversations") ? elementIdForConversationGroup : this.codePanelRowData!.nodeId, nodeIdHashed: this.codePanelRowData!.nodeIdHashed, commentId: commentId, commentText: content, - associatedRowPositionInGroup: this.codePanelRowData!.associatedRowPositionInGroup + associatedRowPositionInGroup: this.codePanelRowData!.associatedRowPositionInGroup, + revisionIdForConversationGroup: revisionIdForConversationGroup } ); this.codePanelRowData!.comments!.find(comment => comment.id === commentId)!.isInEditMode = false; diff --git a/src/dotnet/APIView/ClientSPA/src/app/_helpers/common-helpers.ts b/src/dotnet/APIView/ClientSPA/src/app/_helpers/common-helpers.ts index b9abf5b92e3..86e6d5b908b 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_helpers/common-helpers.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/_helpers/common-helpers.ts @@ -6,6 +6,9 @@ export const SCROLL_TO_NODE_QUERY_PARAM = "nId"; export const FULL_DIFF_STYLE = "full"; export const TREE_DIFF_STYLE = "trees"; export const NODE_DIFF_STYLE = "nodes"; +export const MANUAL_ICON = "fa-solid fa-arrow-up-from-bracket"; +export const PR_ICON = "fa-solid fa-code-pull-request"; +export const AUTOMATIC_ICON = "fa-solid fa-robot"; export function getLanguageCssSafeName(language: string): string { switch (language.toLowerCase()) { @@ -29,4 +32,20 @@ export function mapLanguageAliases(languages: Iterable): string[] { result.add(language); } return Array.from(result); +} + +export function getTypeClass(type: string): string { + let result = ""; + switch (type) { + case 'manual': + result = MANUAL_ICON; + break; + case 'pullRequest': + result = PR_ICON; + break; + case 'automatic': + result = AUTOMATIC_ICON; + break; + } + return result; } \ No newline at end of file diff --git a/src/dotnet/APIView/ClientSPA/src/app/_helpers/router-helpers.ts b/src/dotnet/APIView/ClientSPA/src/app/_helpers/router-helpers.ts index 6200da84d04..10ac49c1f6f 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_helpers/router-helpers.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/_helpers/router-helpers.ts @@ -1,6 +1,7 @@ import { ActivatedRoute } from "@angular/router"; +import { SCROLL_TO_NODE_QUERY_PARAM } from "./common-helpers"; -export function getQueryParams(route: ActivatedRoute, excludedKeys: string[] = ["nId"]) { +export function getQueryParams(route: ActivatedRoute, excludedKeys: string[] = [SCROLL_TO_NODE_QUERY_PARAM]) { return route.snapshot.queryParamMap.keys.reduce((params: { [key: string]: any; }, key) => { if (!excludedKeys.includes(key)) { params[key] = route.snapshot.queryParamMap.get(key); diff --git a/src/dotnet/APIView/ClientSPA/src/app/_models/commentItemModel.ts b/src/dotnet/APIView/ClientSPA/src/app/_models/commentItemModel.ts index 5af7fe0474e..fd5a15c7139 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_models/commentItemModel.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/_models/commentItemModel.ts @@ -8,7 +8,7 @@ export enum CommentType { export class CommentItemModel { id: string = ''; reviewId: string = ''; - aPIRevisionId: string = ''; + apiRevisionId: string = ''; elementId: string = ''; sectionClass: string = ''; commentText: string = ''; @@ -28,7 +28,7 @@ export class CommentItemModel { constructor() { this.id = ''; this.reviewId = ''; - this.aPIRevisionId = ''; + this.apiRevisionId = ''; this.elementId = ''; this.sectionClass = ''; this.commentText = ''; diff --git a/src/dotnet/APIView/ClientSPA/src/app/_modules/review-page/review-page.module.ts b/src/dotnet/APIView/ClientSPA/src/app/_modules/review-page/review-page.module.ts index 3023b546676..cadbd3ecfa9 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_modules/review-page/review-page.module.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/_modules/review-page/review-page.module.ts @@ -14,14 +14,15 @@ import { MenuModule } from 'primeng/menu'; import { TimelineModule } from 'primeng/timeline'; import { SharedAppModule } from '../shared/shared-app.module'; import { ButtonModule } from 'primeng/button'; +import { DividerModule } from 'primeng/divider'; import { UiScrollModule } from 'ngx-ui-scroll' ; import { PageOptionsSectionComponent } from 'src/app/_components/shared/page-options-section/page-options-section.component'; import { ApiRevisionOptionsComponent } from 'src/app/_components/api-revision-options/api-revision-options.component'; import { MarkdownToHtmlPipe } from 'src/app/_pipes/markdown-to-html.pipe'; import { EditorComponent } from 'src/app/_components/shared/editor/editor.component'; -import { SelectButtonModule } from 'primeng/selectbutton'; import { ReviewPageOptionsComponent } from 'src/app/_components/review-page-options/review-page-options.component'; import { InputSwitchModule } from 'primeng/inputswitch'; +import { ConversationsComponent } from 'src/app/_components/conversations/conversations.component'; const routes: Routes = [ { path: '', component: ReviewPageComponent } @@ -34,11 +35,12 @@ const routes: Routes = [ ReviewInfoComponent, CodePanelComponent, CommentThreadComponent, + ConversationsComponent, PageOptionsSectionComponent, ReviewPageOptionsComponent, ApiRevisionOptionsComponent, MarkdownToHtmlPipe, - EditorComponent + EditorComponent, ], imports: [ SharedAppModule, @@ -52,6 +54,7 @@ const routes: Routes = [ ButtonModule, InputSwitchModule, UiScrollModule, + DividerModule, RouterModule.forChild(routes), ] }) diff --git a/src/dotnet/APIView/ClientSPA/src/app/_modules/shared/shared-app.module.ts b/src/dotnet/APIView/ClientSPA/src/app/_modules/shared/shared-app.module.ts index 971a28d4928..2474e4426a8 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/_modules/shared/shared-app.module.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/_modules/shared/shared-app.module.ts @@ -20,6 +20,7 @@ import { SelectButtonModule } from 'primeng/selectbutton'; import { FileUploadModule } from 'primeng/fileupload'; import { InputTextModule } from 'primeng/inputtext'; import { MessagesModule } from 'primeng/messages'; +import { BadgeModule } from 'primeng/badge'; @NgModule({ @@ -38,6 +39,7 @@ import { MessagesModule } from 'primeng/messages'; LanguageNamesPipe, LastUpdatedOnPipe, ApprovalPipe, + BadgeModule, ContextMenuModule, TableModule, ChipModule, @@ -52,9 +54,10 @@ import { MessagesModule } from 'primeng/messages'; SplitterModule, SidebarModule, TimeagoModule, - InputTextModule + InputTextModule, ], imports: [ + BadgeModule, CommonModule, ContextMenuModule, TableModule, diff --git a/src/dotnet/APIView/ClientSPA/src/app/app.module.ts b/src/dotnet/APIView/ClientSPA/src/app/app.module.ts index b69e9dcc59c..b90f72292d8 100644 --- a/src/dotnet/APIView/ClientSPA/src/app/app.module.ts +++ b/src/dotnet/APIView/ClientSPA/src/app/app.module.ts @@ -2,17 +2,14 @@ import { NgModule, APP_INITIALIZER } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; -import { ReactiveFormsModule } from '@angular/forms'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { IndexPageComponent } from './_components/index-page/index-page.component'; import { ReviewsListComponent } from './_components/reviews-list/reviews-list.component'; -import { InputTextModule } from 'primeng/inputtext'; import { TabMenuModule } from 'primeng/tabmenu'; import { ToolbarModule } from 'primeng/toolbar'; import { BadgeModule } from 'primeng/badge'; -import { FileUploadModule } from 'primeng/fileupload'; import { Observable } from 'rxjs'; import { ConfigService } from './_services/config/config.service'; import { CookieService } from 'ngx-cookie-service'; diff --git a/src/dotnet/APIView/ClientSPA/src/ng-prime-overrides.scss b/src/dotnet/APIView/ClientSPA/src/ng-prime-overrides.scss index 49c864c7826..6511967b38a 100644 --- a/src/dotnet/APIView/ClientSPA/src/ng-prime-overrides.scss +++ b/src/dotnet/APIView/ClientSPA/src/ng-prime-overrides.scss @@ -163,6 +163,10 @@ p-contextmenusub { outline-offset: 0.15rem; } +.p-divider.p-divider-horizontal:before { + border-top: 1px solid var(--border-color); +} + .p-editor-container .p-editor-toolbar { background: var(--base-bg-color); }