diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts index 94d5bd915..523d2e4cc 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts @@ -18,6 +18,7 @@ import { GfDataProviderCreditsComponent } from '@ghostfolio/ui/data-provider-cre import { translate } from '@ghostfolio/ui/i18n'; import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; +import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector'; import { GfValueComponent } from '@ghostfolio/ui/value'; import { COMMA, ENTER } from '@angular/cdk/keycodes'; @@ -27,19 +28,13 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - ElementRef, Inject, OnDestroy, - OnInit, - ViewChild + OnInit } from '@angular/core'; import { FormBuilder, FormGroup } from '@angular/forms'; -import { - MatAutocompleteModule, - MatAutocompleteSelectedEvent -} from '@angular/material/autocomplete'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatButtonModule } from '@angular/material/button'; -import { MatChipsModule } from '@angular/material/chips'; import { MAT_DIALOG_DATA, MatDialogModule, @@ -53,8 +48,8 @@ import { Router } from '@angular/router'; import { Account, Tag } from '@prisma/client'; import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; -import { Observable, of, Subject } from 'rxjs'; -import { map, startWith, takeUntil } from 'rxjs/operators'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; import { HoldingDetailDialogParams } from './interfaces/interfaces'; @@ -70,10 +65,10 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces'; GfDialogHeaderModule, GfLineChartComponent, GfPortfolioProportionChartComponent, + GfTagsSelectorComponent, GfValueComponent, MatAutocompleteModule, MatButtonModule, - MatChipsModule, MatDialogModule, MatFormFieldModule, MatTabsModule, @@ -85,8 +80,6 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces'; templateUrl: 'holding-detail-dialog.html' }) export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { - @ViewChild('tagInput') tagInput: ElementRef; - public activityForm: FormGroup; public accounts: Account[]; public assetClass: string; @@ -102,7 +95,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { public dividendInBaseCurrencyPrecision = 2; public dividendYieldPercentWithCurrencyEffect: number; public feeInBaseCurrency: number; - public filteredTagsObservable: Observable = of([]); public firstBuyDate: string; public historicalDataItems: LineChartItem[]; public investment: number; @@ -319,17 +311,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { this.activityForm.setValue({ tags: this.tags }, { emitEvent: false }); - this.filteredTagsObservable = this.activityForm.controls[ - 'tags' - ].valueChanges.pipe( - startWith(this.activityForm.get('tags').value), - map((aTags: Tag[] | null) => { - return aTags - ? this.filterTags(aTags) - : this.tagsAvailable.slice(); - }) - ); - this.transactionCount = transactionCount; this.totalItems = transactionCount; this.value = value; @@ -437,15 +418,8 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { }); } - public onAddTag(event: MatAutocompleteSelectedEvent) { - this.activityForm.get('tags').setValue([ - ...(this.activityForm.get('tags').value ?? []), - this.tagsAvailable.find(({ id }) => { - return id === event.option.value; - }) - ]); - - this.tagInput.nativeElement.value = ''; + public onAddTag(event: Tag[]) { + this.activityForm.get('tags').setValue(event); } public onCloneActivity(aActivity: Activity) { @@ -480,14 +454,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { }); } - public onRemoveTag(aTag: Tag) { - this.activityForm.get('tags').setValue( - this.activityForm.get('tags').value.filter(({ id }) => { - return id !== aTag.id; - }) - ); - } - public onUpdateActivity(aActivity: Activity) { this.router.navigate(['/portfolio', 'activities'], { queryParams: { activityId: aActivity.id, editDialog: true } @@ -500,14 +466,4 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); } - - private filterTags(aTags: Tag[]) { - const tagIds = aTags.map(({ id }) => { - return id; - }); - - return this.tagsAvailable.filter(({ id }) => { - return !tagIds.includes(id); - }); - } } diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html index f92ad54f8..f17cd1ea3 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -373,38 +373,11 @@ }" >
- - Tags - - @for (tag of activityForm.get('tags')?.value; track tag.id) { - - {{ tag.name }} - - - } - - - - @for (tag of filteredTagsObservable | async; track tag.id) { - - {{ tag.name }} - - } - - +
diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts index 271a5cd53..df6070598 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts @@ -8,19 +8,16 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - ElementRef, Inject, - OnDestroy, - ViewChild + OnDestroy } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { AssetClass, AssetSubClass, Tag, Type } from '@prisma/client'; import { isAfter, isToday } from 'date-fns'; -import { EMPTY, Observable, Subject, lastValueFrom, of } from 'rxjs'; -import { catchError, delay, map, startWith, takeUntil } from 'rxjs/operators'; +import { EMPTY, Subject, lastValueFrom, of } from 'rxjs'; +import { catchError, delay, takeUntil } from 'rxjs/operators'; import { DataService } from '../../../../services/data.service'; import { validateObjectForForm } from '../../../../util/form.util'; @@ -35,9 +32,6 @@ import { CreateOrUpdateActivityDialogParams } from './interfaces/interfaces'; standalone: false }) export class CreateOrUpdateActivityDialog implements OnDestroy { - @ViewChild('symbolAutocomplete') symbolAutocomplete; - @ViewChild('tagInput') tagInput: ElementRef; - public activityForm: FormGroup; public assetClasses = Object.keys(AssetClass).map((assetClass) => { return { id: assetClass, label: translate(assetClass) }; @@ -48,7 +42,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { public currencies: string[] = []; public currentMarketPrice = null; public defaultDateFormat: string; - public filteredTagsObservable: Observable = of([]); public isLoading = false; public isToday = isToday; public mode: 'create' | 'update'; @@ -284,15 +277,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { this.changeDetectorRef.markForCheck(); }); - this.filteredTagsObservable = this.activityForm.controls[ - 'tags' - ].valueChanges.pipe( - startWith(this.activityForm.get('tags').value), - map((aTags: Tag[] | null) => { - return aTags ? this.filterTags(aTags) : this.tagsAvailable.slice(); - }) - ); - this.activityForm .get('type') .valueChanges.pipe(takeUntil(this.unsubscribeSubject)) @@ -440,27 +424,12 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { return isAfter(aDate, new Date(0)); } - public onAddTag(event: MatAutocompleteSelectedEvent) { - this.activityForm.get('tags').setValue([ - ...(this.activityForm.get('tags').value ?? []), - this.tagsAvailable.find(({ id }) => { - return id === event.option.value; - }) - ]); - - this.tagInput.nativeElement.value = ''; - } - public onCancel() { this.dialogRef.close(); } - public onRemoveTag(aTag: Tag) { - this.activityForm.get('tags').setValue( - this.activityForm.get('tags').value.filter(({ id }) => { - return id !== aTag.id; - }) - ); + public onTagsChanged(event: Tag[]) { + this.activityForm.get('tags').setValue([...event]); } public async onSubmit() { @@ -523,16 +492,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy { this.unsubscribeSubject.complete(); } - private filterTags(aTags: Tag[]) { - const tagIds = aTags.map(({ id }) => { - return id; - }); - - return this.tagsAvailable.filter(({ id }) => { - return !tagIds.includes(id); - }); - } - private updateSymbol() { this.isLoading = true; this.changeDetectorRef.markForCheck(); diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html index 7795688c0..85fcf5a94 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html @@ -379,38 +379,11 @@
- - Tags - - @for (tag of activityForm.get('tags')?.value; track tag.id) { - - {{ tag.name }} - - - } - - - - @for (tag of filteredTagsObservable | async; track tag.id) { - - {{ tag.name }} - - } - - +
diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.module.ts b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.module.ts index a4d28d0e0..ea6106a33 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.module.ts +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.module.ts @@ -1,5 +1,6 @@ import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component'; import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete'; +import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector'; import { GfValueComponent } from '@ghostfolio/ui/value'; import { CommonModule } from '@angular/common'; @@ -24,6 +25,7 @@ import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog FormsModule, GfAssetProfileIconComponent, GfSymbolAutocompleteComponent, + GfTagsSelectorComponent, GfValueComponent, MatAutocompleteModule, MatButtonModule, diff --git a/libs/ui/src/lib/tags-selector/index.ts b/libs/ui/src/lib/tags-selector/index.ts new file mode 100644 index 000000000..360bce671 --- /dev/null +++ b/libs/ui/src/lib/tags-selector/index.ts @@ -0,0 +1 @@ +export * from './tags-selector.component'; diff --git a/libs/ui/src/lib/tags-selector/tags-selector.component.html b/libs/ui/src/lib/tags-selector/tags-selector.component.html new file mode 100644 index 000000000..45235e0d6 --- /dev/null +++ b/libs/ui/src/lib/tags-selector/tags-selector.component.html @@ -0,0 +1,32 @@ + + Tags + + @for (tag of tagsSignal(); track tag.id) { + + {{ tag.name }} + + + } + + + + @for (tag of filteredTags(); track tag.id) { + + {{ tag.name }} + + } + + diff --git a/libs/ui/src/lib/tags-selector/tags-selector.component.scss b/libs/ui/src/lib/tags-selector/tags-selector.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/libs/ui/src/lib/tags-selector/tags-selector.component.ts b/libs/ui/src/lib/tags-selector/tags-selector.component.ts new file mode 100644 index 000000000..1e5e82423 --- /dev/null +++ b/libs/ui/src/lib/tags-selector/tags-selector.component.ts @@ -0,0 +1,106 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + CUSTOM_ELEMENTS_SCHEMA, + effect, + ElementRef, + EventEmitter, + Input, + model, + OnInit, + Output, + signal, + ViewChild +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + MatAutocompleteModule, + MatAutocompleteSelectedEvent +} from '@angular/material/autocomplete'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { Tag } from '@prisma/client'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + FormsModule, + MatAutocompleteModule, + MatFormFieldModule, + MatInputModule, + MatChipsModule, + MatIconModule + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-tags-selector', + standalone: true, + styleUrls: ['./tags-selector.component.scss'], + templateUrl: 'tags-selector.component.html' +}) +export class GfTagsSelectorComponent implements OnInit { + @Input() tags: Tag[]; + @Input() tagsAvailable: Tag[]; + + @Output() tagsChanged = new EventEmitter(); + + @ViewChild('tagInput') tagInput: ElementRef; + + readonly tagsSignal = signal([{ id: '', name: '' }]); + readonly currentTag = model(''); + readonly filteredTags = computed(() => { + const currentTag = this.currentTag().toLowerCase(); + const aTags = this.tagsAvailable ?? [{ id: '', name: '' }]; + const bTags = this.tagsSignal() ?? [{ id: '', name: '' }]; + const cTags = aTags.filter((value) => !bTags.includes(value)); + + return currentTag + ? cTags.filter((tag) => tag.name.toLowerCase().includes(currentTag)) + : cTags; + }); + + public constructor() { + effect(() => { + if (this.tagsSignal()) { + this.tagsChanged.emit(this.tagsSignal()); + } + }); + } + + ngOnInit() { + this.tagsSignal.set(this.tags); + } + + public onAddTag(event: MatAutocompleteSelectedEvent) { + const tagId = event.option.value; + const newTag = this.tagsAvailable.find(({ id }) => id === tagId); + + if (this.tagsSignal()?.some((el) => el.id === tagId)) { + this.currentTag.set(''); + event.option.deselect(); + return; + } + + this.tagsSignal() + ? this.tagsSignal.update((tags) => [...tags, newTag]) + : this.tagsSignal.update(() => [newTag]); + this.currentTag.set(''); + event.option.deselect(); + } + + public onRemoveTag(aTag: Tag) { + this.tagsSignal.update((tagsSignal) => { + const index = tagsSignal.indexOf(aTag); + if (index < 0) { + return tagsSignal; + } + + tagsSignal.splice(index, 1); + return [...tagsSignal]; + }); + } +}