* APIView Conversation Page

* Show Comment Threads on Conversation Page

* Ad test for comment thread

* handle Save Comment from Conversation Page

* Save, Edit and Delete form Conversation Page

* Conversiation Page Fully Working

* Adjust lastUpdated to last updated
This commit is contained in:
Chidozie Ononiwu 2024-08-14 18:43:19 -07:00 коммит произвёл GitHub
Родитель 0b9efdb190
Коммит a62328ff6a
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
24 изменённых файлов: 569 добавлений и 61 удалений

Просмотреть файл

@ -40,7 +40,7 @@
<span *ngIf="selectedActiveAPIRevision.prNo"> {{ selectedActiveAPIRevision.prNo }}</span>
<span class="emphasis-badge info ms-2">version: {{ selectedActiveAPIRevision.version }}</span>
<i class="fas fa-check-circle text-success ms-2" *ngIf="selectedActiveAPIRevision.isApproved"></i>
<span *ngIf="selectedActiveAPIRevision.apiRevisionType !== 'Automatic'" class="ms-2">{{ selectedActiveAPIRevision.creatorBy }}</span>
<span *ngIf="selectedActiveAPIRevision.apiRevisionType !== 'Automatic'" class="ms-2">{{ selectedActiveAPIRevision.createdBy }}</span>
<span *ngIf="selectedActiveAPIRevision.isReleased" class="emphasis-badge success ms-2">released: {{ selectedActiveAPIRevision.releasedOn | timeago }}</span>
</div>
</ng-template>
@ -50,14 +50,14 @@
<span *ngIf="apiRevision.prNo"> {{ apiRevision.prNo }}</span>
<span class="emphasis-badge info ms-2">version: {{ apiRevision.version }}</span>
<i class="fas fa-check-circle text-success ms-2" *ngIf="apiRevision.isApproved"></i>
<span *ngIf="apiRevision.apiRevisionType !== 'Automatic'" class="ms-2">{{ apiRevision.creatorBy }}</span>
<span *ngIf="apiRevision.apiRevisionType !== 'Automatic'" class="ms-2">{{ apiRevision.createdBy }}</span>
<span *ngIf="apiRevision.isLatestGA" class="emphasis-badge warn small ms-2">Latest GA</span>
<span *ngIf="apiRevision.isLatestApproved" class="emphasis-badge warn small ms-2">Latest Approved</span>
<span *ngIf="apiRevision.isLatestMain" class="emphasis-badge warn small ms-2">Latest Main</span>
<span *ngIf="apiRevision.isLatestReleased" class="emphasis-badge warn small ms-2">Latest Released</span>
<div class="small">
<span class="emphasis-badge secondary">created: {{ apiRevision.createdOn | timeago }}</span>
<span class="emphasis-badge secondary ms-2">lastUpdated: {{ apiRevision | lastUpdatedOn | timeago }}</span>
<span class="emphasis-badge secondary ms-2">last updated: {{ apiRevision | lastUpdatedOn | timeago }}</span>
<span *ngIf="apiRevision.isReleased" class="emphasis-badge success ms-2">released: {{ apiRevision.releasedOn | timeago }}</span>
</div>
<div *ngIf="apiRevision.apiRevisionType === 'Manual' && apiRevision.label">{{ apiRevision.label }}</div>
@ -110,7 +110,7 @@
<span *ngIf="selectedDiffAPIRevision.prNo"> {{ selectedDiffAPIRevision.prNo }}</span>
<span class="emphasis-badge info ms-2">version: {{ selectedDiffAPIRevision.version }}</span>
<i class="fas fa-check-circle text-success ms-2" *ngIf="selectedDiffAPIRevision.isApproved"></i>
<span *ngIf="selectedDiffAPIRevision.apiRevisionType !== 'Automatic'" class="ms-2">{{ selectedDiffAPIRevision.creatorBy }}</span>
<span *ngIf="selectedDiffAPIRevision.apiRevisionType !== 'Automatic'" class="ms-2">{{ selectedDiffAPIRevision.createdBy }}</span>
<span *ngIf="selectedDiffAPIRevision.isReleased" class="emphasis-badge success ms-2">released: {{ selectedDiffAPIRevision.releasedOn | timeago }}</span>
</div>
</ng-template>
@ -120,13 +120,13 @@
<span *ngIf="apiRevision.prNo"> {{ apiRevision.prNo }}</span>
<span class="emphasis-badge info ms-2">version: {{ apiRevision.version }}</span>
<i class="fas fa-check-circle text-success ms-2" *ngIf="apiRevision.isApproved"></i>
<span *ngIf="apiRevision.apiRevisionType !== 'Automatic'" class="ms-2">{{ apiRevision.creatorBy }}</span>
<span *ngIf="apiRevision.apiRevisionType !== 'Automatic'" class="ms-2">{{ apiRevision.createdBy }}</span>
<span *ngIf="apiRevision.isLatestGA" class="emphasis-badge warn small ms-2">Latest GA</span>
<span *ngIf="apiRevision.isLatestApproved" class="emphasis-badge warn small ms-2">Latest Approved</span>
<span *ngIf="apiRevision.isLatestMain" class="emphasis-badge warn small ms-2">Latest Main</span>
<span *ngIf="apiRevision.isLatestReleased" class="emphasis-badge warn small ms-2">Latest Released</span>
<div class="small"><span class="emphasis-badge secondary">created: {{ apiRevision.createdOn | timeago }}</span>
<span class="emphasis-badge secondary ms-2">lastUpdated: {{ apiRevision | lastUpdatedOn | timeago }}</span>
<span class="emphasis-badge secondary ms-2">last updated: {{ apiRevision | lastUpdatedOn | timeago }}</span>
<span *ngIf="apiRevision.isReleased" class="emphasis-badge success ms-2">released: {{ apiRevision.releasedOn | timeago }}</span></div>
<div *ngIf="apiRevision.apiRevisionType === 'Manual' && apiRevision.label">{{ apiRevision.label }}</div>
</div>

Просмотреть файл

@ -76,6 +76,4 @@
border: 1px solid var(--alert-secondary-border-color);
}
}
}

Просмотреть файл

@ -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,

Просмотреть файл

@ -30,7 +30,7 @@ export class CodePanelComponent implements OnChanges{
@Input() showLineNumbers: boolean = true;
@Input() loadFailed : boolean = false;
@Output() hasActiveConversation : EventEmitter<boolean> = new EventEmitter<boolean>();
@Output() hasActiveConversationEmitter : EventEmitter<boolean> = new EventEmitter<boolean>();
noDiffInContentMessage : Message[] = [{ severity: 'info', icon:'bi bi-info-circle', detail: 'There is no difference between the two API revisions.' }];
isLoading: boolean = true;
@ -528,16 +528,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() {

Просмотреть файл

@ -0,0 +1,32 @@
<h4>Conversations</h4>
<p-divider />
<p *ngIf="commentThreads.size === 0">This Review has no comments</p>
<p-timeline [value]="getAPIRevisionWithComments()">
<ng-template pTemplate="marker" let-apiRevision>
<i class="bi bi-clock-history"></i>
</ng-template>
<ng-template pTemplate="content" let-apiRevision>
<div class="pb-3 conversation-group-revision-id" [attr.data-conversation-group-revision-id]="apiRevision.id">
<span>
<i class="{{ getAPIRevisionTypeClass(apiRevision) }} me-2"></i>
<span class="me-2" *ngIf="apiRevision.pullRequestNo"> {{ apiRevision.pullRequestNo }}</span>
<span class="emphasis-badge info me-2">version: {{ apiRevision.packageVersion }}</span>
<i class="fas fa-check-circle text-success me-2" *ngIf="apiRevision.isApproved"></i>
<span *ngIf="apiRevision.apiRevisionType !== 'Automatic'" class="me-2">{{ apiRevision.createdBy }}</span>
<span *ngIf="apiRevision.isReleased" class="emphasis-badge success me-2">released: {{ apiRevision.releasedOn | timeago }}</span>
<span class="emphasis-badge secondary me-2">created: {{ apiRevision.createdOn | timeago }}</span>
<span class="emphasis-badge secondary me-2">last updated: {{ apiRevision | lastUpdatedOn | timeago }}</span>
<span class="me-2" *ngIf="apiRevision.label">{{ apiRevision.label }}</span>
</span>
<div *ngFor="let commentThread of commentThreads.get(apiRevision.id); let isLast = last" class="my-2 conversation-group-threads">
<a class="small conversation-group-element-id" (click)="navigateToCommentThreadOnRevisionPage($event)">{{ commentThread!.comments[0].elementId }}</a>
<app-comment-thread [codePanelRowData]="commentThread" [instanceLocation]='"conversations"'
(saveCommentActionEmitter)="handleSaveCommentActionEmitter($event)"
(deleteCommentActionEmitter)="handleDeleteCommentActionEmitter($event)"
(commentUpvoteActionEmitter)="handleCommentUpvoteActionEmitter($event)"
(commentResolutionActionEmitter)="handleCommentResolutionActionEmitter($event)"
></app-comment-thread>
</div>
</div>
</ng-template>
</p-timeline>

Просмотреть файл

@ -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;
}
}

Просмотреть файл

@ -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<ConversationsComponent>;
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);
});
});
});

Просмотреть файл

@ -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<string> = new EventEmitter<string>();
@Output() numberOfActiveThreadsEmitter : EventEmitter<number> = new EventEmitter<number>();
commentThreads: Map<string, CodePanelRowData[]> = new Map<string, CodePanelRowData[]>();
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<string, CodePanelRowData[]>();
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();
}
});
}
}
}

Просмотреть файл

@ -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';

Просмотреть файл

@ -9,7 +9,14 @@
(pageOptionsEmitter)="handlePageOptionsEmitter($event)"></app-review-info>
<div class="mt-2" style="display: flex;">
<div>
<p-menu [model]="sideMenu"></p-menu>
<p-menu [model]="sideMenu" class="side-menu">
<ng-template pTemplate="item" let-item>
<a pRipple class="flex align-items-center p-menuitem-link">
<span [class]="item.icon"></span>
<p-badge *ngIf="item.badge" class="ml-auto" [value]="item.badge" severity="danger" />
</a>
</ng-template>
</p-menu>
</div>
<div style="flex-grow: 1; min-width: 0; margin-left: 7px;">
<p-splitter
@ -34,7 +41,7 @@
[loadFailed]="loadFailed"
[showLineNumbers]="showLineNumbers" [scrollToNodeIdHashed]="scrollToNodeIdHashed"
[scrollToNodeId]="scrollToNodeId"
(hasActiveConversation)="handleHasActiveConversationEmitter($event)"></app-code-panel>
(hasActiveConversationEmitter)="handleHasActiveConversationEmitter($event)"></app-code-panel>
</div>
</ng-template>
<ng-template pTemplate>
@ -68,9 +75,18 @@
</div>
</div>
</div>
<p-sidebar [(visible)]="revisionSideBarVisible" position="right" [modal]="true" styleClass="revisions-sidebar">
<p-sidebar [(visible)]="revisionSidePanel!" position="right" [modal]="true" styleClass="revisions-sidebar">
<app-revisions-list
[review]="review"
[revisionSideBarVisible]="revisionSideBarVisible"></app-revisions-list>
[revisionSidePanel]="revisionSidePanel!"></app-revisions-list>
</p-sidebar>
<p-sidebar [(visible)]="conversationSidePanel!" position="right" [modal]="true" styleClass="conversation-sidebar">
<app-conversations
[apiRevisions]="apiRevisions"
[comments]="comments"
[review]="review"
[activeApiRevisionId]="activeApiRevisionId"
(scrollToNodeEmitter)="handleScrollToNodeEmitter($event)"
(numberOfActiveThreadsEmitter)="handleNumberOfActiveThreadsEmitter($event)"></app-conversations >
</p-sidebar>
<app-footer></app-footer>

Просмотреть файл

@ -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;
}
}

Просмотреть файл

@ -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({
@ -413,6 +435,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') {

Просмотреть файл

@ -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;
}

Просмотреть файл

@ -1,7 +1,7 @@
<div *ngIf="codePanelRowData!.isResolvedCommentThread" class="resolution-info">
<small class="d-flex align-items-center">This thread is marked resolved by<b>&nbsp;{{ threadResolvedBy }}&nbsp;</b><span class="resolved-toggler" (click)="toggleResolvedCommentExpandState()"><i class="bi {{threadResolvedStateToggleIcon}}"></i>&nbsp;{{threadResolvedStateToggleText}}&nbsp;Resolved</span></small>
</div>
<div *ngIf="!codePanelRowData!.isResolvedCommentThread || threadResolvedAndExpanded" class="border rounded {{spacingBasedOnResolvedState}} py-2">
<div *ngIf="!codePanelRowData!.isResolvedCommentThread || threadResolvedAndExpanded" class="border rounded {{spacingBasedOnResolvedState}} py-2 comment-thread-container">
<p-timeline [value]="codePanelRowData!.comments">
<ng-template pTemplate="marker" let-comment>
<img [alt]="comment.createdBy" src="https://github.com/{{ comment.createdBy }}.png?size=40" width="40" height="40" class="user-avartar"/>

Просмотреть файл

@ -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);

Просмотреть файл

@ -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');
});
});
});

Просмотреть файл

@ -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<any> = new EventEmitter<any>();
@Output() saveCommentActionEmitter : EventEmitter<any> = new EventEmitter<any>();
@Output() deleteCommentActionEmitter : EventEmitter<any> = new EventEmitter<any>();
@ -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;

Просмотреть файл

@ -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>): 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;
}

Просмотреть файл

@ -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);

Просмотреть файл

@ -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 = '';

Просмотреть файл

@ -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),
]
})

Просмотреть файл

@ -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,

Просмотреть файл

@ -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';

Просмотреть файл

@ -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);
}