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.rename": "Rename",
"common.renameTag": "Rename Tag",
"common.reply": "Reply",
"common.repository": "Repository",
"common.requiredHint": "required",
"common.reset": "Reset",

1
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",

1
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",

1
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",

1
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",

1
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": "重置",

1
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",

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

@ -17,6 +17,8 @@ declare class SquidexEditorWrapper {
setIsDisabled(isDisabled: boolean): void;
setAnnotations(annotations?: ReadonlyArray<Annotation> | 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<Annotation>;
annotations?: ReadonlyArray<Annotation> | null;
}
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'">
<sqx-content-editor
(loadLatest)="loadLatest()"
[(language)]="language"
[(contentId)]="contentId"
[isNew]="!content"
[isDeleted]="content?.isDeleted"
[contentForm]="contentForm"
[contentFormCompare]="contentFormCompare"
[contentVersion]="contentVersion"
[formContext]="formContext"
[language]="language"
(languageChange)="language = $event"
[languages]="languages"
(loadLatest)="loadLatest()"
[schema]="schema"
[showIdInput]="showIdInput"
[(contentId)]="contentId">
[showIdInput]="showIdInput">
</sqx-content-editor>
</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 { 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 => {

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

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

6
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,

5
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,

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 { 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) {

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

@ -13,6 +13,7 @@
[class.col-6]="!isComparing && child.field.properties.isHalfWidth">
<sqx-field-editor *ngIf="!(child.hiddenChanges | async)"
[canUnset]="canUnset"
[comments]="null"
[form]="form"
[formContext]="formContext"
[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">
<sqx-field-editor
[canUnset]="!(isDisabled | async)"
[comments]="commentsState"
[displaySuffix]="prefix(language)"
[form]="form"
[formContext]="formContext"
@ -43,6 +44,7 @@
<ng-template #singleControl>
<sqx-field-editor
[canUnset]="!(isDisabled | async)"
[comments]="commentsState"
[form]="form"
[formContext]="formContext"
[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 { 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 { 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 { FieldEditorComponent } from './field-editor.component';
import { FieldLanguagesComponent } from './field-languages.component';
@ -86,6 +86,7 @@ export class ContentFieldComponent {
}
constructor(
@Optional() public readonly commentsState: CommentsState,
private readonly appsState: AppsState,
private readonly localStore: LocalStoreService,
private readonly translations: TranslationsService,

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

@ -195,9 +195,14 @@
<ng-container *ngSwitchCase="'RichText'">
<sqx-rich-editor #editor
mode="Html"
[annotations]="annotations | async"
(annotationsCreate)="annotationCreate($event)"
(annotationsSelect)="annotationsSelect($event)"
(annotationsUpdate)="annotationsUpdate($event)"
[classNames]="field.rawProperties.classNames"
[formControl]="$any(fieldForm)"
[folderId]="field.rawProperties.folderId"
[hasAnnotations]="!!comments"
[hasChatBot]="hasChatBot"
[language]="language"
[languages]="languages"
@ -210,9 +215,14 @@
<ng-container *ngSwitchCase="'Markdown'">
<sqx-rich-editor #editor
mode="Markdown"
[annotations]="annotations | async"
(annotationsCreate)="annotationCreate($event)"
(annotationsSelect)="annotationsSelect($event)"
(annotationsUpdate)="annotationsUpdate($event)"
[classNames]="undefined"
[formControl]="$any(fieldForm)"
[folderId]="field.rawProperties.folderId"
[hasAnnotations]="!!comments"
[hasChatBot]="hasChatBot"
[language]="language"
[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 { AbstractControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
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 { ReferencesCheckboxesComponent } from '../references/references-checkboxes.component';
import { ReferencesEditorComponent } from '../references/references-editor.component';
@ -70,6 +70,9 @@ export class FieldEditorComponent {
@Output()
public expandedChange = new EventEmitter();
@Input()
public comments?: CommentsState | null;
@Input({ required: true })
public hasChatBot!: boolean;
@ -111,6 +114,8 @@ export class FieldEditorComponent {
public chatDialog = new DialogModel();
public annotations?: Observable<ReadonlyArray<Annotation>>;
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<this>) {
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<string>) {
this.messageBus.emit(new AnnotationsSelect(annotation));
}
public annotationsUpdate(annotations: ReadonlyArray<Annotation>) {
this.comments?.updateAnnotations(this.formModel.fieldPath, annotations);
}
public 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.
*/
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();

3
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) {

32
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<HTMLElement>,
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);
}
}
}

11
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();

11
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<Message>();
constructor(
private readonly zone: NgZone,
) {
}
public emit<T>(data: T) {
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> {

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

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

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

97
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<State> {
@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<State> {
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<Comment>;
public comments!: CommentsState;
@Input()
public userToken = '';
@Input()
public currenUrl = '';
@Input({ required: true })
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 isEditable = false;
@ -78,24 +93,28 @@ export class CommentComponent extends StatefulComponent<State> {
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<State> {
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<State> {
}
});
} 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;
}
}

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

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

82
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<HTMLDivElement>;
export class CommentsComponent implements OnInit {
private readonly subscriptions = new Subscriptions();
private readonly selection = new BehaviorSubject<ReadonlyArray<string>>([]);
private reference?: AnnotationCreateAfterNavigate;
@ViewChildren(CommentComponent)
public children!: QueryList<CommentComponent>;
@ViewChild('input', { static: false })
public input!: ElementRef<HTMLInputElement>;
@Input()
public commentsId = '';
public commentsUrl!: string;
public commentsArray!: Observable<SharedArray<Comment>>;
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<Comment>('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<Comment>) {
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;
}
}

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 { 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<string | null>(null);
private editorWrapper: any;
private editorWrapper?: SquidexEditorWrapper;
private value?: string;
private currentContents?: ResolvablePromise<any>;
private currentAssets?: ResolvablePromise<any>;
@ -49,9 +49,24 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im
@Output()
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 })
public hasChatBot = false;
@Input()
public hasAnnotations = false;
@Input()
public annotations?: ReadonlyArray<Annotation> | null;
@Input()
public schemaIds?: ReadonlyArray<string>;
@ -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<RichEditorComponent>) {
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,

2
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';

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';
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<AuthService>().object);
collaborationService = new CollaborationService(Mock.ofType<AuthService>().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<string, any> = {};
@ -64,7 +72,7 @@ describe('CollaborationService', () => {
});
it('should remove from map', () => {
const map = service.getMap('map');
const map = collaborationService.getMap('map');
let values: Record<string, any> = {};
@ -80,7 +88,7 @@ describe('CollaborationService', () => {
});
it('should add to array', () => {
const array = service.getArray('array');
const array = collaborationService.getArray('array');
let items: ReadonlyArray<any> = [];
@ -95,7 +103,7 @@ describe('CollaborationService', () => {
});
it('should replace in array', () => {
const array = service.getArray('array');
const array = collaborationService.getArray('array');
let items: ReadonlyArray<any> = [];
@ -111,7 +119,7 @@ describe('CollaborationService', () => {
});
it('should remove from array', () => {
const array = service.getArray('array');
const array = collaborationService.getArray('array');
let items: ReadonlyArray<any> = [];
@ -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);
}
});

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

@ -106,11 +106,11 @@ export class CollaborationService {
}
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) {
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) {
@ -130,7 +130,8 @@ export class SharedMap<T> {
}
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() || {});
@ -160,7 +161,8 @@ export class SharedArray<T> {
}
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() || []);

2
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<ExtendedFormGroup, Comment> {
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 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()">
<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>
</li>
<sqx-dropdown-menu *sqxModal="modalMenu;onRoot:false" [sqxAnchoredTo]="button" [scrollTop]="scrollMe.nativeElement.scrollHeight" offset="10" #scrollMe>
<ng-container *ngIf="commentsArray?.itemsChanges | async; let comments">
<small class="text-muted" *ngIf="comments.length === 0">
<ng-container *ngIf="commentItems | async; let items">
<small class="text-muted" *ngIf="items.length === 0">
{{ 'notifications.empty' | sqxTranslate}}
</small>
<sqx-comment *ngFor="let comment of comments.slice().reverse(); trackBy: trackByComment; let i = index"
[comment]="$any(comment)"
[commentIndex]="i"
[comments]="commentsArray!"
<sqx-comment *ngFor="let item of items.slice().reverse(); trackBy: trackByComment; let i = index"
canAnswer="false"
confirmDelete="false"
canDelete="true"
canFollow="true"
[userToken]="userToken">
[commentItem]="item"
[comments]="commentsState"
[mentionConfig]="{}"
[mentionUsers]="undefined"
[userToken]="commentUser">
</sqx-comment>
</ng-container>
</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 { 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<Comment>;
public commentsUnread!: Observable<number>;
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<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.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;
}

5
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",

Loading…
Cancel
Save