added annotation mentions

This commit is contained in:
Sofia Papacharalampous 2024-07-15 15:36:19 +03:00
parent a172defde8
commit d315500af8
11 changed files with 274 additions and 25 deletions

View File

@ -40,6 +40,7 @@
"ngx-dropzone": "^3.0.0", "ngx-dropzone": "^3.0.0",
"ngx-guided-tour": "^2.0.1", "ngx-guided-tour": "^2.0.1",
"ngx-matomo-client": "^6.2.0", "ngx-matomo-client": "^6.2.0",
"ngx-mentions": "^16.2.0",
"rxjs": "^7.4.0", "rxjs": "^7.4.0",
"tinymce": "^7.2.0", "tinymce": "^7.2.0",
"ts-simple-nameof": "^1.3.1", "ts-simple-nameof": "^1.3.1",
@ -9838,6 +9839,18 @@
"@angular/core": "^17.0.0 || ^18.0.0" "@angular/core": "^17.0.0 || ^18.0.0"
} }
}, },
"node_modules/ngx-mentions": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/ngx-mentions/-/ngx-mentions-16.2.0.tgz",
"integrity": "sha512-D/lsJmlnb0Ifjd2GnSSHy5/GGfYhw6Xmzip22Z1pYipfCDSfgU00UfhsxHLZyiwo0cljj2vRwg5OlAH4PHP1Fg==",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"@angular/common": ">=16.0.0",
"@angular/core": ">=16.0.0"
}
},
"node_modules/nice-napi": { "node_modules/nice-napi": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz", "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz",

View File

@ -44,6 +44,7 @@
"ngx-dropzone": "^3.0.0", "ngx-dropzone": "^3.0.0",
"ngx-guided-tour": "^2.0.1", "ngx-guided-tour": "^2.0.1",
"ngx-matomo-client": "^6.2.0", "ngx-matomo-client": "^6.2.0",
"ngx-mentions": "^16.2.0",
"rxjs": "^7.4.0", "rxjs": "^7.4.0",
"tinymce": "^7.2.0", "tinymce": "^7.2.0",
"ts-simple-nameof": "^1.3.1", "ts-simple-nameof": "^1.3.1",

View File

@ -27,12 +27,7 @@
<form [formGroup]="threadFormGroup"> <form [formGroup]="threadFormGroup">
<div class="row mt-2 mb-3"> <div class="row mt-2 mb-3">
<div class="col-12"> <div class="col-12">
<mat-form-field appearance="outline" class="w-100"> <text-area-with-mentions [form]="threadFormGroup.get('text')" [planUsers]="planUsers"></text-area-with-mentions>
<mat-label>{{'ANNOTATION-DIALOG.THREADS.NEW-THREAD' | translate}}</mat-label>
<textarea matInput autocomplete="off" formControlName="text" required appTextareaAutoresize></textarea>
<mat-error *ngIf="threadFormGroup.get('text').hasError('backendError')">{{threadFormGroup.get('text').getError('backendError')?.message}}</mat-error>
<mat-error *ngIf="threadFormGroup.get('text').hasError('required')">{{'COMMONS.VALIDATION.REQUIRED' | translate}}</mat-error>
</mat-form-field>
</div> </div>
<div class="col-6"> <div class="col-6">
<mat-button-toggle-group appearance="standard" name="fontStyle" hideSingleSelectionIndicator="true" [formControl]="threadFormGroup.get('protectionType')" aria-label="Font Style" required> <mat-button-toggle-group appearance="standard" name="fontStyle" hideSingleSelectionIndicator="true" [formControl]="threadFormGroup.get('protectionType')" aria-label="Font Style" required>
@ -96,7 +91,10 @@
</div> </div>
</div> </div>
<div class="row mt-3"> <div class="row mt-3">
<div class="col-12 annotation-full-text">{{getParentAnnotation(thread).payload}}</div> <ng-container *ngFor="let annotationItem of getParentAnnotation(thread).payload">
<div *ngIf="!annotationItem.isMention; else mention">{{annotationItem.payload}}&nbsp;</div>
<ng-template #mention><div><span class="highlight-user-mention">{{annotationItem.payload}}</span>&nbsp;</div></ng-template>
</ng-container>
<div class="col-12 align-self-end"> <div class="col-12 align-self-end">
<div class="row mt-1"> <div class="row mt-1">
<button mat-button (click)="enableReply(thread)" class="action-button" [ngClass]="this.replyEnabledPerThread[thread] ? 'active-action' : ''" [disabled]="this.replyEnabledPerThread[thread]">{{ 'ANNOTATION-DIALOG.THREADS.REPLY' | translate}}</button> <button mat-button (click)="enableReply(thread)" class="action-button" [ngClass]="this.replyEnabledPerThread[thread] ? 'active-action' : ''" [disabled]="this.replyEnabledPerThread[thread]">{{ 'ANNOTATION-DIALOG.THREADS.REPLY' | translate}}</button>
@ -139,7 +137,7 @@
</div> </div>
<div class="row mt-3 pt-1 pb-1"> <div class="row mt-3 pt-1 pb-1">
<div class="col-12 annotation-full-text">{{annotation.payload}}</div> <div class="col-12 annotation-full-text" [innerHtml]="parsePayload(annotation.payload)"></div>
</div> </div>
</div> </div>
</div> </div>
@ -154,10 +152,7 @@
<div class="col-12 mt-2"> <div class="col-12 mt-2">
<div class="row new-reply mr-0"> <div class="row new-reply mr-0">
<div class="col"> <div class="col">
<mat-form-field appearance="outline" class="w-100"> <text-area-with-mentions [form]="this.threadReplyTextsFG[thread.toString()].get('replyText')" [planUsers]="planUsers"></text-area-with-mentions>
<mat-label>{{'ANNOTATION-DIALOG.THREADS.REPLY' | translate}}</mat-label>
<textarea matInput autocomplete="off" [formControl]="this.threadReplyTextsFG[thread.toString()].get('replyText')" required appTextareaAutoresize></textarea>
</mat-form-field>
</div> </div>
<div class="col-auto p-0 send-msg"> <div class="col-auto p-0 send-msg">
<button mat-icon-button (click)="replyThread(thread)" matTooltip="{{'ANNOTATION-DIALOG.THREADS.REPLY' | translate}}"> <button mat-icon-button (click)="replyThread(thread)" matTooltip="{{'ANNOTATION-DIALOG.THREADS.REPLY' | translate}}">

View File

@ -146,4 +146,14 @@ $mat-card-header-size: 40px !default;
::ng-deep .statuses-menu.mat-mdc-menu-panel { ::ng-deep .statuses-menu.mat-mdc-menu-panel {
min-width: initial !important; min-width: initial !important;
} }
::ng-deep .highlight-user-mention {
padding: 1px 2px;
margin: -1px -2px;
border-radius: 6px;
overflow-wrap: break-word;
background-color: #e1f2fe;
color: #3f5163;
opacity: .6;
}

View File

@ -24,6 +24,14 @@ import { AnnotationStatusArrayEditorModel } from './annotation-status-editor.mod
import { StatusService } from '@annotation-service/services/http/status.service'; import { StatusService } from '@annotation-service/services/http/status.service';
import { ConfigurationService } from '@app/core/services/configuration/configuration.service'; import { ConfigurationService } from '@app/core/services/configuration/configuration.service';
import { MatSelectionList } from '@angular/material/list'; import { MatSelectionList } from '@angular/material/list';
import { PlanUser } from '@app/core/model/plan/plan';
import { DomSanitizer } from '@angular/platform-browser';
import { Subject } from 'rxjs';
interface AnnotationPayloadItem {
isMention: boolean;
payload: string;
}
@Component({ @Component({
selector: 'app-annotation-dialog', selector: 'app-annotation-dialog',
@ -53,10 +61,9 @@ export class AnnotationDialogComponent extends BaseComponent {
private formBuilder: FormBuilder = new FormBuilder(); private formBuilder: FormBuilder = new FormBuilder();
public annotationStatusFormGroup: UntypedFormGroup; public annotationStatusFormGroup: UntypedFormGroup;
public listingStatuses: Status[] = []; public listingStatuses: Status[] = [];
public planUsersMentionNames: string[] = []; public planUsers: PlanUser[] = [];
@ViewChild('annotationStatus') annotationStatus: MatSelectionList;
@ViewChild('annotationStatus') annotationStatus: MatSelectionList;
constructor( constructor(
public dialogRef: MatDialogRef<AnnotationDialogComponent>, public dialogRef: MatDialogRef<AnnotationDialogComponent>,
@ -71,12 +78,13 @@ export class AnnotationDialogComponent extends BaseComponent {
private statusService: StatusService, private statusService: StatusService,
protected routerUtils: RouterUtilsService, protected routerUtils: RouterUtilsService,
private configurationService: ConfigurationService, private configurationService: ConfigurationService,
private sanitizer: DomSanitizer,
) { ) {
super(); super();
this.entityId = data.entityId; this.entityId = data.entityId;
this.anchor = data.anchor; this.anchor = data.anchor;
this.entityType = data.entityType; this.entityType = data.entityType;
this.planUsersMentionNames = data.planUsers.map(x => x.user.name); this.planUsers = data.planUsers;
dialogRef.backdropClick().pipe(takeUntil(this._destroyed)).subscribe(() => dialogRef.close(this.changesMade)); dialogRef.backdropClick().pipe(takeUntil(this._destroyed)).subscribe(() => dialogRef.close(this.changesMade));
} }
@ -179,7 +187,11 @@ export class AnnotationDialogComponent extends BaseComponent {
this.comments.forEach(element => { this.comments.forEach(element => {
this.threadReplyTextsFG[element.threadId.toString()] = this.formBuilder.group({ replyText: new FormControl(null, [Validators.required]) }); this.threadReplyTextsFG[element.threadId.toString()] = this.formBuilder.group({ replyText: new FormControl(null, [Validators.required]) });
this.annotationsPerThread[element.threadId.toString()] = data.items.filter(x => x.threadId === element.threadId && x.id !== element.id).sort((a1, a2) => new Date(a1.timeStamp).getTime() - new Date(a2.timeStamp).getTime()); this.annotationsPerThread[element.threadId.toString()] = data.items.filter(x => x.threadId === element.threadId && x.id !== element.id).sort((a1, a2) => new Date(a1.timeStamp).getTime() - new Date(a2.timeStamp).getTime());
this.parentAnnotationsPerThread[element.threadId.toString()] = data.items.filter(x => x.threadId === element.threadId && x.id === element.id)[0];
let parentAnnotation: any = data.items.filter(x => x.threadId === element.threadId && x.id === element.id)[0];
parentAnnotation.payload = this.parsePayload(parentAnnotation.payload);
this.parentAnnotationsPerThread[element.threadId.toString()] = parentAnnotation;
this.threads.add(element.threadId); this.threads.add(element.threadId);
}); });
// create annotation status array to handle each annotation // create annotation status array to handle each annotation
@ -340,11 +352,52 @@ export class AnnotationDialogComponent extends BaseComponent {
return control.valid; return control.valid;
} }
parsePayload(payload: string): AnnotationPayloadItem[] {
if (!this.planUsers) return [{ isMention: false, payload: payload}];
if (this.planUsers.length == 0) [{ isMention: false, payload: payload}];
let payloadItems: AnnotationPayloadItem[] = [];
const mentionRegExp = new RegExp(/\@\{\{userid:[a-zA-Z0-9\-]*\}\}/g);
payloadItems = payload.split(/(?=\@\{\{userid:[a-zA-Z0-9\-]*\}\})|(?<=\@\{\{userid:[a-zA-Z0-9\-]*\}\})/g)
.filter( p => p!=null && p!='')
.map((p)=> {
let annotationItem: AnnotationPayloadItem = { isMention: false, payload: p};
if (mentionRegExp.exec(p)) {
annotationItem.isMention = true;
annotationItem.payload = this.planUsers.find(u => p.includes(u.id.toString()))?.user?.name;
}
return annotationItem;
});
return payloadItems;
// for (let planUser of this.planUsers) {
// payloadItems = payload.split(`@{{userid:${planUser.id}}}`).map({isMention }).join('<span class="highlight-user-mention">'+planUser?.user?.name+'</span>');
// }
// return this.sanitizer.bypassSecurityTrustHtml(payload);
}
// parsePayload(payload: string): AnnotationPayloadItem[] {
// if (!this.planUsers) return [{ isMention: false, payload: payload}];
// if (this.planUsers.length == 0) [{ isMention: false, payload: payload}];
// let payloadItems = [];
// // let payloadItems = [];
// for (let planUser of this.planUsers) {
// payloadItems = payload.split(`@{{userid:${planUser.id}}}`).map({isMention }).join('<span class="highlight-user-mention">'+planUser?.user?.name+'</span>');
// }
// return this.sanitizer.bypassSecurityTrustHtml(payload);
// }
private onCallbackAnnotationStatusSuccess() { private onCallbackAnnotationStatusSuccess() {
this.uiNotificationService.snackBarNotification(this.language.instant('ANNOTATION-DIALOG.ANNOTATION-STATUS.SUCCESS'), SnackBarNotificationLevel.Success); this.uiNotificationService.snackBarNotification(this.language.instant('ANNOTATION-DIALOG.ANNOTATION-STATUS.SUCCESS'), SnackBarNotificationLevel.Success);
this.refreshAnnotations(); this.refreshAnnotations();
} }
} }

View File

@ -4,6 +4,8 @@ import { CommonFormsModule } from '@common/forms/common-forms.module';
import { CommonUiModule } from '@common/ui/common-ui.module'; import { CommonUiModule } from '@common/ui/common-ui.module';
import { AnnotationDialogComponent } from './annotation-dialog.component'; import { AnnotationDialogComponent } from './annotation-dialog.component';
import { AutoCompleteModule } from '@app/library/auto-complete/auto-complete.module'; import { AutoCompleteModule } from '@app/library/auto-complete/auto-complete.module';
import { TextAreaWithMentionsComponent } from '../components/text-area-with-mentions.component';
import { NgxMentionsModule } from 'ngx-mentions';
@NgModule({ @NgModule({
imports: [ imports: [
@ -11,12 +13,14 @@ import { AutoCompleteModule } from '@app/library/auto-complete/auto-complete.mod
CommonFormsModule, CommonFormsModule,
FormattingModule, FormattingModule,
AutoCompleteModule, AutoCompleteModule,
NgxMentionsModule,
], ],
declarations: [ declarations: [
AnnotationDialogComponent, AnnotationDialogComponent,
TextAreaWithMentionsComponent,
], ],
exports: [ exports: [
AnnotationDialogComponent, AnnotationDialogComponent,
] ],
}) })
export class AnnotationDialogModule { } export class AnnotationDialogModule { }

View File

@ -0,0 +1,35 @@
<div class="relative-block-container w-100">
<mat-form-field appearance="outline" class="w-100">
<mat-label>{{'ANNOTATION-DIALOG.THREADS.NEW-THREAD' | translate}}</mat-label>
<textarea
[formControl]="internalForm"
placeholder="Enter '@' or '#' and start typing..."
class="w-100"
rows="2"
matInput
appTextareaAutoresize
#textareaRef
></textarea>
<ngx-mentions [textInputElement]="textareaRef" style="position: absolute; left: 0;" class="w-100"
[menuTemplate]="menuTemplate"
[mentionsConfig]="mentionsConfig"
[searchRegexp]="searchRegexp"
[mentions]="mentions"
[removeWholeTagOnBackspace]="true"
(search)="loadChoices($event)"
(selectedChoicesChange)="onSelectedChoicesChange($event)"
(menuShow)="onMenuShow()"
(menuHide)="onMenuHide()"
></ngx-mentions>
<mat-error *ngIf="form.hasError('backendError')">{{form.getError('backendError')?.message}}</mat-error>
<mat-error *ngIf="form.hasError('required')">{{'COMMONS.VALIDATION.REQUIRED' | translate}}</mat-error>
</mat-form-field>
<ng-template #menuTemplate
let-selectChoice="selectChoice">
<ngx-text-input-autocomplete-menu *ngIf="choices" [choices]="choices"
[getDisplayLabel]="getDisplayLabel"
(selectChoice)="selectChoice($event)">
</ngx-text-input-autocomplete-menu>
</ng-template>
</div>

View File

@ -0,0 +1,29 @@
.relative-block-container {
display: flex;
flex-direction: column;
align-items: center;
}
.relative-block-container div{
width: 300px;
}
.relative-block-container textarea {
line-height: 1.4;
word-spacing:2px;
}
.formatted-text {
margin: 3rem 0;
width: 300px;
line-height: 1.4;
font-size: 16px;
}
::ng-deep.ngx-selectable-list {
max-height: 150px !important;
}
::ng-deep.ngx-text-highlight-tag {
letter-spacing: -0.01rem !important;
}

View File

@ -0,0 +1,109 @@
import { Component, Input, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, FormControl, Validators } from "@angular/forms";
import { PlanUser } from "@app/core/model/plan/plan";
import { ChoiceWithIndices } from "ngx-mentions";
import { Observable, Subscription } from "rxjs";
@Component({
selector: 'text-area-with-mentions',
templateUrl: './text-area-with-mentions.component.html',
styleUrls: ['./text-area-with-mentions.component.scss'],
})
export class TextAreaWithMentionsComponent implements OnInit, OnDestroy {
USER_TRIGGER_CHARACTER: string = '@'
@Input() form!: FormControl;
@Input() planUsers: PlanUser[];
internalFormSubscription: Subscription;
clearInputSubscription: Subscription;
internalForm!: FormControl;
choices: PlanUser[];
loading: boolean = false;
mentionsConfig = [
{
triggerCharacter: this.USER_TRIGGER_CHARACTER,
getChoiceLabel: (item: PlanUser): string => {
return `${this.USER_TRIGGER_CHARACTER}${item?.user?.name}`;
},
}
];
mentions: ChoiceWithIndices[] = [];
searchRegexp = new RegExp('^([-&.\\w]+ *){0,3}$');
constructor(
private formBuilder: FormBuilder,
) {}
ngOnInit(): void {
this.internalForm = this.formBuilder.control('', Validators.required);
this.internalFormSubscription = this.internalForm.valueChanges.subscribe(value => {
this.form.setValue(this._formatTextWithSelections(value, this.mentions));
});
}
ngOnDestroy(): void {
this.internalFormSubscription?.unsubscribe();
}
getDisplayLabel = (item: PlanUser): string => {
return item?.user?.name;
}
loadChoices({searchText, triggerCharacter}
: { searchText: string, triggerCharacter: string}
): void {
if (triggerCharacter != this.USER_TRIGGER_CHARACTER) return;
if (triggerCharacter == this.USER_TRIGGER_CHARACTER) {
this.choices = this.planUsers?.filter(u => u?.user?.name.toLowerCase().indexOf(searchText.toLowerCase()) > -1);
}
}
onSelectedChoicesChange(selections: ChoiceWithIndices[]): void {
this.mentions = selections.map((choice: ChoiceWithIndices) => ({
...choice,
}))
this.internalForm.updateValueAndValidity();
}
onMenuShow(): void {}
onMenuHide(): void {
this.choices = undefined;
}
private _formatTextWithSelections(content: string, selections: ChoiceWithIndices[]): string {
let formattedContent = content;
let replaceContentIndex = 0;
selections.forEach((selection: ChoiceWithIndices) => {
const start = selection.indices.start;
const end = selection.indices.end;
const selectionText = content.substring(start, end);
const formattedText = `@{{userid:${selection?.choice?.id}}}`;
const newReplace = formattedContent
.substring(replaceContentIndex)
.replace(selectionText, formattedText);
formattedContent =
replaceContentIndex === 0
? newReplace
: formattedContent.substring(0, replaceContentIndex) + newReplace;
replaceContentIndex = formattedContent.lastIndexOf('}}') + 2;
});
formattedContent = formattedContent.replace(/\n/g, '<br>');
return formattedContent;
}
}

View File

@ -89,9 +89,9 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="heading2 col-12">{{'PLAN-EDITOR.FIELDS.DESCRIPTION' | translate}}</div> <div class="heading2 col-12">{{'PLAN-EDITOR.FIELDS.DESCRIPTION' | translate}} *</div>
<div class="col-12"> <div class="col-12">
<rich-text-editor-component [form]="formGroup.get('description')" placeholder="{{'PLAN-EDITOR.PLACEHOLDER.DESCRIPTION' | translate}}" [required]="false"> <rich-text-editor-component [form]="formGroup.get('description')" placeholder="{{'PLAN-EDITOR.PLACEHOLDER.DESCRIPTION' | translate}}" [required]="true">
</rich-text-editor-component> </rich-text-editor-component>
</div> </div>
</div> </div>

View File

@ -546,7 +546,7 @@ export class PlanEditorComponent extends BaseEditor<PlanEditorModel, Plan> imple
this.selectedBlueprint = data; this.selectedBlueprint = data;
this.formGroup.get('blueprint').setValue(this.selectedBlueprint.id); this.formGroup.get('blueprint').setValue(this.selectedBlueprint.id);
const goToNextStep: boolean = this.formGroup.get('label').valid; const goToNextStep: boolean = this.formGroup.get('label').valid && this.formGroup.get('description').valid;
if (goToNextStep) { if (goToNextStep) {
this.buildFormAfterBlueprintSelection(); this.buildFormAfterBlueprintSelection();
this.nextStep(); this.nextStep();