diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index 1738e4a26..428094564 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -381,6 +381,7 @@ "common.remember": "Don't ask again", "common.rename": "Rename", "common.renameTag": "Rename Tag", + "common.reply": "Reply", "common.repository": "Repository", "common.requiredHint": "required", "common.reset": "Reset", diff --git a/backend/i18n/frontend_fr.json b/backend/i18n/frontend_fr.json index 103ccea35..4bdf964ca 100644 --- a/backend/i18n/frontend_fr.json +++ b/backend/i18n/frontend_fr.json @@ -381,6 +381,7 @@ "common.remember": "Ne demande plus", "common.rename": "Renommer", "common.renameTag": "Renommer la balise", + "common.reply": "Reply", "common.repository": "Repository", "common.requiredHint": "requis", "common.reset": "Réinitialiser", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index b3c81c2d9..311bd4ed6 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -381,6 +381,7 @@ "common.remember": "Ricorda la mia decisione", "common.rename": "Rinomina", "common.renameTag": "Rename Tag", + "common.reply": "Reply", "common.repository": "Repository", "common.requiredHint": "obbligatorio", "common.reset": "Reimposta", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index 0a9854ea2..7dfdfded2 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -381,6 +381,7 @@ "common.remember": "Onthoud mijn keuze", "common.rename": "Hernoemen", "common.renameTag": "Hernoem Tag", + "common.reply": "Reply", "common.repository": "Repository", "common.requiredHint": "verplicht", "common.reset": "Reset", diff --git a/backend/i18n/frontend_pt.json b/backend/i18n/frontend_pt.json index d2c13c94c..afa8d213b 100644 --- a/backend/i18n/frontend_pt.json +++ b/backend/i18n/frontend_pt.json @@ -381,6 +381,7 @@ "common.remember": "Não pergunte de novo.", "common.rename": "Renomear", "common.renameTag": "Renomear Etiqueta", + "common.reply": "Reply", "common.repository": "Repository", "common.requiredHint": "Necessário", "common.reset": "Reset", diff --git a/backend/i18n/frontend_zh.json b/backend/i18n/frontend_zh.json index ea5252b29..97b3d788c 100644 --- a/backend/i18n/frontend_zh.json +++ b/backend/i18n/frontend_zh.json @@ -381,6 +381,7 @@ "common.remember": "不要再问了", "common.rename": "重命名", "common.renameTag": "Rename Tag", + "common.reply": "Reply", "common.repository": "Repository", "common.requiredHint": "必需的", "common.reset": "重置", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index 1738e4a26..428094564 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -381,6 +381,7 @@ "common.remember": "Don't ask again", "common.rename": "Rename", "common.renameTag": "Rename Tag", + "common.reply": "Reply", "common.repository": "Repository", "common.requiredHint": "required", "common.reset": "Reset", diff --git a/frontend/src/app/declarations.d.ts b/frontend/src/app/declarations.d.ts index 172dfdccb..3d51670a7 100644 --- a/frontend/src/app/declarations.d.ts +++ b/frontend/src/app/declarations.d.ts @@ -17,6 +17,8 @@ declare class SquidexEditorWrapper { setIsDisabled(isDisabled: boolean): void; + setAnnotations(annotations?: ReadonlyArray | null): void; + destroy(): void; } @@ -124,8 +126,11 @@ interface EditorProps { // Indicates whether content items can be selected. canSelectContents?: boolean; + // Indicates whether annotations can be added. + canAddAnnotation?: boolean; + // The annotations. - annotations?: ReadonlyArray; + annotations?: ReadonlyArray | null; } interface AnnotationSelection { diff --git a/frontend/src/app/features/content/pages/content/content-page.component.html b/frontend/src/app/features/content/pages/content/content-page.component.html index 22b908b23..b5de3281c 100644 --- a/frontend/src/app/features/content/pages/content/content-page.component.html +++ b/frontend/src/app/features/content/pages/content/content-page.component.html @@ -156,18 +156,19 @@ + [showIdInput]="showIdInput"> diff --git a/frontend/src/app/features/content/pages/content/content-page.component.ts b/frontend/src/app/features/content/pages/content/content-page.component.ts index 6fd23efde..1d2089ae0 100644 --- a/frontend/src/app/features/content/pages/content/content-page.component.ts +++ b/frontend/src/app/features/content/pages/content/content-page.component.ts @@ -11,7 +11,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { Observable, of } from 'rxjs'; import { filter, map, tap } from 'rxjs/operators'; -import { ApiUrlConfig, AppLanguageDto, AppsState, AuthService, AutoSaveKey, AutoSaveService, CanComponentDeactivate, CollaborationService, ConfirmClickDirective, ContentDto, ContentsState, defined, DialogService, DropdownMenuComponent, EditContentForm, LanguageSelectorComponent, LanguagesState, LayoutComponent, LocalStoreService, ModalDirective, ModalModel, ModalPlacementDirective, NotifoComponent, ResolveAssets, ResolveContents, SchemaDto, SchemasState, Settings, ShortcutDirective, SidebarMenuDirective, Subscriptions, TempService, TitleComponent, ToolbarComponent, ToolbarService, TooltipDirective, TourHintDirective, TourStepDirective, TranslatePipe, Types, Version, WatchingUsersComponent } from '@app/shared'; +import { AnnotationCreate, AnnotationCreateAfterNavigate, AnnotationsSelect, AnnotationsSelectAfterNavigate, ApiUrlConfig, AppLanguageDto, AppsState, AuthService, AutoSaveKey, AutoSaveService, CanComponentDeactivate, CollaborationService, CommentsState, ConfirmClickDirective, ContentDto, ContentsState, defined, DialogService, DropdownMenuComponent, EditContentForm, LanguageSelectorComponent, LanguagesState, LayoutComponent, LocalStoreService, MessageBus, ModalDirective, ModalModel, ModalPlacementDirective, NotifoComponent, ResolveAssets, ResolveContents, SchemaDto, SchemasState, Settings, ShortcutDirective, SidebarMenuDirective, Subscriptions, TempService, TitleComponent, ToolbarComponent, ToolbarService, TooltipDirective, TourHintDirective, TourStepDirective, TranslatePipe, Types, Version, WatchingUsersComponent } from '@app/shared'; import { ContentExtensionComponent } from '../../shared/content-extension.component'; import { PreviewButtonComponent } from '../../shared/preview-button.component'; import { ContentEditorComponent } from './editor/content-editor.component'; @@ -25,6 +25,7 @@ import { ContentReferencesComponent } from './references/content-references.comp templateUrl: './content-page.component.html', providers: [ CollaborationService, + CommentsState, ResolveAssets, ResolveContents, ToolbarService, @@ -93,6 +94,7 @@ export class ContentPageComponent implements CanComponentDeactivate, OnInit { private readonly autoSaveService: AutoSaveService, private readonly collaboration: CollaborationService, private readonly dialogs: DialogService, + private readonly messageBus: MessageBus, private readonly languagesState: LanguagesState, private readonly localStore: LocalStoreService, private readonly route: ActivatedRoute, @@ -115,6 +117,30 @@ export class ContentPageComponent implements CanComponentDeactivate, OnInit { public ngOnInit() { this.contentsState.loadIfNotLoaded(); + this.subscriptions.add( + this.messageBus.of(AnnotationCreate) + .subscribe(async message => { + if (message.annotation) { + await this.router.navigate(['comments'], { relativeTo: this.route }); + } + + setTimeout(() => { + this.messageBus.emit(new AnnotationCreateAfterNavigate(message.editorId, message.annotation)); + }); + })); + + this.subscriptions.add( + this.messageBus.of(AnnotationsSelect) + .subscribe(async message => { + if (message.annotations.length > 0) { + await this.router.navigate(['comments'], { relativeTo: this.route }); + } + + setTimeout(() => { + this.messageBus.emit(new AnnotationsSelectAfterNavigate(message.annotations)); + }); + })); + this.subscriptions.add( this.languagesState.isoMasterLanguage .subscribe(language => { diff --git a/frontend/src/app/features/content/pages/content/editor/content-editor.component.html b/frontend/src/app/features/content/pages/content/editor/content-editor.component.html index c654b55fb..683820fac 100644 --- a/frontend/src/app/features/content/pages/content/editor/content-editor.component.html +++ b/frontend/src/app/features/content/pages/content/editor/content-editor.component.html @@ -30,12 +30,13 @@
diff --git a/frontend/src/app/features/content/pages/content/inspecting/content-inspection.component.ts b/frontend/src/app/features/content/pages/content/inspecting/content-inspection.component.ts index 86eb72232..29ca71de9 100644 --- a/frontend/src/app/features/content/pages/content/inspecting/content-inspection.component.ts +++ b/frontend/src/app/features/content/pages/content/inspecting/content-inspection.component.ts @@ -50,10 +50,9 @@ export class ContentInspectionComponent implements OnDestroy { public actualData = combineLatest([ - this.languageChanges$, + this.languageChanges$.pipe(filter(x => !!x)), this.mode, ]).pipe( - filter(x => !!x[0]), switchMap(([language, mode]) => { if (mode === 'Content') { return of(this.content); diff --git a/frontend/src/app/features/content/pages/schemas/schemas-page.component.ts b/frontend/src/app/features/content/pages/schemas/schemas-page.component.ts index 13b15fcc2..d237f7a9c 100644 --- a/frontend/src/app/features/content/pages/schemas/schemas-page.component.ts +++ b/frontend/src/app/features/content/pages/schemas/schemas-page.component.ts @@ -53,12 +53,10 @@ export class SchemasPageComponent { public categories = combineLatest([ - value$(this.schemasFilter), this.schemas, this.schemasState.addedCategories, - ], (filter, schemas, categories) => { - return getCategoryTree(schemas, categories, filter); - }); + value$(this.schemasFilter), + ], getCategoryTree); constructor( public readonly schemasState: SchemasState, diff --git a/frontend/src/app/features/content/pages/sidebar/sidebar-page.component.ts b/frontend/src/app/features/content/pages/sidebar/sidebar-page.component.ts index c7153a2c8..b77465cb0 100644 --- a/frontend/src/app/features/content/pages/sidebar/sidebar-page.component.ts +++ b/frontend/src/app/features/content/pages/sidebar/sidebar-page.component.ts @@ -8,7 +8,6 @@ import { AsyncPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component } from '@angular/core'; import { combineLatest } from 'rxjs'; -import { map } from 'rxjs/operators'; import { ContentsState, defined, LayoutComponent, SchemasState } from '@app/shared'; import { ContentExtensionComponent } from '../../shared/content-extension.component'; @@ -28,14 +27,14 @@ export class SidebarPageComponent { public url = combineLatest([ this.schemasState.selectedSchema.pipe(defined()), this.contentsState.selectedContent, - ]).pipe(map(([schema, content]) => { + ], (schema, content) => { const url = content ? schema.properties.contentSidebarUrl : schema.properties.contentsSidebarUrl; return url; - })); + }); constructor( public readonly contentsState: ContentsState, diff --git a/frontend/src/app/features/content/shared/forms/array-editor.component.ts b/frontend/src/app/features/content/shared/forms/array-editor.component.ts index c7e8608e5..1d5341265 100644 --- a/frontend/src/app/features/content/shared/forms/array-editor.component.ts +++ b/frontend/src/app/features/content/shared/forms/array-editor.component.ts @@ -10,7 +10,6 @@ import { AsyncPipe, NgFor, NgIf } from '@angular/common'; import { booleanAttribute, ChangeDetectionStrategy, Component, Input, numberAttribute, QueryList, ViewChildren } from '@angular/core'; import { VirtualScrollerComponent, VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller'; import { combineLatest, Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; import { AppLanguageDto, ComponentsFieldPropertiesDto, ConfirmClickDirective, disabled$, DropdownMenuComponent, EditContentForm, FieldArrayForm, FormHintComponent, LocalStoreService, ModalDirective, ModalModel, ModalPlacementDirective, ObjectFormBase, SchemaDto, Settings, sorted, TooltipDirective, TranslatePipe, TypedSimpleChanges, Types } from '@app/shared'; import { ArrayItemComponent } from './array-item.component'; @@ -107,9 +106,9 @@ export class ArrayEditorComponent { this.isDisabledOrFull = combineLatest([ this.isDisabled, this.formModel.itemChanges, - ]).pipe(map(([disabled, items]) => { + ], (disabled, items) => { return disabled || items.length >= maxItems; - })); + }); } if (changes.formModel || changes.formLevel) { diff --git a/frontend/src/app/features/content/shared/forms/component-section.component.html b/frontend/src/app/features/content/shared/forms/component-section.component.html index 59ddb4209..84c25392a 100644 --- a/frontend/src/app/features/content/shared/forms/component-section.component.html +++ b/frontend/src/app/features/content/shared/forms/component-section.component.html @@ -13,6 +13,7 @@ [class.col-6]="!isComparing && child.field.properties.isHalfWidth"> >; + public get field() { return this.formModel.field; } @@ -119,10 +124,19 @@ export class FieldEditorComponent { return this.formModel.form; } + constructor( + private readonly messageBus: MessageBus, + ) { + } + public ngOnChanges(changes: TypedSimpleChanges) { if (changes.formModel) { this.isEmpty = hasNoValue$(this.formModel.form); } + + if (changes.formModel || changes.comments) { + this.annotations = this.comments?.getAnnotations(this.formModel.fieldPath); + } } public reset() { @@ -145,6 +159,18 @@ export class FieldEditorComponent { this.isExpanded = !this.isExpanded; } + public annotationCreate(annotation: AnnotationSelection) { + this.messageBus.emit(new AnnotationCreate(this.formModel.fieldPath, annotation)); + } + + public annotationsSelect(annotation: ReadonlyArray) { + this.messageBus.emit(new AnnotationsSelect(annotation)); + } + + public annotationsUpdate(annotations: ReadonlyArray) { + this.comments?.updateAnnotations(this.formModel.fieldPath, annotations); + } + public unset() { this.formModel.unset(); } diff --git a/frontend/src/app/framework/angular/animations.ts b/frontend/src/app/framework/angular/animations.ts index 8deab7800..b53672143 100644 --- a/frontend/src/app/framework/angular/animations.ts +++ b/frontend/src/app/framework/angular/animations.ts @@ -5,7 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { animate, AnimationTriggerMetadata, state, style, transition, trigger } from '@angular/animations'; +import { animate, AnimationTriggerMetadata, keyframes, state, style, transition, trigger } from '@angular/animations'; export function buildSlideRightAnimation(name = 'slideRight', timing = '150ms'): AnimationTriggerMetadata { return trigger( @@ -76,6 +76,21 @@ export function buildFadeAnimation(name = 'fade', timing = '150ms'): AnimationTr ); } +export function buildBounceAnimation(name = 'bounce', timing = '150ms'): AnimationTriggerMetadata { + return trigger( + name, [ + transition('* => true', [ + animate(timing, keyframes([ + style({ transform: 'translateX(0)' }), + style({ transform: 'translateX(-10px)' }), + style({ transform: 'translateX(0)' }), + ])), + ]), + ], + ); +} + +export const bounceAnimation = buildBounceAnimation(); export const fadeAnimation = buildFadeAnimation(); export const slideAnimation = buildSlideAnimation(); export const slideRightAnimation = buildSlideRightAnimation(); diff --git a/frontend/src/app/framework/angular/forms/forms-helper.ts b/frontend/src/app/framework/angular/forms/forms-helper.ts index 8202c9d75..be572bd50 100644 --- a/frontend/src/app/framework/angular/forms/forms-helper.ts +++ b/frontend/src/app/framework/angular/forms/forms-helper.ts @@ -115,8 +115,7 @@ export function changed$(lhs: AbstractControl, rhs: AbstractControl) { return combineLatest([ value$(lhs), value$(rhs), - ]).pipe(map(([lhs, rhs]) => !Types.equals(lhs, rhs, true)), - distinctUntilChanged()); + ], (lhs, rhs) => !Types.equals(lhs, rhs, true)).pipe(distinctUntilChanged()); } export function touchedChange$(form: AbstractControl) { diff --git a/frontend/src/app/framework/angular/scroll-active.directive.ts b/frontend/src/app/framework/angular/scroll-active.directive.ts index b5ed39678..457e77e70 100644 --- a/frontend/src/app/framework/angular/scroll-active.directive.ts +++ b/frontend/src/app/framework/angular/scroll-active.directive.ts @@ -7,7 +7,8 @@ /* eslint-disable @angular-eslint/no-input-rename */ -import { AfterViewInit, booleanAttribute, Directive, ElementRef, Input, Renderer2 } from '@angular/core'; +import { AfterViewInit, booleanAttribute, Directive, ElementRef, Input, numberAttribute, Renderer2 } from '@angular/core'; +import { Types } from '../internal'; @Directive({ selector: '[sqxScrollActive]', @@ -17,11 +18,14 @@ export class ScrollActiveDirective implements AfterViewInit { @Input({ alias: 'sqxScrollActive', transform: booleanAttribute }) public isActive = false; + @Input({ alias: 'sqxScrollOffset', transform: numberAttribute }) + public offset = 0; + @Input('sqxScrollContainer') - public container!: HTMLElement; + public container?: HTMLElement | string | null; constructor( - private readonly element: ElementRef, + private readonly element: ElementRef, private readonly renderer: Renderer2, ) { } @@ -36,27 +40,35 @@ export class ScrollActiveDirective implements AfterViewInit { private check() { if (this.isActive && this.container) { - this.scrollInView(this.container, this.element.nativeElement); + let container = this.container; + + if (Types.isString(container)) { + container = this.element.nativeElement.closest(container) as HTMLElement; + } + + if (container) { + this.scrollInView(container, this.element.nativeElement); + } } } private scrollInView(parent: HTMLElement, target: HTMLElement) { - const parentRect = parent.getBoundingClientRect(); - const targetRect = target.getBoundingClientRect(); + const boundsParent = parent.getBoundingClientRect(); + const boundsTarget = target.getBoundingClientRect(); const body = document.body; - const scrollDiff = (targetRect.top + body.scrollTop) - (parentRect.top + body.scrollTop); + const scrollDiff = (boundsTarget.top + body.scrollTop) - (boundsParent.top + body.scrollTop); const scrollTop = parent.scrollTop; if (scrollDiff < 0) { this.renderer.setProperty(parent, 'scrollTop', scrollTop + scrollDiff); } else { - const targetHeight = targetRect.height; - const parentHeight = parentRect.height; + const targetHeight = boundsTarget.height; + const parentHeight = boundsParent.height; if ((scrollDiff + targetHeight) > parentHeight) { - this.renderer.setProperty(parent, 'scrollTop', scrollTop + scrollDiff - parentHeight + targetHeight); + this.renderer.setProperty(parent, 'scrollTop', scrollTop + scrollDiff - parentHeight + targetHeight + this.offset); } } } diff --git a/frontend/src/app/framework/services/message-bus.service.spec.ts b/frontend/src/app/framework/services/message-bus.service.spec.ts index af78a26df..f992a7bef 100644 --- a/frontend/src/app/framework/services/message-bus.service.spec.ts +++ b/frontend/src/app/framework/services/message-bus.service.spec.ts @@ -5,20 +5,27 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ +import { NgZone } from '@angular/core'; import { MessageBus } from './message-bus.service'; class Event1 {} class Event2 {} describe('MessageBus', () => { + const zone = { + run: action => { + action(); + }, + } as NgZone; + it('should instantiate', () => { - const messageBus = new MessageBus(); + const messageBus = new MessageBus(zone); expect(messageBus).toBeDefined(); }); it('should publish events and subscribe', () => { - const messageBus = new MessageBus(); + const messageBus = new MessageBus(zone); const event1 = new Event1(); const event2 = new Event2(); diff --git a/frontend/src/app/framework/services/message-bus.service.ts b/frontend/src/app/framework/services/message-bus.service.ts index dffe0172f..f4bc1b2bf 100644 --- a/frontend/src/app/framework/services/message-bus.service.ts +++ b/frontend/src/app/framework/services/message-bus.service.ts @@ -5,7 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Injectable } from '@angular/core'; +import { Injectable, NgZone } from '@angular/core'; import { filter, map, Observable, Subject } from 'rxjs'; interface Message { @@ -22,10 +22,17 @@ interface Message { export class MessageBus { private message$ = new Subject(); + constructor( + private readonly zone: NgZone, + ) { + } + public emit(data: T) { const channel = ((data)['constructor']).name; - this.message$.next({ channel, data }); + this.zone.run(() => { + this.message$.next({ channel, data }); + }); } public of(messageType: { new(...args: ReadonlyArray): T }): Observable { diff --git a/frontend/src/app/shared/components/comments/comment.component.html b/frontend/src/app/shared/components/comments/comment.component.html index 66f0cbd88..875f7e0a8 100644 --- a/frontend/src/app/shared/components/comments/comment.component.html +++ b/frontend/src/app/shared/components/comments/comment.component.html @@ -1,28 +1,38 @@ -
-
- -
+
+
+
+ +
-
-
{{comment.user | sqxUserNameRef:null}}
+
{{commentItem.comment.user | sqxUserNameRef:null}}
-
+
- - {{ 'comments.follow' | sqxTranslate }}  + + {{ 'comments.follow' | sqxTranslate }}  - {{comment.time | sqxFromNow}} + {{commentItem.comment.time | sqxFromNow}}
+ + @@ -36,27 +46,65 @@
+
+ + +
+ + +
+ + + +
+
- -
-
- - +
- -
-
-
+ +
\ No newline at end of file diff --git a/frontend/src/app/shared/components/comments/comment.component.scss b/frontend/src/app/shared/components/comments/comment.component.scss index 6e161e784..a7eaa62aa 100644 --- a/frontend/src/app/shared/components/comments/comment.component.scss +++ b/frontend/src/app/shared/components/comments/comment.component.scss @@ -4,7 +4,7 @@ /* stylelint-disable no-descending-specificity */ .actions { - @include absolute(-5px, -15px, auto, auto); + @include absolute(0, 0, auto, auto); background: $color-white; border: 0; border-radius: 0; @@ -34,31 +34,51 @@ } .comment { - font-size: $font-small; + background-color: $color-white; + border: 1px solid transparent; + border-radius: 2px; + font-size: .85rem; font-weight: normal; line-height: 1.25rem; - margin: 0; - margin-bottom: .75rem; + margin-top: 0; + margin-bottom: .25rem; + padding: .5rem; + padding-top: .375rem; position: relative; - &-message { - margin-bottom: .375rem; + &.reply { + margin-bottom: .75rem; + padding: 0; + padding-left: 1.5rem; + } + + &.selected { + border-color: $color-theme-brand; } &-created { font-size: $font-small; } - &:hover { - .actions { - display: block; + &-text { + &:hover { + .actions { + display: block; + } } } } +.replies { + border-top: 1px solid $color-border; + margin-bottom: 0; + margin-top: .5rem; + padding-top: .5rem; +} + :host { &:last-child { - .comment { + & > .comment { margin-bottom: 0; } } diff --git a/frontend/src/app/shared/components/comments/comment.component.ts b/frontend/src/app/shared/components/comments/comment.component.ts index 58f64c81d..025b2b2b0 100644 --- a/frontend/src/app/shared/components/comments/comment.component.ts +++ b/frontend/src/app/shared/components/comments/comment.component.ts @@ -5,17 +5,17 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { NgIf } from '@angular/common'; +import { NgForOf, NgIf } from '@angular/common'; import { booleanAttribute, ChangeDetectionStrategy, Component, Input } from '@angular/core'; -import { FormsModule } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { MentionConfig, MentionModule } from 'angular-mentions'; -import { ConfirmClickDirective, FocusOnInitDirective, FromNowPipe, MarkdownPipe, SafeHtmlPipe, TooltipDirective, TranslatePipe } from '@app/framework'; -import { Comment, ContributorDto, DialogService, Keys, SharedArray, StatefulComponent } from '@app/shared/internal'; +import { bounceAnimation, ConfirmClickDirective, FocusOnInitDirective, FromNowPipe, MarkdownPipe, SafeHtmlPipe, ScrollActiveDirective, TooltipDirective, TranslatePipe } from '@app/framework'; +import { CommentItem, CommentsState, ContributorDto, DialogService, Keys, StatefulComponent, UpsertCommentForm } from '@app/shared/internal'; import { UserNameRefPipe, UserPictureRefPipe } from '../pipes'; interface State { - isEditing: boolean; + mode?: 'Normal' | 'Edit' | 'Reply'; } @Component({ @@ -24,6 +24,9 @@ interface State { styleUrls: ['./comment.component.scss'], templateUrl: './comment.component.html', changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + bounceAnimation, + ], imports: [ ConfirmClickDirective, FocusOnInitDirective, @@ -32,8 +35,11 @@ interface State { MarkdownPipe, MentionModule, NgIf, + NgForOf, + ReactiveFormsModule, RouterLink, SafeHtmlPipe, + ScrollActiveDirective, TooltipDirective, TranslatePipe, UserNameRefPipe, @@ -44,6 +50,9 @@ export class CommentComponent extends StatefulComponent { @Input({ transform: booleanAttribute }) public canFollow?: boolean | null; + @Input({ transform: booleanAttribute }) + public canAnswer?: boolean | null; + @Input({ transform: booleanAttribute }) public canDelete?: boolean | null; @@ -54,21 +63,27 @@ export class CommentComponent extends StatefulComponent { public confirmDelete?: boolean | null = true; @Input({ required: true }) - public comment!: Comment; - - @Input({ required: true }) - public commentIndex!: number; + public commentItem!: CommentItem; @Input({ required: true }) - public comments!: SharedArray; + public comments!: CommentsState; @Input() public userToken = ''; @Input() + public currenUrl = ''; + + @Input({ required: true }) public mentionUsers?: ReadonlyArray; - public mentionConfig: MentionConfig = { dropUp: true, labelKey: 'contributorEmail' }; + @Input({ required: true }) + public mentionConfig!: MentionConfig; + + @Input() + public scrollContainer?: string; + + public replyForm = new UpsertCommentForm(); public isDeletable = false; public isEditable = false; @@ -78,24 +93,28 @@ export class CommentComponent extends StatefulComponent { constructor( private readonly dialogs: DialogService, ) { - super({ isEditing: false }); + super({}); } public ngOnChanges() { - const isMyComment = this.comment.user === this.userToken; + const isMyComment = this.commentItem.comment.user === this.userToken; this.isDeletable = isMyComment; this.isEditable = isMyComment; } public startEdit() { - this.editingText = this.comment.text; + this.editingText = this.commentItem.comment.text; + + this.next({ mode: 'Edit' }); + } - this.next({ isEditing: true }); + public startReply() { + this.next({ mode: 'Reply' }); } - public cancelEdit() { - this.next({ isEditing: false }); + public cancelEditOrReply() { + this.next({ mode: 'Normal' }); } public delete() { @@ -103,17 +122,24 @@ export class CommentComponent extends StatefulComponent { return; } - this.comments.remove(this.commentIndex); + this.comments.remove(this.commentItem.index); } - public updateWhenEnter(event: KeyboardEvent) { - if (Keys.isEnter(event) && !event.altKey && !event.shiftKey && !event.defaultPrevented) { - event.preventDefault(); - event.stopImmediatePropagation(); - event.stopPropagation(); + public reply() { + if (!this.canAnswer) { + return; + } - this.update(); + const { text } = this.replyForm.submit() || {}; + + if (text && text.length > 0 && this.commentItem.comment.id) { + const replyTo = this.commentItem.comment.id!; + + this.comments.create(this.userToken, text, this.currenUrl, { replyTo }); } + + this.replyForm.submitCompleted(); + this.cancelEditOrReply(); } public update() { @@ -131,8 +157,27 @@ export class CommentComponent extends StatefulComponent { } }); } else { - this.comments.set(this.commentIndex, { ...this.comment, text }); - this.cancelEdit(); + this.comments.update(this.commentItem.index, { text }); + } + + this.cancelEditOrReply(); + } + + public replayOnEnter(event: KeyboardEvent) { + if (Keys.isEnter(event) && !event.altKey && !event.shiftKey) { + event.preventDefault(); + this.reply(); + } + } + + public updateOnEnter(event: KeyboardEvent) { + if (Keys.isEnter(event) && !event.altKey && !event.shiftKey) { + event.preventDefault(); + this.update(); } } + + public trackByComment(_: number, item: { index: number }) { + return item.index; + } } diff --git a/frontend/src/app/shared/components/comments/comments.component.html b/frontend/src/app/shared/components/comments/comments.component.html index efc955232..5cc95a489 100644 --- a/frontend/src/app/shared/components/comments/comments.component.html +++ b/frontend/src/app/shared/components/comments/comments.component.html @@ -1,31 +1,30 @@ - - -
-
- - -
-
- - -
-
+ +
+
+ +
+
+
+ + +
+
diff --git a/frontend/src/app/shared/components/comments/comments.component.scss b/frontend/src/app/shared/components/comments/comments.component.scss index 6cfb0541f..e722683cf 100644 --- a/frontend/src/app/shared/components/comments/comments.component.scss +++ b/frontend/src/app/shared/components/comments/comments.component.scss @@ -13,25 +13,25 @@ .comments { &-list { + background-color: $color-background; flex-grow: 1; overflow-x: hidden; overflow-y: auto; - padding: 1rem; - padding-left: 1.5rem; + padding: .5rem; } - &-footer { - border: 0; - border-top: 1px solid $color-border; + &-header { flex-shrink: 0; } } .form-control { - border: 0; + border-color: $color-white; + border-bottom-color: $color-border; border-radius: 0; &:focus { box-shadow: none; + border-color: $color-theme-brand; } } \ No newline at end of file diff --git a/frontend/src/app/shared/components/comments/comments.component.ts b/frontend/src/app/shared/components/comments/comments.component.ts index 6e5c081bf..f4b5aa2f5 100644 --- a/frontend/src/app/shared/components/comments/comments.component.ts +++ b/frontend/src/app/shared/components/comments/comments.component.ts @@ -6,13 +6,13 @@ */ import { AsyncPipe, NgFor, NgIf } from '@angular/common'; -import { Component, ElementRef, Input, QueryList, ViewChild, ViewChildren } from '@angular/core'; +import { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { MentionConfig, MentionModule } from 'angular-mentions'; -import { Observable } from 'rxjs'; -import { ResizedDirective, TranslatePipe } from '@app/framework'; -import { AuthService, CollaborationService, Comment, ContributorsState, SharedArray, UpsertCommentForm } from '@app/shared/internal'; +import { BehaviorSubject } from 'rxjs'; +import { MessageBus, ResizedDirective, Subscriptions, TranslatePipe } from '@app/framework'; +import { AnnotationCreateAfterNavigate, AnnotationsSelectAfterNavigate, AuthService, CommentsState, ContributorsState, UpsertCommentForm } from '@app/shared/internal'; import { CommentComponent } from './comment.component'; @Component({ @@ -32,64 +32,68 @@ import { CommentComponent } from './comment.component'; TranslatePipe, ], }) -export class CommentsComponent { - @ViewChild('scrollContainer', { static: false }) - public scrollContainer!: ElementRef; +export class CommentsComponent implements OnInit { + private readonly subscriptions = new Subscriptions(); + private readonly selection = new BehaviorSubject>([]); + private reference?: AnnotationCreateAfterNavigate; - @ViewChildren(CommentComponent) - public children!: QueryList; + @ViewChild('input', { static: false }) + public input!: ElementRef; @Input() public commentsId = ''; - public commentsUrl!: string; - public commentsArray!: Observable>; - public commentForm = new UpsertCommentForm(); - public mentionUsers = this.contributorsState.contributors; public mentionConfig: MentionConfig = { dropUp: true, labelKey: 'contributorEmail' }; - public userToken = ''; + public commentForm = new UpsertCommentForm(); + public commentsItems = this.commentsState.getGroupedComments(this.selection); + public commentUser: string; constructor(authService: AuthService, - private readonly collaboration: CollaborationService, + public readonly commentsState: CommentsState, + public readonly router: Router, private readonly contributorsState: ContributorsState, - private readonly router: Router, + private readonly messageBus: MessageBus, ) { - this.userToken = authService.user!.token; - } - - public ngOnChanges() { - this.commentsArray = this.collaboration.getArray('stream'); + this.commentUser = authService.user!.token; } - public scrollDown() { - if (this.scrollContainer && this.scrollContainer.nativeElement) { - let isEditing = false; + public ngOnInit() { + this.subscriptions.add( + this.messageBus.of(AnnotationsSelectAfterNavigate) + .subscribe(message => { + this.selection.next(message.annotations); + })); - this.children.forEach(x => { - isEditing = isEditing || x.snapshot.isEditing; - }); + this.subscriptions.add( + this.messageBus.of(AnnotationCreateAfterNavigate) + .subscribe(message => { + this.reference = message; - if (!isEditing) { - const height = this.scrollContainer.nativeElement.scrollHeight; - - this.scrollContainer.nativeElement.scrollTop = height; - } - } + this.input.nativeElement.focus(); + })); } - public comment(comments: SharedArray) { - const value = this.commentForm.submit(); + public comment() { + const { text } = this.commentForm.submit() || {}; + + if (text && text.length > 0) { + const { from, to } = this.reference?.annotation || {}; - if (value?.text && value.text.length > 0) { - comments.add({ text: value.text, url: this.router.url, time: new Date().toISOString(), user: this.userToken }); + this.commentsState.create(this.commentUser, text, this.router.url, { editorId: this.reference?.editorId, from, to }); } this.commentForm.submitCompleted(); } - public trackByComment(index: number) { - return index; + public blurComment() { + setTimeout(() => { + this.reference = undefined; + }, 100); + } + + public trackByComment(_: number, item: { index: number }) { + return item.index; } } diff --git a/frontend/src/app/shared/components/forms/rich-editor.component.ts b/frontend/src/app/shared/components/forms/rich-editor.component.ts index 675047b66..94a2b638a 100644 --- a/frontend/src/app/shared/components/forms/rich-editor.component.ts +++ b/frontend/src/app/shared/components/forms/rich-editor.component.ts @@ -9,7 +9,7 @@ import { AsyncPipe } from '@angular/common'; import { AfterViewInit, booleanAttribute, ChangeDetectionStrategy, Component, ElementRef, EventEmitter, forwardRef, Input, OnDestroy, Output, ViewChild } from '@angular/core'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { BehaviorSubject, catchError, of, switchMap } from 'rxjs'; -import { ModalDirective } from '@app/framework'; +import { ModalDirective, TypedSimpleChanges } from '@app/framework'; import { ApiUrlConfig, AppsState, AssetDto, AssetsService, AssetUploaderState, ContentDto, DialogModel, getContentValue, LanguageDto, ResourceLoaderService, StatefulControlComponent, Types } from '@app/shared/internal'; import { AssetDialogComponent } from '../assets/asset-dialog.component'; import { AssetSelectorComponent } from '../assets/asset-selector.component'; @@ -40,7 +40,7 @@ export const SQX_RICH_EDITOR_CONTROL_VALUE_ACCESSOR: any = { }) export class RichEditorComponent extends StatefulControlComponent<{}, string> implements AfterViewInit, OnDestroy { private readonly assetId = new BehaviorSubject(null); - private editorWrapper: any; + private editorWrapper?: SquidexEditorWrapper; private value?: string; private currentContents?: ResolvablePromise; private currentAssets?: ResolvablePromise; @@ -49,9 +49,24 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im @Output() public assetPluginClick = new EventEmitter(); + @Output() + public annotationsCreate = new EventEmitter(); + + @Output() + public annotationsUpdate = new EventEmitter>(); + + @Output() + public annotationsSelect = new EventEmitter>(); + @Input({ required: true }) public hasChatBot = false; + @Input() + public hasAnnotations = false; + + @Input() + public annotations?: ReadonlyArray | null; + @Input() public schemaIds?: ReadonlyArray; @@ -106,7 +121,13 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im public ngOnDestroy() { if (this.editorWrapper) { this.editorWrapper.destroy?.(); - this.editorWrapper = null; + this.editorWrapper = undefined; + } + } + + public ngOnChanges(changes: TypedSimpleChanges) { + if (changes.annotations) { + this.editorWrapper?.setAnnotations(this.annotations); } } @@ -153,17 +174,28 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im onChange: (value: string | undefined) => { this.callChange(value); }, + onEditAsset: id => { + this.assetId.next(id); + }, + onAnnotationCreate: event => { + this.annotationsCreate.emit(event); + }, + onAnnotationsUpdate: event => { + this.annotationsUpdate.emit(event); + }, + onAnnotationsFocus: event => { + this.annotationsSelect.emit(event); + }, onEditContent: (schemaName, id) => { const url = this.apiUrl.buildUrl(`/app/${this.appsState.appName}/content/${schemaName}/${id}`); window.open(url, '_blank'); }, - onEditAsset: id => { - this.assetId.next(id); - }, mode: this.mode, + annotations: this.annotations, appName: this.appsState.appName, baseUrl: this.apiUrl.buildUrl(''), + canAddAnnotation: this.hasAnnotations, canSelectAIText: this.hasChatBot, canSelectAssets: true, canSelectContents: !!this.schemaIds, diff --git a/frontend/src/app/shared/internal.ts b/frontend/src/app/shared/internal.ts index 390e08355..8c53ef702 100644 --- a/frontend/src/app/shared/internal.ts +++ b/frontend/src/app/shared/internal.ts @@ -50,7 +50,7 @@ export * from './state/backups.forms'; export * from './state/backups.state'; export * from './state/clients.forms'; export * from './state/clients.state'; -export * from './state/comments'; +export * from './state/comments.state'; export * from './state/comments.form'; export * from './state/contents.forms-helpers'; export * from './state/contents.forms.visitors'; diff --git a/frontend/src/app/shared/services/collaboration.service.spec.ts b/frontend/src/app/shared/services/collaboration.service.spec.ts index 833848c6a..5fcd15a04 100644 --- a/frontend/src/app/shared/services/collaboration.service.spec.ts +++ b/frontend/src/app/shared/services/collaboration.service.spec.ts @@ -12,44 +12,52 @@ import * as Y from 'yjs'; import { AuthService, CollaborationProvider, CollaborationService, UIOptions } from '@app/shared/internal'; describe('CollaborationService', () => { - let service: CollaborationService; - let provider!: CollaborationProvider; + let collaborationService: CollaborationService; + let collaborationProvider!: CollaborationProvider; beforeEach(() => { - TestBed.configureTestingModule({ providers: [{ provide: UIOptions, useValue: new UIOptions({}) }] }); + TestBed.configureTestingModule({ + providers: [ + { + provide: UIOptions, + useValue: new UIOptions({}), + }, + ], + }); + TestBed.runInInjectionContext(() => { - service = new CollaborationService(Mock.ofType().object); + collaborationService = new CollaborationService(Mock.ofType().object); }); - service.providerFactory = (_, doc) => { - provider = { awareness: new Awareness(doc), doc, destroy: () => {} }; + collaborationService.providerFactory = (_, doc) => { + collaborationProvider = { awareness: new Awareness(doc), doc, destroy: () => {} }; - return provider; + return collaborationProvider; }; - service.connect('my-room'); + collaborationService.connect('my-room'); }); it('should also get map if disconnected', () => { - service.connect(null); + collaborationService.connect(null); let map: any = undefined; - service.getMap('map').subscribe(v => map = v); + collaborationService.getMap('map').subscribe(v => map = v); expect(map).toBeDefined(); }); it('should also get array if disconnected', () => { - service.connect(null); + collaborationService.connect(null); let array: any = undefined; - service.getArray('array').subscribe(v => array = v); + collaborationService.getArray('array').subscribe(v => array = v); expect(array).toBeDefined(); }); it('should add to map', () => { - const map = service.getMap('map'); + const map = collaborationService.getMap('map'); let values: Record = {}; @@ -64,7 +72,7 @@ describe('CollaborationService', () => { }); it('should remove from map', () => { - const map = service.getMap('map'); + const map = collaborationService.getMap('map'); let values: Record = {}; @@ -80,7 +88,7 @@ describe('CollaborationService', () => { }); it('should add to array', () => { - const array = service.getArray('array'); + const array = collaborationService.getArray('array'); let items: ReadonlyArray = []; @@ -95,7 +103,7 @@ describe('CollaborationService', () => { }); it('should replace in array', () => { - const array = service.getArray('array'); + const array = collaborationService.getArray('array'); let items: ReadonlyArray = []; @@ -111,7 +119,7 @@ describe('CollaborationService', () => { }); it('should remove from array', () => { - const array = service.getArray('array'); + const array = collaborationService.getArray('array'); let items: ReadonlyArray = []; @@ -128,11 +136,11 @@ describe('CollaborationService', () => { it('should provide one awareness per user', () => { let users: any[] = []; - service.userChanges.subscribe(u => users = u); + collaborationService.userChanges.subscribe(u => users = u); - provider.awareness.setLocalStateField('user', { id: '0', displayName: 'User0' }); - provider.awareness.setLocalStateField('key1', 101); - provider.awareness.setLocalStateField('key2', 102); + collaborationProvider.awareness.setLocalStateField('user', { id: '0', displayName: 'User0' }); + collaborationProvider.awareness.setLocalStateField('key1', 101); + collaborationProvider.awareness.setLocalStateField('key2', 102); setOtherAwareness({ user: { id: '1', displayName: 'User1' }, @@ -172,6 +180,6 @@ describe('CollaborationService', () => { otherAwarness.setLocalState(state); - applyAwarenessUpdate(provider.awareness, encodeAwarenessUpdate(otherAwarness, [otherDoc.clientID], otherAwarness.getStates()), otherAwarness); + applyAwarenessUpdate(collaborationProvider.awareness, encodeAwarenessUpdate(otherAwarness, [otherDoc.clientID], otherAwarness.getStates()), otherAwarness); } }); \ No newline at end of file diff --git a/frontend/src/app/shared/services/collaboration.service.ts b/frontend/src/app/shared/services/collaboration.service.ts index bffe49598..8b078a0c8 100644 --- a/frontend/src/app/shared/services/collaboration.service.ts +++ b/frontend/src/app/shared/services/collaboration.service.ts @@ -106,11 +106,11 @@ export class CollaborationService { } public getArray(name: string) { - return this.provider.pipe(map(p => new SharedArray(p?.doc.getArray(name)))); + return this.provider.pipe(map(p => new SharedArray(p?.doc, p?.doc.getArray(name)))); } public getMap(name: string) { - return this.provider.pipe(map(p => new SharedMap(p?.doc.getMap(name)))); + return this.provider.pipe(map(p => new SharedMap(p?.doc, p?.doc.getMap(name)))); } public updateAwareness(key: string, value: any) { @@ -130,7 +130,8 @@ export class SharedMap { } constructor( - private readonly source: Y.Map | undefined, + public readonly doc: Y.Doc | undefined, + public readonly source: Y.Map | undefined, ) { this.value$ = new BehaviorSubject(source?.toJSON() || {}); @@ -160,7 +161,8 @@ export class SharedArray { } constructor( - private readonly source: Y.Array | undefined, + public readonly doc: Y.Doc | undefined, + public readonly source: Y.Array | undefined, ) { this.items$ = new BehaviorSubject>(source?.toJSON() || []); diff --git a/frontend/src/app/shared/state/comments.form.ts b/frontend/src/app/shared/state/comments.form.ts index 0d418b5f4..ba7b6e09e 100644 --- a/frontend/src/app/shared/state/comments.form.ts +++ b/frontend/src/app/shared/state/comments.form.ts @@ -7,7 +7,7 @@ import { UntypedFormControl, Validators } from '@angular/forms'; import { ExtendedFormGroup, Form } from '@app/framework'; -import { Comment } from './comments'; +import { Comment } from './comments.state'; export class UpsertCommentForm extends Form { constructor() { diff --git a/frontend/src/app/shared/state/comments.state.spec.ts b/frontend/src/app/shared/state/comments.state.spec.ts new file mode 100644 index 000000000..654473575 --- /dev/null +++ b/frontend/src/app/shared/state/comments.state.spec.ts @@ -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; + let commentsState: CommentsState; + let sharedArray: SharedArray; + + beforeEach(() => { + const yDoc = new Y.Doc(); + const yArray = yDoc.getArray(); + + sharedArray = new SharedArray(yDoc, yArray); + + collaborationSevice = Mock.ofType(); + collaborationSevice.setup(x => x.getArray(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 = []; + 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 = []; + 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 = []; + 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 = []; + 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 = []; + 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 = []; + 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 = []; + 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 = []; + 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 = []; + 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 = []; + commentsState.getAnnotations(undefined).subscribe(result => { + items = result; + }); + + expect(items).toEqual([]); + }); + + it('should get grouped comments', () => { + const selection = of>(['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 = []; + 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, + }, + ], + }, + ]); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/shared/state/comments.state.ts b/frontend/src/app/shared/state/comments.state.ts new file mode 100644 index 000000000..3d295285c --- /dev/null +++ b/frontend/src/app/shared/state/comments.state.ts @@ -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>(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('stream') + .subscribe(comments => { + this.comments.next(comments); + }); + } + + public ngOnDestroy() { + this.subscription.unsubscribe(); + } + + public create(user: string, text: string, url: string, optional?: Pick) { + 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) { + 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) { + 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) => 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>) { + 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[] }; \ No newline at end of file diff --git a/frontend/src/app/shared/state/comments.ts b/frontend/src/app/shared/state/comments.ts deleted file mode 100644 index a4c13781a..000000000 --- a/frontend/src/app/shared/state/comments.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/frontend/src/app/shared/utils/messages.ts b/frontend/src/app/shared/utils/messages.ts index 7fd806a39..e9e3c7316 100644 --- a/frontend/src/app/shared/utils/messages.ts +++ b/frontend/src/app/shared/utils/messages.ts @@ -10,3 +10,33 @@ export class HistoryChannelUpdated {} export class QueryExecuted {} export class ClientTourStated {} + +export class AnnotationCreate { + constructor( + public readonly editorId: string, + public readonly annotation: AnnotationSelection, + ) { + } +} + +export class AnnotationCreateAfterNavigate { + constructor( + public readonly editorId: string, + public readonly annotation: AnnotationSelection, + ) { + } +} + +export class AnnotationsSelect { + constructor( + public readonly annotations: ReadonlyArray, + ) { + } +} + +export class AnnotationsSelectAfterNavigate { + constructor( + public readonly annotations: ReadonlyArray, + ) { + } +} \ No newline at end of file diff --git a/frontend/src/app/shell/pages/internal/notification-dropdown.component.html b/frontend/src/app/shell/pages/internal/notification-dropdown.component.html index dcfbcb686..95916a33a 100644 --- a/frontend/src/app/shell/pages/internal/notification-dropdown.component.html +++ b/frontend/src/app/shell/pages/internal/notification-dropdown.component.html @@ -2,24 +2,26 @@ - {{unread}} + {{unread}} - - + + {{ 'notifications.empty' | sqxTranslate}} - + [commentItem]="item" + [comments]="commentsState" + [mentionConfig]="{}" + [mentionUsers]="undefined" + [userToken]="commentUser"> \ No newline at end of file diff --git a/frontend/src/app/shell/pages/internal/notification-dropdown.component.ts b/frontend/src/app/shell/pages/internal/notification-dropdown.component.ts index 27c05c83d..49218997e 100644 --- a/frontend/src/app/shell/pages/internal/notification-dropdown.component.ts +++ b/frontend/src/app/shell/pages/internal/notification-dropdown.component.ts @@ -7,9 +7,9 @@ import { AsyncPipe, NgFor, NgIf } from '@angular/common'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { Observable } from 'rxjs'; -import { map, switchMap, tap } from 'rxjs/operators'; -import { AuthService, CollaborationService, Comment, CommentComponent, DropdownMenuComponent, ModalDirective, ModalModel, ModalPlacementDirective, SharedArray, Subscriptions, TranslatePipe } from '@app/shared'; +import { of } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { AuthService, CollaborationService, CommentComponent, CommentsState, DropdownMenuComponent, ModalDirective, ModalModel, ModalPlacementDirective, Subscriptions, TranslatePipe } from '@app/shared'; @Component({ standalone: true, @@ -19,6 +19,7 @@ import { AuthService, CollaborationService, Comment, CommentComponent, DropdownM changeDetection: ChangeDetectionStrategy.OnPush, providers: [ CollaborationService, + CommentsState, ], imports: [ AsyncPipe, @@ -36,60 +37,28 @@ export class NotificationDropdownComponent implements OnInit { public modalMenu = new ModalModel(); - public commentsArray?: SharedArray; - public commentsUnread!: Observable; - - public userToken: string; + public commentUser: string; + public commentItems = this.commentsState.getGroupedComments(of([])); constructor(authService: AuthService, - private readonly collaborations: CollaborationService, + public readonly commentsState: CommentsState, + public readonly collaboration: CollaborationService, ) { - this.userToken = authService.user!.token; + this.commentUser = authService.user!.token; } public ngOnInit() { - this.collaborations.connect('users/collaboration'); - - const comments$ = this.collaborations.getArray('stream'); - - this.commentsUnread = - comments$.pipe(switchMap(x => x.itemsChanges), - map(array => { - return array.filter(x => !x.isRead).length; - })); - - this.subscriptions.add( - comments$ - .subscribe(array => { - this.commentsArray = array; - })); + this.collaboration.connect('users/collaboration'); this.subscriptions.add( this.modalMenu.isOpenChanges.pipe( tap(_ => { - this.markRead(); + this.commentsState.prune(100); + this.commentsState.markRead(); }), )); } - public markRead() { - if (!this.commentsArray) { - return; - } - - const toDelete = this.commentsArray.items.length - 100; - - if (toDelete > 0) { - this.commentsArray.remove(0, toDelete); - } - - this.commentsArray.items.forEach((item, i) => { - if (!item.isRead) { - this.commentsArray?.set(i, { ...item, isRead: true }); - } - }); - } - public trackByComment(index: number) { return index; } diff --git a/frontend/tsconfig.spec.json b/frontend/tsconfig.spec.json index be7e9da76..3e64598b6 100644 --- a/frontend/tsconfig.spec.json +++ b/frontend/tsconfig.spec.json @@ -2,10 +2,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "./out-tsc/spec", - "types": [ - "jasmine" - ] + "outDir": "./out-tsc/spec" }, "include": [ "src/**/*.spec.ts",