added annotation mentions
This commit is contained in:
parent
a172defde8
commit
d315500af8
|
@ -40,6 +40,7 @@
|
|||
"ngx-dropzone": "^3.0.0",
|
||||
"ngx-guided-tour": "^2.0.1",
|
||||
"ngx-matomo-client": "^6.2.0",
|
||||
"ngx-mentions": "^16.2.0",
|
||||
"rxjs": "^7.4.0",
|
||||
"tinymce": "^7.2.0",
|
||||
"ts-simple-nameof": "^1.3.1",
|
||||
|
@ -9838,6 +9839,18 @@
|
|||
"@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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz",
|
||||
|
|
|
@ -44,6 +44,7 @@
|
|||
"ngx-dropzone": "^3.0.0",
|
||||
"ngx-guided-tour": "^2.0.1",
|
||||
"ngx-matomo-client": "^6.2.0",
|
||||
"ngx-mentions": "^16.2.0",
|
||||
"rxjs": "^7.4.0",
|
||||
"tinymce": "^7.2.0",
|
||||
"ts-simple-nameof": "^1.3.1",
|
||||
|
|
|
@ -27,12 +27,7 @@
|
|||
<form [formGroup]="threadFormGroup">
|
||||
<div class="row mt-2 mb-3">
|
||||
<div class="col-12">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<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>
|
||||
<text-area-with-mentions [form]="threadFormGroup.get('text')" [planUsers]="planUsers"></text-area-with-mentions>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<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 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}} </div>
|
||||
<ng-template #mention><div><span class="highlight-user-mention">{{annotationItem.payload}}</span> </div></ng-template>
|
||||
</ng-container>
|
||||
<div class="col-12 align-self-end">
|
||||
<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>
|
||||
|
@ -139,7 +137,7 @@
|
|||
</div>
|
||||
|
||||
<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>
|
||||
|
@ -154,10 +152,7 @@
|
|||
<div class="col-12 mt-2">
|
||||
<div class="row new-reply mr-0">
|
||||
<div class="col">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<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>
|
||||
<text-area-with-mentions [form]="this.threadReplyTextsFG[thread.toString()].get('replyText')" [planUsers]="planUsers"></text-area-with-mentions>
|
||||
</div>
|
||||
<div class="col-auto p-0 send-msg">
|
||||
<button mat-icon-button (click)="replyThread(thread)" matTooltip="{{'ANNOTATION-DIALOG.THREADS.REPLY' | translate}}">
|
||||
|
|
|
@ -146,4 +146,14 @@ $mat-card-header-size: 40px !default;
|
|||
|
||||
::ng-deep .statuses-menu.mat-mdc-menu-panel {
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -24,6 +24,14 @@ import { AnnotationStatusArrayEditorModel } from './annotation-status-editor.mod
|
|||
import { StatusService } from '@annotation-service/services/http/status.service';
|
||||
import { ConfigurationService } from '@app/core/services/configuration/configuration.service';
|
||||
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({
|
||||
selector: 'app-annotation-dialog',
|
||||
|
@ -53,10 +61,9 @@ export class AnnotationDialogComponent extends BaseComponent {
|
|||
private formBuilder: FormBuilder = new FormBuilder();
|
||||
public annotationStatusFormGroup: UntypedFormGroup;
|
||||
public listingStatuses: Status[] = [];
|
||||
public planUsersMentionNames: string[] = [];
|
||||
|
||||
@ViewChild('annotationStatus') annotationStatus: MatSelectionList;
|
||||
public planUsers: PlanUser[] = [];
|
||||
|
||||
@ViewChild('annotationStatus') annotationStatus: MatSelectionList;
|
||||
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<AnnotationDialogComponent>,
|
||||
|
@ -71,12 +78,13 @@ export class AnnotationDialogComponent extends BaseComponent {
|
|||
private statusService: StatusService,
|
||||
protected routerUtils: RouterUtilsService,
|
||||
private configurationService: ConfigurationService,
|
||||
private sanitizer: DomSanitizer,
|
||||
) {
|
||||
super();
|
||||
this.entityId = data.entityId;
|
||||
this.anchor = data.anchor;
|
||||
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));
|
||||
}
|
||||
|
||||
|
@ -179,7 +187,11 @@ export class AnnotationDialogComponent extends BaseComponent {
|
|||
this.comments.forEach(element => {
|
||||
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.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);
|
||||
});
|
||||
// create annotation status array to handle each annotation
|
||||
|
@ -340,11 +352,52 @@ export class AnnotationDialogComponent extends BaseComponent {
|
|||
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() {
|
||||
this.uiNotificationService.snackBarNotification(this.language.instant('ANNOTATION-DIALOG.ANNOTATION-STATUS.SUCCESS'), SnackBarNotificationLevel.Success);
|
||||
this.refreshAnnotations();
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -4,6 +4,8 @@ import { CommonFormsModule } from '@common/forms/common-forms.module';
|
|||
import { CommonUiModule } from '@common/ui/common-ui.module';
|
||||
import { AnnotationDialogComponent } from './annotation-dialog.component';
|
||||
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({
|
||||
imports: [
|
||||
|
@ -11,12 +13,14 @@ import { AutoCompleteModule } from '@app/library/auto-complete/auto-complete.mod
|
|||
CommonFormsModule,
|
||||
FormattingModule,
|
||||
AutoCompleteModule,
|
||||
NgxMentionsModule,
|
||||
],
|
||||
declarations: [
|
||||
AnnotationDialogComponent,
|
||||
TextAreaWithMentionsComponent,
|
||||
],
|
||||
exports: [
|
||||
AnnotationDialogComponent,
|
||||
]
|
||||
],
|
||||
})
|
||||
export class AnnotationDialogModule { }
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -89,9 +89,9 @@
|
|||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -546,7 +546,7 @@ export class PlanEditorComponent extends BaseEditor<PlanEditorModel, Plan> imple
|
|||
this.selectedBlueprint = data;
|
||||
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) {
|
||||
this.buildFormAfterBlueprintSelection();
|
||||
this.nextStep();
|
||||
|
|
Loading…
Reference in New Issue