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"> |
||||
<ng-container *ngIf="mentionUsers | async; let users"> |
<div class="comments-header"> |
||||
<div class="comments-list" #scrollContainer> |
<form [formGroup]="commentForm.form" (ngSubmit)="comment()"> |
||||
<div (sqxResized)="scrollDown()"> |
<input class="form-control" name="text" formControlName="text" placeholder="{{ 'comments.create' | sqxTranslate }}" #input |
||||
<sqx-comment *ngFor="let comment of comments.itemsChanges | async; trackBy: trackByComment; let i = index" |
[mention]="$any(users)" |
||||
[comment]="comment" |
[mentionConfig]="mentionConfig" |
||||
[commentIndex]="i" |
autocomplete="off" |
||||
[comments]="comments" |
autocorrect="off" |
||||
[mentionUsers]="users" |
autocapitalize="off" |
||||
canEdit="true" |
(blur)="blurComment()"> |
||||
canFollow="false" |
</form> |
||||
[userToken]="userToken"> |
</div> |
||||
</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> |
|
||||
|
|
||||
|
<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