Browse Source

Annotations. (#1045)

* Annotations.

* Fixes to annotation injection.
pull/1046/head
Sebastian Stehle 3 years ago
committed by GitHub
parent
commit
4ee3083c08
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      backend/i18n/frontend_en.json
  2. 1
      backend/i18n/frontend_fr.json
  3. 1
      backend/i18n/frontend_it.json
  4. 1
      backend/i18n/frontend_nl.json
  5. 1
      backend/i18n/frontend_pt.json
  6. 1
      backend/i18n/frontend_zh.json
  7. 1
      backend/i18n/source/frontend_en.json
  8. 7
      frontend/src/app/declarations.d.ts
  9. 9
      frontend/src/app/features/content/pages/content/content-page.component.html
  10. 28
      frontend/src/app/features/content/pages/content/content-page.component.ts
  11. 3
      frontend/src/app/features/content/pages/content/editor/content-editor.component.html
  12. 3
      frontend/src/app/features/content/pages/content/inspecting/content-inspection.component.ts
  13. 6
      frontend/src/app/features/content/pages/schemas/schemas-page.component.ts
  14. 5
      frontend/src/app/features/content/pages/sidebar/sidebar-page.component.ts
  15. 5
      frontend/src/app/features/content/shared/forms/array-editor.component.ts
  16. 1
      frontend/src/app/features/content/shared/forms/component-section.component.html
  17. 2
      frontend/src/app/features/content/shared/forms/content-field.component.html
  18. 5
      frontend/src/app/features/content/shared/forms/content-field.component.ts
  19. 10
      frontend/src/app/features/content/shared/forms/field-editor.component.html
  20. 28
      frontend/src/app/features/content/shared/forms/field-editor.component.ts
  21. 17
      frontend/src/app/framework/angular/animations.ts
  22. 3
      frontend/src/app/framework/angular/forms/forms-helper.ts
  23. 32
      frontend/src/app/framework/angular/scroll-active.directive.ts
  24. 11
      frontend/src/app/framework/services/message-bus.service.spec.ts
  25. 11
      frontend/src/app/framework/services/message-bus.service.ts
  26. 92
      frontend/src/app/shared/components/comments/comment.component.html
  27. 40
      frontend/src/app/shared/components/comments/comment.component.scss
  28. 97
      frontend/src/app/shared/components/comments/comment.component.ts
  29. 55
      frontend/src/app/shared/components/comments/comments.component.html
  30. 12
      frontend/src/app/shared/components/comments/comments.component.scss
  31. 82
      frontend/src/app/shared/components/comments/comments.component.ts
  32. 44
      frontend/src/app/shared/components/forms/rich-editor.component.ts
  33. 2
      frontend/src/app/shared/internal.ts
  34. 52
      frontend/src/app/shared/services/collaboration.service.spec.ts
  35. 10
      frontend/src/app/shared/services/collaboration.service.ts
  36. 2
      frontend/src/app/shared/state/comments.form.ts
  37. 242
      frontend/src/app/shared/state/comments.state.spec.ts
  38. 201
      frontend/src/app/shared/state/comments.state.ts
  39. 23
      frontend/src/app/shared/state/comments.ts
  40. 30
      frontend/src/app/shared/utils/messages.ts
  41. 18
      frontend/src/app/shell/pages/internal/notification-dropdown.component.html
  42. 55
      frontend/src/app/shell/pages/internal/notification-dropdown.component.ts
  43. 5
      frontend/tsconfig.spec.json

1
backend/i18n/frontend_en.json

@ -381,6 +381,7 @@
"common.remember": "Don't ask again", "common.remember": "Don't ask again",
"common.rename": "Rename", "common.rename": "Rename",
"common.renameTag": "Rename Tag", "common.renameTag": "Rename Tag",
"common.reply": "Reply",
"common.repository": "Repository", "common.repository": "Repository",
"common.requiredHint": "required", "common.requiredHint": "required",
"common.reset": "Reset", "common.reset": "Reset",

1
backend/i18n/frontend_fr.json

@ -381,6 +381,7 @@
"common.remember": "Ne demande plus", "common.remember": "Ne demande plus",
"common.rename": "Renommer", "common.rename": "Renommer",
"common.renameTag": "Renommer la balise", "common.renameTag": "Renommer la balise",
"common.reply": "Reply",
"common.repository": "Repository", "common.repository": "Repository",
"common.requiredHint": "requis", "common.requiredHint": "requis",
"common.reset": "Réinitialiser", "common.reset": "Réinitialiser",

1
backend/i18n/frontend_it.json

@ -381,6 +381,7 @@
"common.remember": "Ricorda la mia decisione", "common.remember": "Ricorda la mia decisione",
"common.rename": "Rinomina", "common.rename": "Rinomina",
"common.renameTag": "Rename Tag", "common.renameTag": "Rename Tag",
"common.reply": "Reply",
"common.repository": "Repository", "common.repository": "Repository",
"common.requiredHint": "obbligatorio", "common.requiredHint": "obbligatorio",
"common.reset": "Reimposta", "common.reset": "Reimposta",

1
backend/i18n/frontend_nl.json

@ -381,6 +381,7 @@
"common.remember": "Onthoud mijn keuze", "common.remember": "Onthoud mijn keuze",
"common.rename": "Hernoemen", "common.rename": "Hernoemen",
"common.renameTag": "Hernoem Tag", "common.renameTag": "Hernoem Tag",
"common.reply": "Reply",
"common.repository": "Repository", "common.repository": "Repository",
"common.requiredHint": "verplicht", "common.requiredHint": "verplicht",
"common.reset": "Reset", "common.reset": "Reset",

1
backend/i18n/frontend_pt.json

@ -381,6 +381,7 @@
"common.remember": "Não pergunte de novo.", "common.remember": "Não pergunte de novo.",
"common.rename": "Renomear", "common.rename": "Renomear",
"common.renameTag": "Renomear Etiqueta", "common.renameTag": "Renomear Etiqueta",
"common.reply": "Reply",
"common.repository": "Repository", "common.repository": "Repository",
"common.requiredHint": "Necessário", "common.requiredHint": "Necessário",
"common.reset": "Reset", "common.reset": "Reset",

1
backend/i18n/frontend_zh.json

@ -381,6 +381,7 @@
"common.remember": "不要再问了", "common.remember": "不要再问了",
"common.rename": "重命名", "common.rename": "重命名",
"common.renameTag": "Rename Tag", "common.renameTag": "Rename Tag",
"common.reply": "Reply",
"common.repository": "Repository", "common.repository": "Repository",
"common.requiredHint": "必需的", "common.requiredHint": "必需的",
"common.reset": "重置", "common.reset": "重置",

1
backend/i18n/source/frontend_en.json

@ -381,6 +381,7 @@
"common.remember": "Don't ask again", "common.remember": "Don't ask again",
"common.rename": "Rename", "common.rename": "Rename",
"common.renameTag": "Rename Tag", "common.renameTag": "Rename Tag",
"common.reply": "Reply",
"common.repository": "Repository", "common.repository": "Repository",
"common.requiredHint": "required", "common.requiredHint": "required",
"common.reset": "Reset", "common.reset": "Reset",

7
frontend/src/app/declarations.d.ts

@ -17,6 +17,8 @@ declare class SquidexEditorWrapper {
setIsDisabled(isDisabled: boolean): void; setIsDisabled(isDisabled: boolean): void;
setAnnotations(annotations?: ReadonlyArray<Annotation> | null): void;
destroy(): void; destroy(): void;
} }
@ -124,8 +126,11 @@ interface EditorProps {
// Indicates whether content items can be selected. // Indicates whether content items can be selected.
canSelectContents?: boolean; canSelectContents?: boolean;
// Indicates whether annotations can be added.
canAddAnnotation?: boolean;
// The annotations. // The annotations.
annotations?: ReadonlyArray<Annotation>; annotations?: ReadonlyArray<Annotation> | null;
} }
interface AnnotationSelection { interface AnnotationSelection {

9
frontend/src/app/features/content/pages/content/content-page.component.html

@ -156,18 +156,19 @@
<ng-container *ngIf="!content || (contentTab | async) === 'editor'"> <ng-container *ngIf="!content || (contentTab | async) === 'editor'">
<sqx-content-editor <sqx-content-editor
(loadLatest)="loadLatest()" [(contentId)]="contentId"
[(language)]="language"
[isNew]="!content" [isNew]="!content"
[isDeleted]="content?.isDeleted" [isDeleted]="content?.isDeleted"
[contentForm]="contentForm" [contentForm]="contentForm"
[contentFormCompare]="contentFormCompare" [contentFormCompare]="contentFormCompare"
[contentVersion]="contentVersion" [contentVersion]="contentVersion"
[formContext]="formContext" [formContext]="formContext"
[language]="language"
(languageChange)="language = $event"
[languages]="languages" [languages]="languages"
(loadLatest)="loadLatest()"
[schema]="schema" [schema]="schema"
[showIdInput]="showIdInput" [showIdInput]="showIdInput">
[(contentId)]="contentId">
</sqx-content-editor> </sqx-content-editor>
</ng-container> </ng-container>
</ng-container> </ng-container>

28
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 { ActivatedRoute, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { filter, map, tap } from 'rxjs/operators'; 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 { ContentExtensionComponent } from '../../shared/content-extension.component';
import { PreviewButtonComponent } from '../../shared/preview-button.component'; import { PreviewButtonComponent } from '../../shared/preview-button.component';
import { ContentEditorComponent } from './editor/content-editor.component'; import { ContentEditorComponent } from './editor/content-editor.component';
@ -25,6 +25,7 @@ import { ContentReferencesComponent } from './references/content-references.comp
templateUrl: './content-page.component.html', templateUrl: './content-page.component.html',
providers: [ providers: [
CollaborationService, CollaborationService,
CommentsState,
ResolveAssets, ResolveAssets,
ResolveContents, ResolveContents,
ToolbarService, ToolbarService,
@ -93,6 +94,7 @@ export class ContentPageComponent implements CanComponentDeactivate, OnInit {
private readonly autoSaveService: AutoSaveService, private readonly autoSaveService: AutoSaveService,
private readonly collaboration: CollaborationService, private readonly collaboration: CollaborationService,
private readonly dialogs: DialogService, private readonly dialogs: DialogService,
private readonly messageBus: MessageBus,
private readonly languagesState: LanguagesState, private readonly languagesState: LanguagesState,
private readonly localStore: LocalStoreService, private readonly localStore: LocalStoreService,
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
@ -115,6 +117,30 @@ export class ContentPageComponent implements CanComponentDeactivate, OnInit {
public ngOnInit() { public ngOnInit() {
this.contentsState.loadIfNotLoaded(); 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.subscriptions.add(
this.languagesState.isoMasterLanguage this.languagesState.isoMasterLanguage
.subscribe(language => { .subscribe(language => {

3
frontend/src/app/features/content/pages/content/editor/content-editor.component.html

@ -30,12 +30,13 @@
<ng-container> <ng-container>
<div class="cursors" sqxCursors> <div class="cursors" sqxCursors>
<sqx-content-section *ngFor="let section of contentForm.sections; trackBy: trackBySection" <sqx-content-section *ngFor="let section of contentForm.sections; trackBy: trackBySection"
[(language)]="language"
[form]="contentForm" [form]="contentForm"
[formCompare]="contentFormCompare" [formCompare]="contentFormCompare"
[formContext]="formContext" [formContext]="formContext"
[formLevel]="0" [formLevel]="0"
[formSection]="section" [formSection]="section"
[language]="language"
(languageChange)="languageChange.emit($event)"
[languages]="languages" [languages]="languages"
[schema]="schema"> [schema]="schema">
</sqx-content-section> </sqx-content-section>

3
frontend/src/app/features/content/pages/content/inspecting/content-inspection.component.ts

@ -50,10 +50,9 @@ export class ContentInspectionComponent implements OnDestroy {
public actualData = public actualData =
combineLatest([ combineLatest([
this.languageChanges$, this.languageChanges$.pipe(filter(x => !!x)),
this.mode, this.mode,
]).pipe( ]).pipe(
filter(x => !!x[0]),
switchMap(([language, mode]) => { switchMap(([language, mode]) => {
if (mode === 'Content') { if (mode === 'Content') {
return of(this.content); return of(this.content);

6
frontend/src/app/features/content/pages/schemas/schemas-page.component.ts

@ -53,12 +53,10 @@ export class SchemasPageComponent {
public categories = public categories =
combineLatest([ combineLatest([
value$(this.schemasFilter),
this.schemas, this.schemas,
this.schemasState.addedCategories, this.schemasState.addedCategories,
], (filter, schemas, categories) => { value$(this.schemasFilter),
return getCategoryTree(schemas, categories, filter); ], getCategoryTree);
});
constructor( constructor(
public readonly schemasState: SchemasState, public readonly schemasState: SchemasState,

5
frontend/src/app/features/content/pages/sidebar/sidebar-page.component.ts

@ -8,7 +8,6 @@
import { AsyncPipe } from '@angular/common'; import { AsyncPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { combineLatest } from 'rxjs'; import { combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';
import { ContentsState, defined, LayoutComponent, SchemasState } from '@app/shared'; import { ContentsState, defined, LayoutComponent, SchemasState } from '@app/shared';
import { ContentExtensionComponent } from '../../shared/content-extension.component'; import { ContentExtensionComponent } from '../../shared/content-extension.component';
@ -28,14 +27,14 @@ export class SidebarPageComponent {
public url = combineLatest([ public url = combineLatest([
this.schemasState.selectedSchema.pipe(defined()), this.schemasState.selectedSchema.pipe(defined()),
this.contentsState.selectedContent, this.contentsState.selectedContent,
]).pipe(map(([schema, content]) => { ], (schema, content) => {
const url = const url =
content ? content ?
schema.properties.contentSidebarUrl : schema.properties.contentSidebarUrl :
schema.properties.contentsSidebarUrl; schema.properties.contentsSidebarUrl;
return url; return url;
})); });
constructor( constructor(
public readonly contentsState: ContentsState, public readonly contentsState: ContentsState,

5
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 { booleanAttribute, ChangeDetectionStrategy, Component, Input, numberAttribute, QueryList, ViewChildren } from '@angular/core';
import { VirtualScrollerComponent, VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller'; import { VirtualScrollerComponent, VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
import { combineLatest, Observable } from 'rxjs'; 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 { 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'; import { ArrayItemComponent } from './array-item.component';
@ -107,9 +106,9 @@ export class ArrayEditorComponent {
this.isDisabledOrFull = combineLatest([ this.isDisabledOrFull = combineLatest([
this.isDisabled, this.isDisabled,
this.formModel.itemChanges, this.formModel.itemChanges,
]).pipe(map(([disabled, items]) => { ], (disabled, items) => {
return disabled || items.length >= maxItems; return disabled || items.length >= maxItems;
})); });
} }
if (changes.formModel || changes.formLevel) { if (changes.formModel || changes.formLevel) {

1
frontend/src/app/features/content/shared/forms/component-section.component.html

@ -13,6 +13,7 @@
[class.col-6]="!isComparing && child.field.properties.isHalfWidth"> [class.col-6]="!isComparing && child.field.properties.isHalfWidth">
<sqx-field-editor *ngIf="!(child.hiddenChanges | async)" <sqx-field-editor *ngIf="!(child.hiddenChanges | async)"
[canUnset]="canUnset" [canUnset]="canUnset"
[comments]="null"
[form]="form" [form]="form"
[formContext]="formContext" [formContext]="formContext"
[formLevel]="formLevel" [formLevel]="formLevel"

2
frontend/src/app/features/content/shared/forms/content-field.component.html

@ -27,6 +27,7 @@
<div class="form-group" *ngFor="let language of languages"> <div class="form-group" *ngFor="let language of languages">
<sqx-field-editor <sqx-field-editor
[canUnset]="!(isDisabled | async)" [canUnset]="!(isDisabled | async)"
[comments]="commentsState"
[displaySuffix]="prefix(language)" [displaySuffix]="prefix(language)"
[form]="form" [form]="form"
[formContext]="formContext" [formContext]="formContext"
@ -43,6 +44,7 @@
<ng-template #singleControl> <ng-template #singleControl>
<sqx-field-editor <sqx-field-editor
[canUnset]="!(isDisabled | async)" [canUnset]="!(isDisabled | async)"
[comments]="commentsState"
[form]="form" [form]="form"
[formContext]="formContext" [formContext]="formContext"
[formLevel]="formLevel" [formLevel]="formLevel"

5
frontend/src/app/features/content/shared/forms/content-field.component.ts

@ -6,9 +6,9 @@
*/ */
import { AsyncPipe, NgFor, NgIf } from '@angular/common'; import { AsyncPipe, NgFor, NgIf } from '@angular/common';
import { booleanAttribute, Component, EventEmitter, HostBinding, inject, Input, numberAttribute, Output } from '@angular/core'; import { booleanAttribute, Component, EventEmitter, HostBinding, inject, Input, numberAttribute, Optional, Output } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AppLanguageDto, AppsState, changed$, disabled$, EditContentForm, FieldForm, FocusMarkerComponent, invalid$, LocalStoreService, SchemaDto, Settings, TooltipDirective, TranslationsService, TypedSimpleChanges, UIOptions } from '@app/shared'; import { AppLanguageDto, AppsState, changed$, CommentsState, disabled$, EditContentForm, FieldForm, FocusMarkerComponent, invalid$, LocalStoreService, SchemaDto, Settings, TooltipDirective, TranslationsService, TypedSimpleChanges, UIOptions } from '@app/shared';
import { FieldCopyButtonComponent } from './field-copy-button.component'; import { FieldCopyButtonComponent } from './field-copy-button.component';
import { FieldEditorComponent } from './field-editor.component'; import { FieldEditorComponent } from './field-editor.component';
import { FieldLanguagesComponent } from './field-languages.component'; import { FieldLanguagesComponent } from './field-languages.component';
@ -86,6 +86,7 @@ export class ContentFieldComponent {
} }
constructor( constructor(
@Optional() public readonly commentsState: CommentsState,
private readonly appsState: AppsState, private readonly appsState: AppsState,
private readonly localStore: LocalStoreService, private readonly localStore: LocalStoreService,
private readonly translations: TranslationsService, private readonly translations: TranslationsService,

10
frontend/src/app/features/content/shared/forms/field-editor.component.html

@ -195,9 +195,14 @@
<ng-container *ngSwitchCase="'RichText'"> <ng-container *ngSwitchCase="'RichText'">
<sqx-rich-editor #editor <sqx-rich-editor #editor
mode="Html" mode="Html"
[annotations]="annotations | async"
(annotationsCreate)="annotationCreate($event)"
(annotationsSelect)="annotationsSelect($event)"
(annotationsUpdate)="annotationsUpdate($event)"
[classNames]="field.rawProperties.classNames" [classNames]="field.rawProperties.classNames"
[formControl]="$any(fieldForm)" [formControl]="$any(fieldForm)"
[folderId]="field.rawProperties.folderId" [folderId]="field.rawProperties.folderId"
[hasAnnotations]="!!comments"
[hasChatBot]="hasChatBot" [hasChatBot]="hasChatBot"
[language]="language" [language]="language"
[languages]="languages" [languages]="languages"
@ -210,9 +215,14 @@
<ng-container *ngSwitchCase="'Markdown'"> <ng-container *ngSwitchCase="'Markdown'">
<sqx-rich-editor #editor <sqx-rich-editor #editor
mode="Markdown" mode="Markdown"
[annotations]="annotations | async"
(annotationsCreate)="annotationCreate($event)"
(annotationsSelect)="annotationsSelect($event)"
(annotationsUpdate)="annotationsUpdate($event)"
[classNames]="undefined" [classNames]="undefined"
[formControl]="$any(fieldForm)" [formControl]="$any(fieldForm)"
[folderId]="field.rawProperties.folderId" [folderId]="field.rawProperties.folderId"
[hasAnnotations]="!!comments"
[hasChatBot]="hasChatBot" [hasChatBot]="hasChatBot"
[language]="language" [language]="language"
[languages]="languages" [languages]="languages"

28
frontend/src/app/features/content/shared/forms/field-editor.component.ts

@ -9,7 +9,7 @@ import { AsyncPipe, NgFor, NgIf, NgSwitch, NgSwitchCase } from '@angular/common'
import { booleanAttribute, Component, ElementRef, EventEmitter, Input, numberAttribute, Output, ViewChild } from '@angular/core'; import { booleanAttribute, Component, ElementRef, EventEmitter, Input, numberAttribute, Output, ViewChild } from '@angular/core';
import { AbstractControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { AbstractControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AbstractContentForm, AppLanguageDto, ChatDialogComponent, CheckboxGroupComponent, CodeEditorComponent, ColorPickerComponent, ConfirmClickDirective, ControlErrorsComponent, DateTimeEditorComponent, DialogModel, EditContentForm, FieldDto, FormHintComponent, GeolocationEditorComponent, hasNoValue$, IndeterminateValueDirective, MarkdownDirective, MathHelper, ModalDirective, RadioGroupComponent, ReferenceInputComponent, RichEditorComponent, StarsComponent, TagEditorComponent, ToggleComponent, TooltipDirective, TransformInputDirective, TypedSimpleChanges, Types } from '@app/shared'; import { AbstractContentForm, AnnotationCreate, AnnotationsSelect, AppLanguageDto, ChatDialogComponent, CheckboxGroupComponent, CodeEditorComponent, ColorPickerComponent, CommentsState, ConfirmClickDirective, ControlErrorsComponent, DateTimeEditorComponent, DialogModel, EditContentForm, FieldDto, FormHintComponent, GeolocationEditorComponent, hasNoValue$, IndeterminateValueDirective, MarkdownDirective, MathHelper, MessageBus, ModalDirective, RadioGroupComponent, ReferenceInputComponent, RichEditorComponent, StarsComponent, TagEditorComponent, ToggleComponent, TooltipDirective, TransformInputDirective, TypedSimpleChanges, Types } from '@app/shared';
import { ReferenceDropdownComponent } from '../references/reference-dropdown.component'; import { ReferenceDropdownComponent } from '../references/reference-dropdown.component';
import { ReferencesCheckboxesComponent } from '../references/references-checkboxes.component'; import { ReferencesCheckboxesComponent } from '../references/references-checkboxes.component';
import { ReferencesEditorComponent } from '../references/references-editor.component'; import { ReferencesEditorComponent } from '../references/references-editor.component';
@ -70,6 +70,9 @@ export class FieldEditorComponent {
@Output() @Output()
public expandedChange = new EventEmitter(); public expandedChange = new EventEmitter();
@Input()
public comments?: CommentsState | null;
@Input({ required: true }) @Input({ required: true })
public hasChatBot!: boolean; public hasChatBot!: boolean;
@ -111,6 +114,8 @@ export class FieldEditorComponent {
public chatDialog = new DialogModel(); public chatDialog = new DialogModel();
public annotations?: Observable<ReadonlyArray<Annotation>>;
public get field() { public get field() {
return this.formModel.field; return this.formModel.field;
} }
@ -119,10 +124,19 @@ export class FieldEditorComponent {
return this.formModel.form; return this.formModel.form;
} }
constructor(
private readonly messageBus: MessageBus,
) {
}
public ngOnChanges(changes: TypedSimpleChanges<this>) { public ngOnChanges(changes: TypedSimpleChanges<this>) {
if (changes.formModel) { if (changes.formModel) {
this.isEmpty = hasNoValue$(this.formModel.form); this.isEmpty = hasNoValue$(this.formModel.form);
} }
if (changes.formModel || changes.comments) {
this.annotations = this.comments?.getAnnotations(this.formModel.fieldPath);
}
} }
public reset() { public reset() {
@ -145,6 +159,18 @@ export class FieldEditorComponent {
this.isExpanded = !this.isExpanded; this.isExpanded = !this.isExpanded;
} }
public annotationCreate(annotation: AnnotationSelection) {
this.messageBus.emit(new AnnotationCreate(this.formModel.fieldPath, annotation));
}
public annotationsSelect(annotation: ReadonlyArray<string>) {
this.messageBus.emit(new AnnotationsSelect(annotation));
}
public annotationsUpdate(annotations: ReadonlyArray<Annotation>) {
this.comments?.updateAnnotations(this.formModel.fieldPath, annotations);
}
public unset() { public unset() {
this.formModel.unset(); this.formModel.unset();
} }

17
frontend/src/app/framework/angular/animations.ts

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * 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 { export function buildSlideRightAnimation(name = 'slideRight', timing = '150ms'): AnimationTriggerMetadata {
return trigger( 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 fadeAnimation = buildFadeAnimation();
export const slideAnimation = buildSlideAnimation(); export const slideAnimation = buildSlideAnimation();
export const slideRightAnimation = buildSlideRightAnimation(); export const slideRightAnimation = buildSlideRightAnimation();

3
frontend/src/app/framework/angular/forms/forms-helper.ts

@ -115,8 +115,7 @@ export function changed$(lhs: AbstractControl, rhs: AbstractControl) {
return combineLatest([ return combineLatest([
value$(lhs), value$(lhs),
value$(rhs), value$(rhs),
]).pipe(map(([lhs, rhs]) => !Types.equals(lhs, rhs, true)), ], (lhs, rhs) => !Types.equals(lhs, rhs, true)).pipe(distinctUntilChanged());
distinctUntilChanged());
} }
export function touchedChange$(form: AbstractControl) { export function touchedChange$(form: AbstractControl) {

32
frontend/src/app/framework/angular/scroll-active.directive.ts

@ -7,7 +7,8 @@
/* eslint-disable @angular-eslint/no-input-rename */ /* 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({ @Directive({
selector: '[sqxScrollActive]', selector: '[sqxScrollActive]',
@ -17,11 +18,14 @@ export class ScrollActiveDirective implements AfterViewInit {
@Input({ alias: 'sqxScrollActive', transform: booleanAttribute }) @Input({ alias: 'sqxScrollActive', transform: booleanAttribute })
public isActive = false; public isActive = false;
@Input({ alias: 'sqxScrollOffset', transform: numberAttribute })
public offset = 0;
@Input('sqxScrollContainer') @Input('sqxScrollContainer')
public container!: HTMLElement; public container?: HTMLElement | string | null;
constructor( constructor(
private readonly element: ElementRef, private readonly element: ElementRef<HTMLElement>,
private readonly renderer: Renderer2, private readonly renderer: Renderer2,
) { ) {
} }
@ -36,27 +40,35 @@ export class ScrollActiveDirective implements AfterViewInit {
private check() { private check() {
if (this.isActive && this.container) { 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) { private scrollInView(parent: HTMLElement, target: HTMLElement) {
const parentRect = parent.getBoundingClientRect(); const boundsParent = parent.getBoundingClientRect();
const targetRect = target.getBoundingClientRect(); const boundsTarget = target.getBoundingClientRect();
const body = document.body; 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; const scrollTop = parent.scrollTop;
if (scrollDiff < 0) { if (scrollDiff < 0) {
this.renderer.setProperty(parent, 'scrollTop', scrollTop + scrollDiff); this.renderer.setProperty(parent, 'scrollTop', scrollTop + scrollDiff);
} else { } else {
const targetHeight = targetRect.height; const targetHeight = boundsTarget.height;
const parentHeight = parentRect.height; const parentHeight = boundsParent.height;
if ((scrollDiff + targetHeight) > parentHeight) { if ((scrollDiff + targetHeight) > parentHeight) {
this.renderer.setProperty(parent, 'scrollTop', scrollTop + scrollDiff - parentHeight + targetHeight); this.renderer.setProperty(parent, 'scrollTop', scrollTop + scrollDiff - parentHeight + targetHeight + this.offset);
} }
} }
} }

11
frontend/src/app/framework/services/message-bus.service.spec.ts

@ -5,20 +5,27 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { NgZone } from '@angular/core';
import { MessageBus } from './message-bus.service'; import { MessageBus } from './message-bus.service';
class Event1 {} class Event1 {}
class Event2 {} class Event2 {}
describe('MessageBus', () => { describe('MessageBus', () => {
const zone = {
run: action => {
action();
},
} as NgZone;
it('should instantiate', () => { it('should instantiate', () => {
const messageBus = new MessageBus(); const messageBus = new MessageBus(zone);
expect(messageBus).toBeDefined(); expect(messageBus).toBeDefined();
}); });
it('should publish events and subscribe', () => { it('should publish events and subscribe', () => {
const messageBus = new MessageBus(); const messageBus = new MessageBus(zone);
const event1 = new Event1(); const event1 = new Event1();
const event2 = new Event2(); const event2 = new Event2();

11
frontend/src/app/framework/services/message-bus.service.ts

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * 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'; import { filter, map, Observable, Subject } from 'rxjs';
interface Message { interface Message {
@ -22,10 +22,17 @@ interface Message {
export class MessageBus { export class MessageBus {
private message$ = new Subject<Message>(); private message$ = new Subject<Message>();
constructor(
private readonly zone: NgZone,
) {
}
public emit<T>(data: T) { public emit<T>(data: T) {
const channel = ((<any>data)['constructor']).name; const channel = ((<any>data)['constructor']).name;
this.message$.next({ channel, data }); this.zone.run(() => {
this.message$.next({ channel, data });
});
} }
public of<T>(messageType: { new(...args: ReadonlyArray<any>): T }): Observable<T> { public of<T>(messageType: { new(...args: ReadonlyArray<any>): T }): Observable<T> {

92
frontend/src/app/shared/components/comments/comment.component.html

@ -1,28 +1,38 @@
<div class="comment row g-0"> <div class="comment row g-0" [class.selected]="commentItem.isSelected" [class.reply]="commentItem.comment.replyTo" [@bounce]="commentItem.isSelected"
<div class="col-auto pe-2"> [sqxScrollActive]="true"
<img class="user-picture" title="{{comment.user | sqxUserNameRef}}" [src]="comment.user | sqxUserPictureRef"> [sqxScrollOffset]="20"
</div> [sqxScrollContainer]="scrollContainer">
<div class="comment-text row g-0" sqxSc *ngIf="snapshot.mode !== 'Edit'"
[sqxScrollActive]="commentItem.isSelected"
[sqxScrollOffset]="-20"
[sqxScrollContainer]="scrollContainer">
<div class="col-auto pe-2">
<img class="user-picture" title="{{commentItem.comment.user | sqxUserNameRef}}" [src]="commentItem.comment.user | sqxUserPictureRef">
</div>
<ng-container *ngIf="!snapshot.isEditing; else editing">
<div class="col col-text"> <div class="col col-text">
<div class="comment-message"> <div class="comment-message">
<div class="user-row"> <div class="user-row">
<div class="user-ref" [title]="comment.user | sqxUserNameRef:null">{{comment.user | sqxUserNameRef:null}}</div> <div class="user-ref" [title]="commentItem.comment.user | sqxUserNameRef:null">{{commentItem.comment.user | sqxUserNameRef:null}}</div>
</div> </div>
<div [innerHTML]="comment.text | sqxMarkdown | sqxSafeHtml"></div> <div [innerHTML]="commentItem.comment.text | sqxMarkdown | sqxSafeHtml"></div>
<div class="comment-created text-muted"> <div class="comment-created text-muted">
<ng-container *ngIf="canFollow && comment.url"> <ng-container *ngIf="canFollow && commentItem.comment.url">
<a [routerLink]="comment.url">{{ 'comments.follow' | sqxTranslate }}</a>&nbsp; <a [routerLink]="commentItem.comment.url">{{ 'comments.follow' | sqxTranslate }}</a>&nbsp;
</ng-container> </ng-container>
{{comment.time | sqxFromNow}} {{commentItem.comment.time | sqxFromNow}}
</div> </div>
</div> </div>
</div> </div>
<div class="actions"> <div class="actions">
<button *ngIf="isEditable && canAnswer" type="button" class="btn btn-sm btn-text-secondary" (click)="startReply()">
<i class="icon-enter"></i>
</button>
<button *ngIf="isEditable && canEdit" type="button" class="btn btn-sm btn-text-secondary" (click)="startEdit()"> <button *ngIf="isEditable && canEdit" type="button" class="btn btn-sm btn-text-secondary" (click)="startEdit()">
<i class="icon-pencil"></i> <i class="icon-pencil"></i>
</button> </button>
@ -36,27 +46,65 @@
<i class="icon-bin2"></i> <i class="icon-bin2"></i>
</button> </button>
</div> </div>
</div>
<ng-container *ngIf="snapshot.mode === 'Edit'">
<form (ngSubmit)="update()">
<textarea class="form-control mb-1" name="{{commentItem.comment}}" [(ngModel)]="editingText"
sqxFocusOnInit
[mention]="$any(mentionUsers)"
[mentionConfig]="mentionConfig"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
(keydown)="updateOnEnter($event)">
</textarea>
<div>
<button type="submit" class="btn btn-sm btn-primary">
<i class="icon-enter"></i> {{ 'common.save' | sqxTranslate }}
</button>
<button type="button" class="btn btn-sm btn-text-secondary me-1" (click)="cancelEditOrReply()">
{{ 'common.cancel' | sqxTranslate }}
</button>
</div>
</form>
</ng-container> </ng-container>
<ng-template #editing> <div class="replies" *ngIf="commentItem.replies.length > 0 || snapshot.mode === 'Reply'">
<div class="col"> <sqx-comment *ngFor="let item of commentItem.replies; trackBy: trackByComment;"
<form (ngSubmit)="update()"> canEdit="true"
<textarea class="form-control mb-1" name="{{commentIndex}}" sqxFocusOnInit [(ngModel)]="editingText" canFollow="false"
[commentItem]="item"
[comments]="comments"
[mentionConfig]="mentionConfig"
[mentionUsers]="mentionUsers"
[userToken]="userToken">
</sqx-comment>
<ng-container *ngIf="snapshot.mode === 'Reply'">
<form [formGroup]="replyForm.form" (ngSubmit)="reply()">
<textarea class="form-control mb-1" name="text" formControlName="text"
sqxFocusOnInit
[mention]="$any(mentionUsers)" [mention]="$any(mentionUsers)"
[mentionConfig]="mentionConfig" [mentionConfig]="mentionConfig"
(keydown)="updateWhenEnter($event)"> autocomplete="off"
autocorrect="off"
autocapitalize="off"
(keydown)="replayOnEnter($event)">
</textarea> </textarea>
<div> <div>
<button type="button" class="btn btn-sm btn-text-secondary me-1" (click)="cancelEdit()"> <button type="submit" class="btn btn-sm btn-primary">
{{ 'common.cancel' | sqxTranslate }} <i class="icon-enter"></i> {{ 'common.reply' | sqxTranslate }}
</button> </button>
<button type="submit" class="btn btn-sm btn-primary"> <button type="button" class="btn btn-sm btn-text-secondary me-1" (click)="cancelEditOrReply()">
<i class="icon-enter"></i> {{ 'common.save' | sqxTranslate }} {{ 'common.cancel' | sqxTranslate }}
</button> </button>
</div> </div>
</form> </form>
</div> </ng-container>
</ng-template> </div>
</div> </div>

40
frontend/src/app/shared/components/comments/comment.component.scss

@ -4,7 +4,7 @@
/* stylelint-disable no-descending-specificity */ /* stylelint-disable no-descending-specificity */
.actions { .actions {
@include absolute(-5px, -15px, auto, auto); @include absolute(0, 0, auto, auto);
background: $color-white; background: $color-white;
border: 0; border: 0;
border-radius: 0; border-radius: 0;
@ -34,31 +34,51 @@
} }
.comment { .comment {
font-size: $font-small; background-color: $color-white;
border: 1px solid transparent;
border-radius: 2px;
font-size: .85rem;
font-weight: normal; font-weight: normal;
line-height: 1.25rem; line-height: 1.25rem;
margin: 0; margin-top: 0;
margin-bottom: .75rem; margin-bottom: .25rem;
padding: .5rem;
padding-top: .375rem;
position: relative; position: relative;
&-message { &.reply {
margin-bottom: .375rem; margin-bottom: .75rem;
padding: 0;
padding-left: 1.5rem;
}
&.selected {
border-color: $color-theme-brand;
} }
&-created { &-created {
font-size: $font-small; font-size: $font-small;
} }
&:hover { &-text {
.actions { &:hover {
display: block; .actions {
display: block;
}
} }
} }
} }
.replies {
border-top: 1px solid $color-border;
margin-bottom: 0;
margin-top: .5rem;
padding-top: .5rem;
}
:host { :host {
&:last-child { &:last-child {
.comment { & > .comment {
margin-bottom: 0; margin-bottom: 0;
} }
} }

97
frontend/src/app/shared/components/comments/comment.component.ts

@ -5,17 +5,17 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * 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 { booleanAttribute, ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { MentionConfig, MentionModule } from 'angular-mentions'; import { MentionConfig, MentionModule } from 'angular-mentions';
import { ConfirmClickDirective, FocusOnInitDirective, FromNowPipe, MarkdownPipe, SafeHtmlPipe, TooltipDirective, TranslatePipe } from '@app/framework'; import { bounceAnimation, ConfirmClickDirective, FocusOnInitDirective, FromNowPipe, MarkdownPipe, SafeHtmlPipe, ScrollActiveDirective, TooltipDirective, TranslatePipe } from '@app/framework';
import { Comment, ContributorDto, DialogService, Keys, SharedArray, StatefulComponent } from '@app/shared/internal'; import { CommentItem, CommentsState, ContributorDto, DialogService, Keys, StatefulComponent, UpsertCommentForm } from '@app/shared/internal';
import { UserNameRefPipe, UserPictureRefPipe } from '../pipes'; import { UserNameRefPipe, UserPictureRefPipe } from '../pipes';
interface State { interface State {
isEditing: boolean; mode?: 'Normal' | 'Edit' | 'Reply';
} }
@Component({ @Component({
@ -24,6 +24,9 @@ interface State {
styleUrls: ['./comment.component.scss'], styleUrls: ['./comment.component.scss'],
templateUrl: './comment.component.html', templateUrl: './comment.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
bounceAnimation,
],
imports: [ imports: [
ConfirmClickDirective, ConfirmClickDirective,
FocusOnInitDirective, FocusOnInitDirective,
@ -32,8 +35,11 @@ interface State {
MarkdownPipe, MarkdownPipe,
MentionModule, MentionModule,
NgIf, NgIf,
NgForOf,
ReactiveFormsModule,
RouterLink, RouterLink,
SafeHtmlPipe, SafeHtmlPipe,
ScrollActiveDirective,
TooltipDirective, TooltipDirective,
TranslatePipe, TranslatePipe,
UserNameRefPipe, UserNameRefPipe,
@ -44,6 +50,9 @@ export class CommentComponent extends StatefulComponent<State> {
@Input({ transform: booleanAttribute }) @Input({ transform: booleanAttribute })
public canFollow?: boolean | null; public canFollow?: boolean | null;
@Input({ transform: booleanAttribute })
public canAnswer?: boolean | null;
@Input({ transform: booleanAttribute }) @Input({ transform: booleanAttribute })
public canDelete?: boolean | null; public canDelete?: boolean | null;
@ -54,21 +63,27 @@ export class CommentComponent extends StatefulComponent<State> {
public confirmDelete?: boolean | null = true; public confirmDelete?: boolean | null = true;
@Input({ required: true }) @Input({ required: true })
public comment!: Comment; public commentItem!: CommentItem;
@Input({ required: true })
public commentIndex!: number;
@Input({ required: true }) @Input({ required: true })
public comments!: SharedArray<Comment>; public comments!: CommentsState;
@Input() @Input()
public userToken = ''; public userToken = '';
@Input() @Input()
public currenUrl = '';
@Input({ required: true })
public mentionUsers?: ReadonlyArray<ContributorDto>; public mentionUsers?: ReadonlyArray<ContributorDto>;
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 isDeletable = false;
public isEditable = false; public isEditable = false;
@ -78,24 +93,28 @@ export class CommentComponent extends StatefulComponent<State> {
constructor( constructor(
private readonly dialogs: DialogService, private readonly dialogs: DialogService,
) { ) {
super({ isEditing: false }); super({});
} }
public ngOnChanges() { public ngOnChanges() {
const isMyComment = this.comment.user === this.userToken; const isMyComment = this.commentItem.comment.user === this.userToken;
this.isDeletable = isMyComment; this.isDeletable = isMyComment;
this.isEditable = isMyComment; this.isEditable = isMyComment;
} }
public startEdit() { 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() { public cancelEditOrReply() {
this.next({ isEditing: false }); this.next({ mode: 'Normal' });
} }
public delete() { public delete() {
@ -103,17 +122,24 @@ export class CommentComponent extends StatefulComponent<State> {
return; return;
} }
this.comments.remove(this.commentIndex); this.comments.remove(this.commentItem.index);
} }
public updateWhenEnter(event: KeyboardEvent) { public reply() {
if (Keys.isEnter(event) && !event.altKey && !event.shiftKey && !event.defaultPrevented) { if (!this.canAnswer) {
event.preventDefault(); return;
event.stopImmediatePropagation(); }
event.stopPropagation();
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() { public update() {
@ -131,8 +157,27 @@ export class CommentComponent extends StatefulComponent<State> {
} }
}); });
} else { } else {
this.comments.set(this.commentIndex, { ...this.comment, text }); this.comments.update(this.commentItem.index, { text });
this.cancelEdit(); }
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;
}
} }

55
frontend/src/app/shared/components/comments/comments.component.html

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

12
frontend/src/app/shared/components/comments/comments.component.scss

@ -13,25 +13,25 @@
.comments { .comments {
&-list { &-list {
background-color: $color-background;
flex-grow: 1; flex-grow: 1;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
padding: 1rem; padding: .5rem;
padding-left: 1.5rem;
} }
&-footer { &-header {
border: 0;
border-top: 1px solid $color-border;
flex-shrink: 0; flex-shrink: 0;
} }
} }
.form-control { .form-control {
border: 0; border-color: $color-white;
border-bottom-color: $color-border;
border-radius: 0; border-radius: 0;
&:focus { &:focus {
box-shadow: none; box-shadow: none;
border-color: $color-theme-brand;
} }
} }

82
frontend/src/app/shared/components/comments/comments.component.ts

@ -6,13 +6,13 @@
*/ */
import { AsyncPipe, NgFor, NgIf } from '@angular/common'; 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 { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { MentionConfig, MentionModule } from 'angular-mentions'; import { MentionConfig, MentionModule } from 'angular-mentions';
import { Observable } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { ResizedDirective, TranslatePipe } from '@app/framework'; import { MessageBus, ResizedDirective, Subscriptions, TranslatePipe } from '@app/framework';
import { AuthService, CollaborationService, Comment, ContributorsState, SharedArray, UpsertCommentForm } from '@app/shared/internal'; import { AnnotationCreateAfterNavigate, AnnotationsSelectAfterNavigate, AuthService, CommentsState, ContributorsState, UpsertCommentForm } from '@app/shared/internal';
import { CommentComponent } from './comment.component'; import { CommentComponent } from './comment.component';
@Component({ @Component({
@ -32,64 +32,68 @@ import { CommentComponent } from './comment.component';
TranslatePipe, TranslatePipe,
], ],
}) })
export class CommentsComponent { export class CommentsComponent implements OnInit {
@ViewChild('scrollContainer', { static: false }) private readonly subscriptions = new Subscriptions();
public scrollContainer!: ElementRef<HTMLDivElement>; private readonly selection = new BehaviorSubject<ReadonlyArray<string>>([]);
private reference?: AnnotationCreateAfterNavigate;
@ViewChildren(CommentComponent) @ViewChild('input', { static: false })
public children!: QueryList<CommentComponent>; public input!: ElementRef<HTMLInputElement>;
@Input() @Input()
public commentsId = ''; public commentsId = '';
public commentsUrl!: string;
public commentsArray!: Observable<SharedArray<Comment>>;
public commentForm = new UpsertCommentForm();
public mentionUsers = this.contributorsState.contributors; public mentionUsers = this.contributorsState.contributors;
public mentionConfig: MentionConfig = { dropUp: true, labelKey: 'contributorEmail' }; 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, constructor(authService: AuthService,
private readonly collaboration: CollaborationService, public readonly commentsState: CommentsState,
public readonly router: Router,
private readonly contributorsState: ContributorsState, private readonly contributorsState: ContributorsState,
private readonly router: Router, private readonly messageBus: MessageBus,
) { ) {
this.userToken = authService.user!.token; this.commentUser = authService.user!.token;
}
public ngOnChanges() {
this.commentsArray = this.collaboration.getArray<Comment>('stream');
} }
public scrollDown() { public ngOnInit() {
if (this.scrollContainer && this.scrollContainer.nativeElement) { this.subscriptions.add(
let isEditing = false; this.messageBus.of(AnnotationsSelectAfterNavigate)
.subscribe(message => {
this.selection.next(message.annotations);
}));
this.children.forEach(x => { this.subscriptions.add(
isEditing = isEditing || x.snapshot.isEditing; this.messageBus.of(AnnotationCreateAfterNavigate)
}); .subscribe(message => {
this.reference = message;
if (!isEditing) { this.input.nativeElement.focus();
const height = this.scrollContainer.nativeElement.scrollHeight; }));
this.scrollContainer.nativeElement.scrollTop = height;
}
}
} }
public comment(comments: SharedArray<Comment>) { public comment() {
const value = this.commentForm.submit(); const { text } = this.commentForm.submit() || {};
if (text && text.length > 0) {
const { from, to } = this.reference?.annotation || {};
if (value?.text && value.text.length > 0) { this.commentsState.create(this.commentUser, text, this.router.url, { editorId: this.reference?.editorId, from, to });
comments.add({ text: value.text, url: this.router.url, time: new Date().toISOString(), user: this.userToken });
} }
this.commentForm.submitCompleted(); this.commentForm.submitCompleted();
} }
public trackByComment(index: number) { public blurComment() {
return index; setTimeout(() => {
this.reference = undefined;
}, 100);
}
public trackByComment(_: number, item: { index: number }) {
return item.index;
} }
} }

44
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 { AfterViewInit, booleanAttribute, ChangeDetectionStrategy, Component, ElementRef, EventEmitter, forwardRef, Input, OnDestroy, Output, ViewChild } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { BehaviorSubject, catchError, of, switchMap } from 'rxjs'; 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 { ApiUrlConfig, AppsState, AssetDto, AssetsService, AssetUploaderState, ContentDto, DialogModel, getContentValue, LanguageDto, ResourceLoaderService, StatefulControlComponent, Types } from '@app/shared/internal';
import { AssetDialogComponent } from '../assets/asset-dialog.component'; import { AssetDialogComponent } from '../assets/asset-dialog.component';
import { AssetSelectorComponent } from '../assets/asset-selector.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 { export class RichEditorComponent extends StatefulControlComponent<{}, string> implements AfterViewInit, OnDestroy {
private readonly assetId = new BehaviorSubject<string | null>(null); private readonly assetId = new BehaviorSubject<string | null>(null);
private editorWrapper: any; private editorWrapper?: SquidexEditorWrapper;
private value?: string; private value?: string;
private currentContents?: ResolvablePromise<any>; private currentContents?: ResolvablePromise<any>;
private currentAssets?: ResolvablePromise<any>; private currentAssets?: ResolvablePromise<any>;
@ -49,9 +49,24 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im
@Output() @Output()
public assetPluginClick = new EventEmitter<any>(); public assetPluginClick = new EventEmitter<any>();
@Output()
public annotationsCreate = new EventEmitter<AnnotationSelection>();
@Output()
public annotationsUpdate = new EventEmitter<ReadonlyArray<Annotation>>();
@Output()
public annotationsSelect = new EventEmitter<ReadonlyArray<string>>();
@Input({ required: true }) @Input({ required: true })
public hasChatBot = false; public hasChatBot = false;
@Input()
public hasAnnotations = false;
@Input()
public annotations?: ReadonlyArray<Annotation> | null;
@Input() @Input()
public schemaIds?: ReadonlyArray<string>; public schemaIds?: ReadonlyArray<string>;
@ -106,7 +121,13 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im
public ngOnDestroy() { public ngOnDestroy() {
if (this.editorWrapper) { if (this.editorWrapper) {
this.editorWrapper.destroy?.(); this.editorWrapper.destroy?.();
this.editorWrapper = null; this.editorWrapper = undefined;
}
}
public ngOnChanges(changes: TypedSimpleChanges<RichEditorComponent>) {
if (changes.annotations) {
this.editorWrapper?.setAnnotations(this.annotations);
} }
} }
@ -153,17 +174,28 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im
onChange: (value: string | undefined) => { onChange: (value: string | undefined) => {
this.callChange(value); 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) => { onEditContent: (schemaName, id) => {
const url = this.apiUrl.buildUrl(`/app/${this.appsState.appName}/content/${schemaName}/${id}`); const url = this.apiUrl.buildUrl(`/app/${this.appsState.appName}/content/${schemaName}/${id}`);
window.open(url, '_blank'); window.open(url, '_blank');
}, },
onEditAsset: id => {
this.assetId.next(id);
},
mode: this.mode, mode: this.mode,
annotations: this.annotations,
appName: this.appsState.appName, appName: this.appsState.appName,
baseUrl: this.apiUrl.buildUrl(''), baseUrl: this.apiUrl.buildUrl(''),
canAddAnnotation: this.hasAnnotations,
canSelectAIText: this.hasChatBot, canSelectAIText: this.hasChatBot,
canSelectAssets: true, canSelectAssets: true,
canSelectContents: !!this.schemaIds, canSelectContents: !!this.schemaIds,

2
frontend/src/app/shared/internal.ts

@ -50,7 +50,7 @@ export * from './state/backups.forms';
export * from './state/backups.state'; export * from './state/backups.state';
export * from './state/clients.forms'; export * from './state/clients.forms';
export * from './state/clients.state'; export * from './state/clients.state';
export * from './state/comments'; export * from './state/comments.state';
export * from './state/comments.form'; export * from './state/comments.form';
export * from './state/contents.forms-helpers'; export * from './state/contents.forms-helpers';
export * from './state/contents.forms.visitors'; export * from './state/contents.forms.visitors';

52
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'; import { AuthService, CollaborationProvider, CollaborationService, UIOptions } from '@app/shared/internal';
describe('CollaborationService', () => { describe('CollaborationService', () => {
let service: CollaborationService; let collaborationService: CollaborationService;
let provider!: CollaborationProvider; let collaborationProvider!: CollaborationProvider;
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ providers: [{ provide: UIOptions, useValue: new UIOptions({}) }] }); TestBed.configureTestingModule({
providers: [
{
provide: UIOptions,
useValue: new UIOptions({}),
},
],
});
TestBed.runInInjectionContext(() => { TestBed.runInInjectionContext(() => {
service = new CollaborationService(Mock.ofType<AuthService>().object); collaborationService = new CollaborationService(Mock.ofType<AuthService>().object);
}); });
service.providerFactory = (_, doc) => { collaborationService.providerFactory = (_, doc) => {
provider = { awareness: new Awareness(doc), doc, destroy: () => {} }; 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', () => { it('should also get map if disconnected', () => {
service.connect(null); collaborationService.connect(null);
let map: any = undefined; let map: any = undefined;
service.getMap('map').subscribe(v => map = v); collaborationService.getMap('map').subscribe(v => map = v);
expect(map).toBeDefined(); expect(map).toBeDefined();
}); });
it('should also get array if disconnected', () => { it('should also get array if disconnected', () => {
service.connect(null); collaborationService.connect(null);
let array: any = undefined; let array: any = undefined;
service.getArray('array').subscribe(v => array = v); collaborationService.getArray('array').subscribe(v => array = v);
expect(array).toBeDefined(); expect(array).toBeDefined();
}); });
it('should add to map', () => { it('should add to map', () => {
const map = service.getMap('map'); const map = collaborationService.getMap('map');
let values: Record<string, any> = {}; let values: Record<string, any> = {};
@ -64,7 +72,7 @@ describe('CollaborationService', () => {
}); });
it('should remove from map', () => { it('should remove from map', () => {
const map = service.getMap('map'); const map = collaborationService.getMap('map');
let values: Record<string, any> = {}; let values: Record<string, any> = {};
@ -80,7 +88,7 @@ describe('CollaborationService', () => {
}); });
it('should add to array', () => { it('should add to array', () => {
const array = service.getArray('array'); const array = collaborationService.getArray('array');
let items: ReadonlyArray<any> = []; let items: ReadonlyArray<any> = [];
@ -95,7 +103,7 @@ describe('CollaborationService', () => {
}); });
it('should replace in array', () => { it('should replace in array', () => {
const array = service.getArray('array'); const array = collaborationService.getArray('array');
let items: ReadonlyArray<any> = []; let items: ReadonlyArray<any> = [];
@ -111,7 +119,7 @@ describe('CollaborationService', () => {
}); });
it('should remove from array', () => { it('should remove from array', () => {
const array = service.getArray('array'); const array = collaborationService.getArray('array');
let items: ReadonlyArray<any> = []; let items: ReadonlyArray<any> = [];
@ -128,11 +136,11 @@ describe('CollaborationService', () => {
it('should provide one awareness per user', () => { it('should provide one awareness per user', () => {
let users: any[] = []; let users: any[] = [];
service.userChanges.subscribe(u => users = u); collaborationService.userChanges.subscribe(u => users = u);
provider.awareness.setLocalStateField('user', { id: '0', displayName: 'User0' }); collaborationProvider.awareness.setLocalStateField('user', { id: '0', displayName: 'User0' });
provider.awareness.setLocalStateField('key1', 101); collaborationProvider.awareness.setLocalStateField('key1', 101);
provider.awareness.setLocalStateField('key2', 102); collaborationProvider.awareness.setLocalStateField('key2', 102);
setOtherAwareness({ setOtherAwareness({
user: { id: '1', displayName: 'User1' }, user: { id: '1', displayName: 'User1' },
@ -172,6 +180,6 @@ describe('CollaborationService', () => {
otherAwarness.setLocalState(state); otherAwarness.setLocalState(state);
applyAwarenessUpdate(provider.awareness, encodeAwarenessUpdate(otherAwarness, [otherDoc.clientID], otherAwarness.getStates()), otherAwarness); applyAwarenessUpdate(collaborationProvider.awareness, encodeAwarenessUpdate(otherAwarness, [otherDoc.clientID], otherAwarness.getStates()), otherAwarness);
} }
}); });

10
frontend/src/app/shared/services/collaboration.service.ts

@ -106,11 +106,11 @@ export class CollaborationService {
} }
public getArray<T>(name: string) { public getArray<T>(name: string) {
return this.provider.pipe(map(p => new SharedArray<T>(p?.doc.getArray(name)))); return this.provider.pipe(map(p => new SharedArray<T>(p?.doc, p?.doc.getArray(name))));
} }
public getMap<T>(name: string) { public getMap<T>(name: string) {
return this.provider.pipe(map(p => new SharedMap<T>(p?.doc.getMap(name)))); return this.provider.pipe(map(p => new SharedMap<T>(p?.doc, p?.doc.getMap(name))));
} }
public updateAwareness(key: string, value: any) { public updateAwareness(key: string, value: any) {
@ -130,7 +130,8 @@ export class SharedMap<T> {
} }
constructor( constructor(
private readonly source: Y.Map<T> | undefined, public readonly doc: Y.Doc | undefined,
public readonly source: Y.Map<T> | undefined,
) { ) {
this.value$ = new BehaviorSubject(source?.toJSON() || {}); this.value$ = new BehaviorSubject(source?.toJSON() || {});
@ -160,7 +161,8 @@ export class SharedArray<T> {
} }
constructor( constructor(
private readonly source: Y.Array<T> | undefined, public readonly doc: Y.Doc | undefined,
public readonly source: Y.Array<T> | undefined,
) { ) {
this.items$ = new BehaviorSubject<ReadonlyArray<T>>(source?.toJSON() || []); this.items$ = new BehaviorSubject<ReadonlyArray<T>>(source?.toJSON() || []);

2
frontend/src/app/shared/state/comments.form.ts

@ -7,7 +7,7 @@
import { UntypedFormControl, Validators } from '@angular/forms'; import { UntypedFormControl, Validators } from '@angular/forms';
import { ExtendedFormGroup, Form } from '@app/framework'; import { ExtendedFormGroup, Form } from '@app/framework';
import { Comment } from './comments'; import { Comment } from './comments.state';
export class UpsertCommentForm extends Form<ExtendedFormGroup, Comment> { export class UpsertCommentForm extends Form<ExtendedFormGroup, Comment> {
constructor() { constructor() {

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

201
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<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[] };

23
frontend/src/app/shared/state/comments.ts

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

30
frontend/src/app/shared/utils/messages.ts

@ -10,3 +10,33 @@ export class HistoryChannelUpdated {}
export class QueryExecuted {} export class QueryExecuted {}
export class ClientTourStated {} 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<string>,
) {
}
}
export class AnnotationsSelectAfterNavigate {
constructor(
public readonly annotations: ReadonlyArray<string>,
) {
}
}

18
frontend/src/app/shell/pages/internal/notification-dropdown.component.html

@ -2,24 +2,26 @@
<span class="nav-link dropdown-toggle" (click)="modalMenu.show()"> <span class="nav-link dropdown-toggle" (click)="modalMenu.show()">
<i class="icon-comments"></i> <i class="icon-comments"></i>
<span class="badge rounded-pill badge-danger" *ngIf="commentsUnread | async; let unread">{{unread}}</span> <span class="badge rounded-pill badge-danger" *ngIf="commentsState.unreadCountChanges| async; let unread">{{unread}}</span>
</span> </span>
</li> </li>
<sqx-dropdown-menu *sqxModal="modalMenu;onRoot:false" [sqxAnchoredTo]="button" [scrollTop]="scrollMe.nativeElement.scrollHeight" offset="10" #scrollMe> <sqx-dropdown-menu *sqxModal="modalMenu;onRoot:false" [sqxAnchoredTo]="button" [scrollTop]="scrollMe.nativeElement.scrollHeight" offset="10" #scrollMe>
<ng-container *ngIf="commentsArray?.itemsChanges | async; let comments"> <ng-container *ngIf="commentItems | async; let items">
<small class="text-muted" *ngIf="comments.length === 0"> <small class="text-muted" *ngIf="items.length === 0">
{{ 'notifications.empty' | sqxTranslate}} {{ 'notifications.empty' | sqxTranslate}}
</small> </small>
<sqx-comment *ngFor="let comment of comments.slice().reverse(); trackBy: trackByComment; let i = index" <sqx-comment *ngFor="let item of items.slice().reverse(); trackBy: trackByComment; let i = index"
[comment]="$any(comment)" canAnswer="false"
[commentIndex]="i"
[comments]="commentsArray!"
confirmDelete="false" confirmDelete="false"
canDelete="true" canDelete="true"
canFollow="true" canFollow="true"
[userToken]="userToken"> [commentItem]="item"
[comments]="commentsState"
[mentionConfig]="{}"
[mentionUsers]="undefined"
[userToken]="commentUser">
</sqx-comment> </sqx-comment>
</ng-container> </ng-container>
</sqx-dropdown-menu> </sqx-dropdown-menu>

55
frontend/src/app/shell/pages/internal/notification-dropdown.component.ts

@ -7,9 +7,9 @@
import { AsyncPipe, NgFor, NgIf } from '@angular/common'; import { AsyncPipe, NgFor, NgIf } from '@angular/common';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs'; import { of } from 'rxjs';
import { map, switchMap, tap } from 'rxjs/operators'; import { tap } from 'rxjs/operators';
import { AuthService, CollaborationService, Comment, CommentComponent, DropdownMenuComponent, ModalDirective, ModalModel, ModalPlacementDirective, SharedArray, Subscriptions, TranslatePipe } from '@app/shared'; import { AuthService, CollaborationService, CommentComponent, CommentsState, DropdownMenuComponent, ModalDirective, ModalModel, ModalPlacementDirective, Subscriptions, TranslatePipe } from '@app/shared';
@Component({ @Component({
standalone: true, standalone: true,
@ -19,6 +19,7 @@ import { AuthService, CollaborationService, Comment, CommentComponent, DropdownM
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
providers: [ providers: [
CollaborationService, CollaborationService,
CommentsState,
], ],
imports: [ imports: [
AsyncPipe, AsyncPipe,
@ -36,60 +37,28 @@ export class NotificationDropdownComponent implements OnInit {
public modalMenu = new ModalModel(); public modalMenu = new ModalModel();
public commentsArray?: SharedArray<Comment>; public commentUser: string;
public commentsUnread!: Observable<number>; public commentItems = this.commentsState.getGroupedComments(of([]));
public userToken: string;
constructor(authService: AuthService, 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() { public ngOnInit() {
this.collaborations.connect('users/collaboration'); this.collaboration.connect('users/collaboration');
const comments$ = this.collaborations.getArray<Comment>('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.subscriptions.add( this.subscriptions.add(
this.modalMenu.isOpenChanges.pipe( this.modalMenu.isOpenChanges.pipe(
tap(_ => { 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) { public trackByComment(index: number) {
return index; return index;
} }

5
frontend/tsconfig.spec.json

@ -2,10 +2,7 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./out-tsc/spec", "outDir": "./out-tsc/spec"
"types": [
"jasmine"
]
}, },
"include": [ "include": [
"src/**/*.spec.ts", "src/**/*.spec.ts",

Loading…
Cancel
Save