mirror of https://github.com/Squidex/squidex.git
34 changed files with 686 additions and 238 deletions
@ -0,0 +1 @@ |
|||
<sqx-comments [commentsId]="commentsId"></sqx-comments> |
|||
@ -0,0 +1,2 @@ |
|||
@import '_vars'; |
|||
@import '_mixins'; |
|||
@ -0,0 +1,30 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { Component, OnInit } from '@angular/core'; |
|||
import { ActivatedRoute } from '@angular/router'; |
|||
|
|||
import { allParams } from '@app/shared'; |
|||
|
|||
@Component({ |
|||
selector: 'sqx-comments-page', |
|||
styleUrls: ['./comments-page.component.scss'], |
|||
templateUrl: './comments-page.component.html' |
|||
}) |
|||
export class CommentsPageComponent implements OnInit { |
|||
public commentsId: string; |
|||
|
|||
constructor( |
|||
private readonly route: ActivatedRoute |
|||
) { |
|||
} |
|||
|
|||
public ngOnInit() { |
|||
this.commentsId = allParams(this.route)['contentId']; |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,19 @@ |
|||
<div class="comment row no-gutters"> |
|||
<div class="col col-auto"> |
|||
<img class="user-picture" [attr.title]="comment.user | sqxUserNameRef:null" [attr.src]="comment.user | sqxUserPictureRef" /> |
|||
</div> |
|||
<div class="col pl-2"> |
|||
<div class="comment-message"> |
|||
<div class="user-row"> |
|||
<div class="user-ref">{{comment.user | sqxUserNameRef:null}}</div> |
|||
|
|||
<button *ngIf="comment.user === userId" type="button" class="btn btn-sm btn-link btn-danger item-remove" (click)="deleting.emit()!"> |
|||
<i class="icon-bin2"></i> |
|||
</button> |
|||
</div> |
|||
|
|||
<div>{{comment.text}}</div> |
|||
<div class="comment-created text-muted">{{comment.time | sqxFromNow}}</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,43 @@ |
|||
@import '_vars'; |
|||
@import '_mixins'; |
|||
|
|||
.user-ref { |
|||
font-weight: bold; |
|||
} |
|||
|
|||
.item-remove { |
|||
@include absolute(-5px, -15px, auto, auto); |
|||
display: none; |
|||
} |
|||
|
|||
.user-row { |
|||
& { |
|||
position: relative; |
|||
} |
|||
|
|||
&:hover { |
|||
.item-remove { |
|||
display: block; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.comment { |
|||
& { |
|||
font-size: .9rem; |
|||
font-weight: normal; |
|||
margin-bottom: .75rem; |
|||
} |
|||
|
|||
&-message { |
|||
margin-bottom: .375rem; |
|||
} |
|||
|
|||
&-created { |
|||
font-size: .75rem; |
|||
} |
|||
} |
|||
|
|||
.text-muted { |
|||
color: $color-history; |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; |
|||
import { FormBuilder } from '@angular/forms'; |
|||
|
|||
import { CommentDto, UpsertCommentForm } from '@app/shared/internal'; |
|||
|
|||
@Component({ |
|||
selector: 'sqx-comment', |
|||
styleUrls: ['./comment.component.scss'], |
|||
templateUrl: './comment.component.html', |
|||
changeDetection: ChangeDetectionStrategy.OnPush |
|||
}) |
|||
export class CommentComponent { |
|||
public editForm = new UpsertCommentForm(this.formBuilder); |
|||
|
|||
@Input() |
|||
public comment: CommentDto; |
|||
|
|||
@Input() |
|||
public userId: string; |
|||
|
|||
@Output() |
|||
public deleting = new EventEmitter(); |
|||
|
|||
@Output() |
|||
public updated = new EventEmitter<string>(); |
|||
|
|||
constructor( |
|||
private readonly formBuilder: FormBuilder |
|||
) { |
|||
} |
|||
} |
|||
@ -0,0 +1,26 @@ |
|||
<sqx-panel desiredWidth="20rem" isBlank="true" [isLazyLoaded]="false" contentClass="grid"> |
|||
<ng-container title> |
|||
Comments |
|||
</ng-container> |
|||
|
|||
<ng-container content> |
|||
<div class="grid-content" #scrollMe [scrollTop]="scrollMe.scrollHeight"> |
|||
<sqx-comment *ngFor="let comment of state.comments | async; trackBy: trackByComment" |
|||
[comment]="comment" |
|||
[userId]="userId" |
|||
(updated)="update(comment, $event)" |
|||
(deleting)="delete(comment)"> |
|||
</sqx-comment> |
|||
</div> |
|||
|
|||
<div class="grid-footer"> |
|||
<form [formGroup]="commentForm.form" (submit)="comment()"> |
|||
<input class="form-control" name="text" formControlName="text" placeholder="Create a comment" /> |
|||
</form> |
|||
</div> |
|||
</ng-container> |
|||
</sqx-panel> |
|||
|
|||
|
|||
|
|||
|
|||
@ -0,0 +1,11 @@ |
|||
@import '_vars'; |
|||
@import '_mixins'; |
|||
|
|||
.grid-footer { |
|||
border-top-width: 1px; |
|||
} |
|||
|
|||
.grid-body, |
|||
.grid-footer { |
|||
padding: 1rem; |
|||
} |
|||
@ -0,0 +1,84 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { Component, Input, OnDestroy, OnInit } from '@angular/core'; |
|||
import { FormBuilder } from '@angular/forms'; |
|||
import { Subscription, timer } from 'rxjs'; |
|||
import { onErrorResumeNext, switchMap } from 'rxjs/operators'; |
|||
|
|||
import { |
|||
AppsState, |
|||
AuthService, |
|||
CommentDto, |
|||
CommentsService, |
|||
CommentsState, |
|||
DialogService, |
|||
fadeAnimation, |
|||
UpsertCommentForm |
|||
} from '@app/shared/internal'; |
|||
|
|||
@Component({ |
|||
selector: 'sqx-comments', |
|||
styleUrls: ['./comments.component.scss'], |
|||
templateUrl: './comments.component.html', |
|||
animations: [ |
|||
fadeAnimation |
|||
] |
|||
}) |
|||
export class CommentsComponent implements OnDestroy, OnInit { |
|||
private timer: Subscription; |
|||
|
|||
public state: CommentsState; |
|||
|
|||
public userId: string; |
|||
|
|||
public commentForm = new UpsertCommentForm(this.formBuilder); |
|||
|
|||
@Input() |
|||
public commentsId: string; |
|||
|
|||
constructor(authService: AuthService, |
|||
private readonly appsState: AppsState, |
|||
private readonly commentsService: CommentsService, |
|||
private readonly dialogs: DialogService, |
|||
private readonly formBuilder: FormBuilder |
|||
) { |
|||
this.userId = authService.user!.token; |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
this.timer.unsubscribe(); |
|||
} |
|||
|
|||
public ngOnInit() { |
|||
this.state = new CommentsState(this.appsState, this.commentsId, this.commentsService, this.dialogs); |
|||
|
|||
this.timer = timer(0, 4000).pipe(switchMap(() => this.state.load()), onErrorResumeNext()).subscribe(); |
|||
} |
|||
|
|||
public delete(comment: CommentDto) { |
|||
this.state.delete(comment.id).pipe(onErrorResumeNext()).subscribe(); |
|||
} |
|||
|
|||
public update(comment: CommentDto, text: string) { |
|||
this.state.update(comment.id, text).pipe(onErrorResumeNext()).subscribe(); |
|||
} |
|||
|
|||
public comment() { |
|||
const value = this.commentForm.submit(); |
|||
|
|||
if (value) { |
|||
this.state.create(value.text).pipe(onErrorResumeNext()).subscribe(); |
|||
|
|||
this.commentForm.submitCompleted({}); |
|||
} |
|||
} |
|||
|
|||
public trackByComment(index: number, comment: CommentDto) { |
|||
return comment.id; |
|||
} |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; |
|||
|
|||
import { Form } from '@app/framework'; |
|||
|
|||
export class UpsertCommentForm extends Form<FormGroup> { |
|||
constructor(formBuilder: FormBuilder) { |
|||
super(formBuilder.group({ |
|||
text: ['', |
|||
[ |
|||
Validators.required |
|||
] |
|||
] |
|||
})); |
|||
} |
|||
} |
|||
@ -0,0 +1,119 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { of } from 'rxjs'; |
|||
import { IMock, It, Mock, Times } from 'typemoq'; |
|||
|
|||
import { |
|||
AppsState, |
|||
CommentDto, |
|||
CommentsDto, |
|||
CommentsService, |
|||
CommentsState, |
|||
DateTime, |
|||
DialogService, |
|||
ImmutableArray, |
|||
UpsertCommentDto, |
|||
Version |
|||
} from '@app/shared'; |
|||
|
|||
describe('CommentsState', () => { |
|||
const app = 'my-app'; |
|||
const commentsId = 'my-comments'; |
|||
const now = DateTime.today(); |
|||
const user = 'not-me'; |
|||
|
|||
const oldComments = new CommentsDto([ |
|||
new CommentDto('1', now, 'text1', user), |
|||
new CommentDto('2', now, 'text2', user) |
|||
], [], [], new Version('1')); |
|||
|
|||
let dialogs: IMock<DialogService>; |
|||
let appsState: IMock<AppsState>; |
|||
let commentsService: IMock<CommentsService>; |
|||
let commentsState: CommentsState; |
|||
|
|||
beforeEach(() => { |
|||
dialogs = Mock.ofType<DialogService>(); |
|||
|
|||
appsState = Mock.ofType<AppsState>(); |
|||
|
|||
appsState.setup(x => x.appName) |
|||
.returns(() => app); |
|||
|
|||
commentsService = Mock.ofType<CommentsService>(); |
|||
|
|||
commentsService.setup(x => x.getComments(app, commentsId, new Version(''))) |
|||
.returns(() => of(oldComments)); |
|||
|
|||
commentsState = new CommentsState(appsState.object, commentsId, commentsService.object, dialogs.object); |
|||
commentsState.load().subscribe(); |
|||
}); |
|||
|
|||
it('should load and merge comments', () => { |
|||
const newComments = new CommentsDto([ |
|||
new CommentDto('3', now, 'text3', user) |
|||
], [ |
|||
new CommentDto('2', now, 'text2_2', user) |
|||
], ['1'], new Version('2')); |
|||
|
|||
commentsService.setup(x => x.getComments(app, commentsId, new Version('1'))) |
|||
.returns(() => of(newComments)); |
|||
|
|||
commentsState.load().subscribe(); |
|||
|
|||
expect(commentsState.snapshot.isLoaded).toBeTruthy(); |
|||
expect(commentsState.snapshot.comments).toEqual(ImmutableArray.of([ |
|||
new CommentDto('2', now, 'text2_2', user), |
|||
new CommentDto('3', now, 'text3', user) |
|||
])); |
|||
|
|||
commentsService.verify(x => x.getComments(app, commentsId, It.isAny()), Times.exactly(2)); |
|||
}); |
|||
|
|||
it('should add comment to snapshot when created', () => { |
|||
const newComment = new CommentDto('3', now, 'text3', user); |
|||
|
|||
commentsService.setup(x => x.postComment(app, commentsId, new UpsertCommentDto('text3'))) |
|||
.returns(() => of(newComment)); |
|||
|
|||
commentsState.create('text3').subscribe(); |
|||
|
|||
expect(commentsState.snapshot.comments).toEqual(ImmutableArray.of([ |
|||
new CommentDto('1', now, 'text1', user), |
|||
new CommentDto('2', now, 'text2', user), |
|||
new CommentDto('3', now, 'text3', user) |
|||
])); |
|||
}); |
|||
|
|||
it('should update properties when updated', () => { |
|||
commentsService.setup(x => x.putComment(app, commentsId, '2', new UpsertCommentDto('text2_2'))) |
|||
.returns(() => of({})); |
|||
|
|||
commentsState.update('2', 'text2_2', now).subscribe(); |
|||
|
|||
expect(commentsState.snapshot.comments).toEqual(ImmutableArray.of([ |
|||
new CommentDto('1', now, 'text1', user), |
|||
new CommentDto('2', now, 'text2_2', user) |
|||
])); |
|||
|
|||
commentsService.verify(x => x.putComment(app, commentsId, '2', new UpsertCommentDto('text2_2')), Times.once()); |
|||
}); |
|||
|
|||
it('should remove comment from snapshot when deleted', () => { |
|||
commentsService.setup(x => x.deleteComment(app, commentsId, '2')) |
|||
.returns(() => of({})); |
|||
|
|||
commentsState.delete('2').subscribe(); |
|||
|
|||
expect(commentsState.snapshot.comments).toEqual(ImmutableArray.of([ |
|||
new CommentDto('1', now, 'text1', user) |
|||
])); |
|||
|
|||
commentsService.verify(x => x.deleteComment(app, commentsId, '2'), Times.once()); |
|||
}); |
|||
}); |
|||
Binary file not shown.
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Loading…
Reference in new issue