mirror of https://github.com/Squidex/squidex.git
committed by
GitHub
43 changed files with 973 additions and 282 deletions
@ -1,31 +1,30 @@ |
|||
<ng-container *ngIf="commentsArray | async; let comments"> |
|||
<ng-container *ngIf="mentionUsers | async; let users"> |
|||
<div class="comments-list" #scrollContainer> |
|||
<div (sqxResized)="scrollDown()"> |
|||
<sqx-comment *ngFor="let comment of comments.itemsChanges | async; trackBy: trackByComment; let i = index" |
|||
[comment]="comment" |
|||
[commentIndex]="i" |
|||
[comments]="comments" |
|||
[mentionUsers]="users" |
|||
canEdit="true" |
|||
canFollow="false" |
|||
[userToken]="userToken"> |
|||
</sqx-comment> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="comments-footer"> |
|||
<form [formGroup]="commentForm.form" (ngSubmit)="comment(comments)"> |
|||
<input class="form-control" name="text" formControlName="text" placeholder="{{ 'comments.create' | sqxTranslate }}" |
|||
[mention]="$any(users)" |
|||
[mentionConfig]="mentionConfig" |
|||
autocomplete="off" |
|||
autocorrect="off" |
|||
autocapitalize="off"> |
|||
</form> |
|||
</div> |
|||
</ng-container> |
|||
</ng-container> |
|||
<ng-container *ngIf="mentionUsers | async; let users"> |
|||
<div class="comments-header"> |
|||
<form [formGroup]="commentForm.form" (ngSubmit)="comment()"> |
|||
<input class="form-control" name="text" formControlName="text" placeholder="{{ 'comments.create' | sqxTranslate }}" #input |
|||
[mention]="$any(users)" |
|||
[mentionConfig]="mentionConfig" |
|||
autocomplete="off" |
|||
autocorrect="off" |
|||
autocapitalize="off" |
|||
(blur)="blurComment()"> |
|||
</form> |
|||
</div> |
|||
|
|||
<div class="comments-list" #scrollContainer> |
|||
<sqx-comment *ngFor="let item of commentsItems | async; trackBy: trackByComment;" |
|||
canAnswer="true" |
|||
canEdit="true" |
|||
canFollow="false" |
|||
[commentItem]="item" |
|||
[comments]="commentsState" |
|||
[currenUrl]="router.url" |
|||
[mentionConfig]="mentionConfig" |
|||
[mentionUsers]="users" |
|||
[scrollContainer]="'.comments-list'" |
|||
[userToken]="commentUser"> |
|||
</sqx-comment> |
|||
</div> |
|||
</ng-container> |
|||
|
|||
|
|||
|
|||
@ -0,0 +1,242 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { of } from 'rxjs'; |
|||
import { IMock, It, Mock } from 'typemoq'; |
|||
import * as Y from 'yjs'; |
|||
import { CollaborationService, SharedArray } from '../internal'; |
|||
import { Comment, CommentItem, CommentsState } from './comments.state'; |
|||
|
|||
describe('CommentsState', () => { |
|||
let collaborationSevice: IMock<CollaborationService>; |
|||
let commentsState: CommentsState; |
|||
let sharedArray: SharedArray<Comment>; |
|||
|
|||
beforeEach(() => { |
|||
const yDoc = new Y.Doc(); |
|||
const yArray = yDoc.getArray<Comment>(); |
|||
|
|||
sharedArray = new SharedArray<Comment>(yDoc, yArray); |
|||
|
|||
collaborationSevice = Mock.ofType<CollaborationService>(); |
|||
collaborationSevice.setup(x => x.getArray<Comment>(It.isAnyString())) |
|||
.returns(() => of(sharedArray)); |
|||
|
|||
commentsState = new CommentsState(collaborationSevice.object); |
|||
}); |
|||
|
|||
it('should get total items', () => { |
|||
sharedArray.add({} as any); |
|||
sharedArray.add({} as any); |
|||
|
|||
let items: ReadonlyArray<Comment> = []; |
|||
commentsState.itemsChanges.subscribe(result => { |
|||
items = result; |
|||
}); |
|||
|
|||
expect(items.length).toEqual(2); |
|||
}); |
|||
|
|||
it('should get unread items count', () => { |
|||
sharedArray.add({} as any); |
|||
sharedArray.add({} as any); |
|||
sharedArray.add({ isRead: true } as any); |
|||
|
|||
let unreadCount = 0; |
|||
commentsState.unreadCountChanges.subscribe(result => { |
|||
unreadCount = result; |
|||
}); |
|||
|
|||
expect(unreadCount).toEqual(2); |
|||
}); |
|||
|
|||
it('should add comment', () => { |
|||
const comment = { user: 'me', text: 'My Text', url: '/url/to/comment' }; |
|||
|
|||
let items: ReadonlyArray<Comment> = []; |
|||
commentsState.itemsChanges.subscribe(result => { |
|||
items = result; |
|||
}); |
|||
|
|||
commentsState.create(comment.user, comment.text, comment.url); |
|||
|
|||
expect(items.length).toEqual(1); |
|||
expect(items[0].text).toEqual(comment.text); |
|||
expect(items[0].url).toEqual(comment.url); |
|||
expect(items[0].user).toEqual(comment.user); |
|||
expect(items[0].id).toBeDefined(); |
|||
expect(items[0].time).toBeDefined(); |
|||
}); |
|||
|
|||
it('should update comment', () => { |
|||
const comment = { user: 'me', text: 'My Text', url: '/url/to/comment' }; |
|||
|
|||
sharedArray.add({} as any); |
|||
|
|||
let items: ReadonlyArray<Comment> = []; |
|||
commentsState.itemsChanges.subscribe(result => { |
|||
items = result; |
|||
}); |
|||
|
|||
commentsState.update(0, comment); |
|||
|
|||
expect(items.length).toEqual(1); |
|||
expect(items[0].text).toEqual(comment.text); |
|||
expect(items[0].url).toEqual(comment.url); |
|||
expect(items[0].user).toEqual(comment.user); |
|||
}); |
|||
|
|||
it('should prune comments', () => { |
|||
sharedArray.add({ id: '1' } as any); |
|||
sharedArray.add({ id: '2' } as any); |
|||
sharedArray.add({ id: '3' } as any); |
|||
sharedArray.add({ id: '4' } as any); |
|||
|
|||
let items: ReadonlyArray<Comment> = []; |
|||
commentsState.itemsChanges.subscribe(result => { |
|||
items = result; |
|||
}); |
|||
|
|||
commentsState.prune(2); |
|||
|
|||
expect(items.map(x => x.id)).toEqual(['3', '4']); |
|||
}); |
|||
|
|||
it('should not prune comments if max not reached', () => { |
|||
sharedArray.add({ id: '1' } as any); |
|||
sharedArray.add({ id: '2' } as any); |
|||
sharedArray.add({ id: '3' } as any); |
|||
sharedArray.add({ id: '4' } as any); |
|||
|
|||
let items: ReadonlyArray<Comment> = []; |
|||
commentsState.itemsChanges.subscribe(result => { |
|||
items = result; |
|||
}); |
|||
|
|||
commentsState.prune(4); |
|||
|
|||
expect(items.map(x => x.id)).toEqual(['1', '2', '3', '4']); |
|||
}); |
|||
|
|||
it('should mark comments as read', () => { |
|||
sharedArray.add({ id: '1' } as any); |
|||
sharedArray.add({ id: '2' } as any); |
|||
sharedArray.add({ id: '3' } as any); |
|||
sharedArray.add({ id: '4' } as any); |
|||
|
|||
let items: ReadonlyArray<Comment> = []; |
|||
commentsState.itemsChanges.subscribe(result => { |
|||
items = result; |
|||
}); |
|||
|
|||
commentsState.markRead(); |
|||
|
|||
expect(items.map(x => x.isRead)).toEqual([true, true, true, true]); |
|||
}); |
|||
|
|||
it('should update annotations', () => { |
|||
sharedArray.add({ id: '1', editorId: '1', from: 11, to: 12 } as any); |
|||
sharedArray.add({ id: '2', editorId: '2' } as any); |
|||
|
|||
let items: ReadonlyArray<Comment> = []; |
|||
commentsState.itemsChanges.subscribe(result => { |
|||
items = result; |
|||
}); |
|||
|
|||
commentsState.updateAnnotations('2', [{ id: '2', from: 13, to: 52 }]); |
|||
|
|||
expect(items).toEqual([ |
|||
{ id: '1', editorId: '1', from: 11, to: 12 } as any, |
|||
{ id: '2', editorId: '2', from: 13, to: 52 } as any, |
|||
]); |
|||
}); |
|||
|
|||
it('should unset annotations', () => { |
|||
sharedArray.add({ editorId: '1', id: '1', from: 13, to: 52 } as any); |
|||
sharedArray.add({ editorId: '1', id: '2' } as any); |
|||
|
|||
let items: ReadonlyArray<Comment> = []; |
|||
commentsState.itemsChanges.subscribe(result => { |
|||
items = result; |
|||
}); |
|||
|
|||
commentsState.updateAnnotations('1', [{ id: '2', from: 13, to: 52 }]); |
|||
|
|||
expect(items).toEqual([ |
|||
{ id: '1' } as any, |
|||
{ id: '2', editorId: '1', from: 13, to: 52 } as any, |
|||
]); |
|||
}); |
|||
|
|||
it('should get annotations', () => { |
|||
sharedArray.add({ editorId: '1', id: '1', from: 11, to: 12 } as any); |
|||
sharedArray.add({ editorId: '2', id: '2', from: 21, to: 22 } as any); |
|||
|
|||
let items: ReadonlyArray<Annotation> = []; |
|||
commentsState.getAnnotations('2').subscribe(result => { |
|||
items = result; |
|||
}); |
|||
|
|||
expect(items).toEqual([ |
|||
{ editorId: '2', id: '2', from: 21, to: 22 } as any, |
|||
]); |
|||
}); |
|||
|
|||
it('should get empty annotations', () => { |
|||
sharedArray.add({ editorId: '1', id: '1', from: 11, to: 12 } as any); |
|||
sharedArray.add({ editorId: '2', id: '2', from: 21, to: 22 } as any); |
|||
|
|||
let items: ReadonlyArray<Annotation> = []; |
|||
commentsState.getAnnotations(undefined).subscribe(result => { |
|||
items = result; |
|||
}); |
|||
|
|||
expect(items).toEqual([]); |
|||
}); |
|||
|
|||
it('should get grouped comments', () => { |
|||
const selection = of<ReadonlyArray<string>>(['1']); |
|||
|
|||
sharedArray.add({ id: '1' } as any); |
|||
sharedArray.add({ id: '2' } as any); |
|||
sharedArray.add({ id: '3', replyTo: '5' } as any); |
|||
sharedArray.add({ id: '4', replyTo: '2' } as any); |
|||
|
|||
let items: ReadonlyArray<CommentItem> = []; |
|||
commentsState.getGroupedComments(selection).subscribe(result => { |
|||
items = result; |
|||
}); |
|||
|
|||
expect(items).toEqual([ |
|||
{ |
|||
index: 0, |
|||
comment: { |
|||
id: '1', |
|||
} as any as Comment, |
|||
isSelected: true, |
|||
replies: [], |
|||
}, { |
|||
index: 1, |
|||
comment: { |
|||
id: '2', |
|||
} as any as Comment, |
|||
isSelected: false, |
|||
replies: [ |
|||
{ |
|||
index: 3, |
|||
comment: { |
|||
id: '4', |
|||
replyTo: '2', |
|||
} as any as Comment, |
|||
replies: [], |
|||
isSelected: false, |
|||
}, |
|||
], |
|||
}, |
|||
]); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,201 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { Injectable, OnDestroy } from '@angular/core'; |
|||
import { BehaviorSubject, combineLatest, distinctUntilChanged, map, Observable, of, Subscription, switchMap } from 'rxjs'; |
|||
import { DateTime, MathHelper, Types } from '@app/framework'; |
|||
import { CollaborationService, SharedArray } from '../services/collaboration.service'; |
|||
|
|||
export interface Comment { |
|||
// The timestamp when the comment was created.
|
|||
time: string; |
|||
|
|||
// The actual text.
|
|||
text: string; |
|||
|
|||
// The user token.
|
|||
user: string; |
|||
|
|||
// The url.
|
|||
url?: string; |
|||
|
|||
// The reply.
|
|||
replyTo?: string; |
|||
|
|||
// The ID of the comment.
|
|||
id?: string; |
|||
|
|||
// Indicates whether this comment has been read.
|
|||
isRead?: boolean; |
|||
|
|||
// The editor ID.
|
|||
editorId?: string; |
|||
|
|||
// The selection range.
|
|||
from?: number; |
|||
|
|||
// The selection number.
|
|||
to?: number; |
|||
} |
|||
|
|||
@Injectable() |
|||
export class CommentsState implements OnDestroy { |
|||
private readonly subscription: Subscription; |
|||
private readonly comments = new BehaviorSubject<SharedArray<Comment>>(null!); |
|||
|
|||
public get itemsChanges() { |
|||
return this.comments.pipe(switchMap(x => x.itemsChanges)); |
|||
} |
|||
|
|||
public get unreadCountChanges() { |
|||
return this.itemsChanges.pipe(map(x => x.filter(c => !c.isRead).length)); |
|||
} |
|||
|
|||
constructor( |
|||
collaboration: CollaborationService, |
|||
) { |
|||
this.subscription = |
|||
collaboration.getArray<Comment>('stream') |
|||
.subscribe(comments => { |
|||
this.comments.next(comments); |
|||
}); |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
this.subscription.unsubscribe(); |
|||
} |
|||
|
|||
public create(user: string, text: string, url: string, optional?: Pick<Comment, 'editorId' | 'from' | 'to' | 'replyTo'>) { |
|||
this.updateInternal(comments => { |
|||
const comment = { user, text, url, id: MathHelper.guid(), time: DateTime.now().toISOString(), ...optional || {} }; |
|||
|
|||
comments.add(comment); |
|||
}); |
|||
} |
|||
|
|||
public update(index: number, update: Partial<Comment>) { |
|||
this.updateInternal(comments => { |
|||
const comment = comments.items[index]; |
|||
|
|||
if (comment) { |
|||
comments.set(index, { ...comment, ...update }); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public prune(maxCount: number) { |
|||
this.updateInternal(comments => { |
|||
const toDelete = comments.items.length - maxCount; |
|||
|
|||
if (toDelete > 0) { |
|||
comments.remove(0, toDelete); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public remove(index: number) { |
|||
this.updateInternal(comments => { |
|||
comments.remove(index); |
|||
}); |
|||
} |
|||
|
|||
public markRead() { |
|||
this.updateInternal(comments => { |
|||
comments.items.filter(x => !x.isRead).map((comment, index) => { |
|||
comments.set(index, { ...comment, isRead: true }); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
public updateAnnotations(editorId: string, updates: ReadonlyArray<Annotation>) { |
|||
this.updateInternal(comments => { |
|||
comments.items.map((comment, index) => { |
|||
if (!comment.id || comment.editorId !== editorId) { |
|||
return; |
|||
} |
|||
|
|||
const update = updates.find(x => x.id === comment.id); |
|||
if (update) { |
|||
const newComment = { ...comment, ...update }; |
|||
|
|||
comments.set(index, newComment); |
|||
} else { |
|||
const newComment = { ...comment }; |
|||
|
|||
delete newComment.to; |
|||
delete newComment.from; |
|||
delete newComment.editorId; |
|||
|
|||
comments.set(index, newComment); |
|||
} |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
private updateInternal(update: (comments: SharedArray<Comment>) => void) { |
|||
const comments = this.comments.value; |
|||
|
|||
if (!comments.doc) { |
|||
return; |
|||
} |
|||
|
|||
comments.doc.transact(() => { |
|||
update(comments); |
|||
}); |
|||
|
|||
} |
|||
|
|||
public getAnnotations(editorId: string | undefined | null) { |
|||
if (!editorId) { |
|||
return of([]); |
|||
} |
|||
|
|||
return this.itemsChanges.pipe( |
|||
map(c => { |
|||
const annotations: Annotation[] = []; |
|||
|
|||
for (const comment of c) { |
|||
if (comment.editorId && |
|||
comment.editorId === editorId && |
|||
comment.from && |
|||
comment.to && |
|||
comment.id) { |
|||
annotations.push(comment as never); |
|||
} |
|||
} |
|||
|
|||
return annotations; |
|||
}), |
|||
distinctUntilChanged(Types.equals)); |
|||
} |
|||
|
|||
public getGroupedComments(selection: Observable<ReadonlyArray<string>>) { |
|||
return combineLatest([this.itemsChanges, selection], (comments, selection) => { |
|||
const result: CommentItem[] = []; |
|||
|
|||
comments.forEach((comment, index) => { |
|||
const isSelected = !!comment.id && selection.indexOf(comment.id) >= 0; |
|||
|
|||
const item = { comment, index, isSelected, replies: [] }; |
|||
|
|||
if (comment.replyTo) { |
|||
const replied = result.find(x => x.comment.id === comment.replyTo); |
|||
|
|||
if (replied) { |
|||
replied.replies.push(item); |
|||
} |
|||
} else { |
|||
result.push(item); |
|||
} |
|||
}); |
|||
|
|||
return result; |
|||
}); |
|||
} |
|||
} |
|||
|
|||
export type CommentItem = { comment: Comment; index: number; isSelected: boolean; replies: CommentItem[] }; |
|||
@ -1,23 +0,0 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
export interface Comment { |
|||
// The timestamp when the comment was created.
|
|||
time: string; |
|||
|
|||
// The actual text.
|
|||
text: string; |
|||
|
|||
// The user token.
|
|||
user: string; |
|||
|
|||
// The url.
|
|||
url?: string; |
|||
|
|||
// Indicates whether this has been read.
|
|||
isRead?: boolean; |
|||
} |
|||
Loading…
Reference in new issue