Feature/extract tags selector to reusable component (#4256)
* feat(ui): create gf-tags-selector component * feat(ui): implement gf-tags-selector in activity dialog * feat(ui): implement gf-tags-selector in holding detail dialog * Update changelog
This commit is contained in:
parent
9905c428af
commit
d711fed4f5
@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extracted the tags selector to a reusable component used in the create or update activity dialog and holding detail dialog
|
||||||
|
|
||||||
## 2.137.1 - 2025-02-01
|
## 2.137.1 - 2025-02-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
@ -18,26 +18,20 @@ import { GfDataProviderCreditsComponent } from '@ghostfolio/ui/data-provider-cre
|
|||||||
import { translate } from '@ghostfolio/ui/i18n';
|
import { translate } from '@ghostfolio/ui/i18n';
|
||||||
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
|
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
|
||||||
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
|
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
|
||||||
|
import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector';
|
||||||
import { GfValueComponent } from '@ghostfolio/ui/value';
|
import { GfValueComponent } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
import { COMMA, ENTER } from '@angular/cdk/keycodes';
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
CUSTOM_ELEMENTS_SCHEMA,
|
CUSTOM_ELEMENTS_SCHEMA,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
ElementRef,
|
|
||||||
Inject,
|
Inject,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit,
|
OnInit
|
||||||
ViewChild
|
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FormBuilder, FormGroup } from '@angular/forms';
|
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||||
import {
|
|
||||||
MatAutocompleteModule,
|
|
||||||
MatAutocompleteSelectedEvent
|
|
||||||
} from '@angular/material/autocomplete';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatChipsModule } from '@angular/material/chips';
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
import {
|
import {
|
||||||
@ -53,8 +47,8 @@ import { Router } from '@angular/router';
|
|||||||
import { Account, Tag } from '@prisma/client';
|
import { Account, Tag } from '@prisma/client';
|
||||||
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
import { Observable, of, Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { map, startWith, takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
@ -70,8 +64,8 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
|||||||
GfDialogHeaderModule,
|
GfDialogHeaderModule,
|
||||||
GfLineChartComponent,
|
GfLineChartComponent,
|
||||||
GfPortfolioProportionChartComponent,
|
GfPortfolioProportionChartComponent,
|
||||||
|
GfTagsSelectorComponent,
|
||||||
GfValueComponent,
|
GfValueComponent,
|
||||||
MatAutocompleteModule,
|
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatChipsModule,
|
MatChipsModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
@ -85,8 +79,6 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
|||||||
templateUrl: 'holding-detail-dialog.html'
|
templateUrl: 'holding-detail-dialog.html'
|
||||||
})
|
})
|
||||||
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||||
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
|
|
||||||
|
|
||||||
public activityForm: FormGroup;
|
public activityForm: FormGroup;
|
||||||
public accounts: Account[];
|
public accounts: Account[];
|
||||||
public assetClass: string;
|
public assetClass: string;
|
||||||
@ -102,7 +94,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
|||||||
public dividendInBaseCurrencyPrecision = 2;
|
public dividendInBaseCurrencyPrecision = 2;
|
||||||
public dividendYieldPercentWithCurrencyEffect: number;
|
public dividendYieldPercentWithCurrencyEffect: number;
|
||||||
public feeInBaseCurrency: number;
|
public feeInBaseCurrency: number;
|
||||||
public filteredTagsObservable: Observable<Tag[]> = of([]);
|
|
||||||
public firstBuyDate: string;
|
public firstBuyDate: string;
|
||||||
public historicalDataItems: LineChartItem[];
|
public historicalDataItems: LineChartItem[];
|
||||||
public investment: number;
|
public investment: number;
|
||||||
@ -122,7 +113,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
|||||||
public sectors: {
|
public sectors: {
|
||||||
[name: string]: { name: string; value: number };
|
[name: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
public separatorKeysCodes: number[] = [COMMA, ENTER];
|
|
||||||
public sortColumn = 'date';
|
public sortColumn = 'date';
|
||||||
public sortDirection: SortDirection = 'desc';
|
public sortDirection: SortDirection = 'desc';
|
||||||
public SymbolProfile: EnhancedSymbolProfile;
|
public SymbolProfile: EnhancedSymbolProfile;
|
||||||
@ -319,17 +309,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
this.activityForm.setValue({ tags: this.tags }, { emitEvent: false });
|
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.transactionCount = transactionCount;
|
||||||
this.totalItems = transactionCount;
|
this.totalItems = transactionCount;
|
||||||
this.value = value;
|
this.value = value;
|
||||||
@ -437,17 +416,6 @@ 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 onCloneActivity(aActivity: Activity) {
|
public onCloneActivity(aActivity: Activity) {
|
||||||
this.router.navigate(['/portfolio', 'activities'], {
|
this.router.navigate(['/portfolio', 'activities'], {
|
||||||
queryParams: { activityId: aActivity.id, createDialog: true }
|
queryParams: { activityId: aActivity.id, createDialog: true }
|
||||||
@ -480,12 +448,8 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onRemoveTag(aTag: Tag) {
|
public onTagsChanged(tags: Tag[]) {
|
||||||
this.activityForm.get('tags').setValue(
|
this.activityForm.get('tags').setValue(tags);
|
||||||
this.activityForm.get('tags').value.filter(({ id }) => {
|
|
||||||
return id !== aTag.id;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public onUpdateActivity(aActivity: Activity) {
|
public onUpdateActivity(aActivity: Activity) {
|
||||||
@ -500,14 +464,4 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
|||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
private filterTags(aTags: Tag[]) {
|
|
||||||
const tagIds = aTags.map(({ id }) => {
|
|
||||||
return id;
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.tagsAvailable.filter(({ id }) => {
|
|
||||||
return !tagIds.includes(id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -373,38 +373,11 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<mat-form-field appearance="outline" class="w-100 without-hint">
|
<gf-tags-selector
|
||||||
<mat-label i18n>Tags</mat-label>
|
[tags]="activityForm.get('tags')?.value"
|
||||||
<mat-chip-grid #tagsChipList>
|
[tagsAvailable]="tagsAvailable"
|
||||||
@for (tag of activityForm.get('tags')?.value; track tag.id) {
|
(tagsChanged)="onTagsChanged($event)"
|
||||||
<mat-chip-row
|
/>
|
||||||
matChipRemove
|
|
||||||
[removable]="true"
|
|
||||||
(removed)="onRemoveTag(tag)"
|
|
||||||
>
|
|
||||||
{{ tag.name }}
|
|
||||||
<ion-icon class="ml-2" matPrefix name="close-outline" />
|
|
||||||
</mat-chip-row>
|
|
||||||
}
|
|
||||||
<input
|
|
||||||
#tagInput
|
|
||||||
name="close-outline"
|
|
||||||
[matAutocomplete]="autocompleteTags"
|
|
||||||
[matChipInputFor]="tagsChipList"
|
|
||||||
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
|
|
||||||
/>
|
|
||||||
</mat-chip-grid>
|
|
||||||
<mat-autocomplete
|
|
||||||
#autocompleteTags="matAutocomplete"
|
|
||||||
(optionSelected)="onAddTag($event)"
|
|
||||||
>
|
|
||||||
@for (tag of filteredTagsObservable | async; track tag.id) {
|
|
||||||
<mat-option [value]="tag.id">
|
|
||||||
{{ tag.name }}
|
|
||||||
</mat-option>
|
|
||||||
}
|
|
||||||
</mat-autocomplete>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -3,24 +3,20 @@ import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
|||||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||||
import { translate } from '@ghostfolio/ui/i18n';
|
import { translate } from '@ghostfolio/ui/i18n';
|
||||||
|
|
||||||
import { COMMA, ENTER } from '@angular/cdk/keycodes';
|
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
ElementRef,
|
|
||||||
Inject,
|
Inject,
|
||||||
OnDestroy,
|
OnDestroy
|
||||||
ViewChild
|
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
|
|
||||||
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
|
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
import { AssetClass, AssetSubClass, Tag, Type } from '@prisma/client';
|
import { AssetClass, AssetSubClass, Tag, Type } from '@prisma/client';
|
||||||
import { isAfter, isToday } from 'date-fns';
|
import { isAfter, isToday } from 'date-fns';
|
||||||
import { EMPTY, Observable, Subject, lastValueFrom, of } from 'rxjs';
|
import { EMPTY, Subject, lastValueFrom } from 'rxjs';
|
||||||
import { catchError, delay, map, startWith, takeUntil } from 'rxjs/operators';
|
import { catchError, delay, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
import { DataService } from '../../../../services/data.service';
|
import { DataService } from '../../../../services/data.service';
|
||||||
import { validateObjectForForm } from '../../../../util/form.util';
|
import { validateObjectForForm } from '../../../../util/form.util';
|
||||||
@ -35,9 +31,6 @@ import { CreateOrUpdateActivityDialogParams } from './interfaces/interfaces';
|
|||||||
standalone: false
|
standalone: false
|
||||||
})
|
})
|
||||||
export class CreateOrUpdateActivityDialog implements OnDestroy {
|
export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||||
@ViewChild('symbolAutocomplete') symbolAutocomplete;
|
|
||||||
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
|
|
||||||
|
|
||||||
public activityForm: FormGroup;
|
public activityForm: FormGroup;
|
||||||
public assetClasses = Object.keys(AssetClass).map((assetClass) => {
|
public assetClasses = Object.keys(AssetClass).map((assetClass) => {
|
||||||
return { id: assetClass, label: translate(assetClass) };
|
return { id: assetClass, label: translate(assetClass) };
|
||||||
@ -48,12 +41,10 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
public currencies: string[] = [];
|
public currencies: string[] = [];
|
||||||
public currentMarketPrice = null;
|
public currentMarketPrice = null;
|
||||||
public defaultDateFormat: string;
|
public defaultDateFormat: string;
|
||||||
public filteredTagsObservable: Observable<Tag[]> = of([]);
|
|
||||||
public isLoading = false;
|
public isLoading = false;
|
||||||
public isToday = isToday;
|
public isToday = isToday;
|
||||||
public mode: 'create' | 'update';
|
public mode: 'create' | 'update';
|
||||||
public platforms: { id: string; name: string }[];
|
public platforms: { id: string; name: string }[];
|
||||||
public separatorKeysCodes: number[] = [COMMA, ENTER];
|
|
||||||
public tagsAvailable: Tag[] = [];
|
public tagsAvailable: Tag[] = [];
|
||||||
public total = 0;
|
public total = 0;
|
||||||
public typesTranslationMap = new Map<Type, string>();
|
public typesTranslationMap = new Map<Type, string>();
|
||||||
@ -284,15 +275,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
this.changeDetectorRef.markForCheck();
|
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
|
this.activityForm
|
||||||
.get('type')
|
.get('type')
|
||||||
.valueChanges.pipe(takeUntil(this.unsubscribeSubject))
|
.valueChanges.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
@ -440,29 +422,10 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
return isAfter(aDate, new Date(0));
|
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() {
|
public onCancel() {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
public onRemoveTag(aTag: Tag) {
|
|
||||||
this.activityForm.get('tags').setValue(
|
|
||||||
this.activityForm.get('tags').value.filter(({ id }) => {
|
|
||||||
return id !== aTag.id;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async onSubmit() {
|
public async onSubmit() {
|
||||||
const activity: CreateOrderDto | UpdateOrderDto = {
|
const activity: CreateOrderDto | UpdateOrderDto = {
|
||||||
accountId: this.activityForm.get('accountId').value,
|
accountId: this.activityForm.get('accountId').value,
|
||||||
@ -518,21 +481,15 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onTagsChanged(tags: Tag[]) {
|
||||||
|
this.activityForm.get('tags').setValue(tags);
|
||||||
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
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() {
|
private updateSymbol() {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
|
@ -379,38 +379,11 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3" [ngClass]="{ 'd-none': tagsAvailable?.length < 1 }">
|
<div class="mb-3" [ngClass]="{ 'd-none': tagsAvailable?.length < 1 }">
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<gf-tags-selector
|
||||||
<mat-label i18n>Tags</mat-label>
|
[tags]="activityForm.get('tags')?.value"
|
||||||
<mat-chip-grid #tagsChipList>
|
[tagsAvailable]="tagsAvailable"
|
||||||
@for (tag of activityForm.get('tags')?.value; track tag.id) {
|
(tagsChanged)="onTagsChanged($event)"
|
||||||
<mat-chip-row
|
/>
|
||||||
matChipRemove
|
|
||||||
[removable]="true"
|
|
||||||
(removed)="onRemoveTag(tag)"
|
|
||||||
>
|
|
||||||
{{ tag.name }}
|
|
||||||
<ion-icon class="ml-2" matPrefix name="close-outline" />
|
|
||||||
</mat-chip-row>
|
|
||||||
}
|
|
||||||
<input
|
|
||||||
#tagInput
|
|
||||||
name="close-outline"
|
|
||||||
[matAutocomplete]="autocompleteTags"
|
|
||||||
[matChipInputFor]="tagsChipList"
|
|
||||||
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
|
|
||||||
/>
|
|
||||||
</mat-chip-grid>
|
|
||||||
<mat-autocomplete
|
|
||||||
#autocompleteTags="matAutocomplete"
|
|
||||||
(optionSelected)="onAddTag($event)"
|
|
||||||
>
|
|
||||||
@for (tag of filteredTagsObservable | async; track tag.id) {
|
|
||||||
<mat-option [value]="tag.id">
|
|
||||||
{{ tag.name }}
|
|
||||||
</mat-option>
|
|
||||||
}
|
|
||||||
</mat-autocomplete>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex" mat-dialog-actions>
|
<div class="d-flex" mat-dialog-actions>
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
|
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
|
||||||
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete';
|
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete';
|
||||||
|
import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector';
|
||||||
import { GfValueComponent } from '@ghostfolio/ui/value';
|
import { GfValueComponent } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
import { MatChipsModule } from '@angular/material/chips';
|
|
||||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
@ -24,11 +23,10 @@ import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
GfAssetProfileIconComponent,
|
GfAssetProfileIconComponent,
|
||||||
GfSymbolAutocompleteComponent,
|
GfSymbolAutocompleteComponent,
|
||||||
|
GfTagsSelectorComponent,
|
||||||
GfValueComponent,
|
GfValueComponent,
|
||||||
MatAutocompleteModule,
|
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCheckboxModule,
|
MatCheckboxModule,
|
||||||
MatChipsModule,
|
|
||||||
MatDatepickerModule,
|
MatDatepickerModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
|
1
libs/ui/src/lib/tags-selector/index.ts
Normal file
1
libs/ui/src/lib/tags-selector/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './tags-selector.component';
|
32
libs/ui/src/lib/tags-selector/tags-selector.component.html
Normal file
32
libs/ui/src/lib/tags-selector/tags-selector.component.html
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||||
|
<mat-label i18n>Tags</mat-label>
|
||||||
|
<mat-chip-grid #tagsChipList>
|
||||||
|
@for (tag of tagsSelected(); track tag.id) {
|
||||||
|
<mat-chip-row
|
||||||
|
matChipRemove
|
||||||
|
[removable]="true"
|
||||||
|
(removed)="onRemoveTag(tag)"
|
||||||
|
>
|
||||||
|
{{ tag.name }}
|
||||||
|
<ion-icon matChipTrailingIcon name="close-outline" />
|
||||||
|
</mat-chip-row>
|
||||||
|
}
|
||||||
|
<input
|
||||||
|
#tagInput
|
||||||
|
[formControl]="tagInputControl"
|
||||||
|
[matAutocomplete]="autocompleteTags"
|
||||||
|
[matChipInputFor]="tagsChipList"
|
||||||
|
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
|
||||||
|
/>
|
||||||
|
</mat-chip-grid>
|
||||||
|
<mat-autocomplete
|
||||||
|
#autocompleteTags="matAutocomplete"
|
||||||
|
(optionSelected)="onAddTag($event)"
|
||||||
|
>
|
||||||
|
@for (tag of filteredOptions | async; track tag.id) {
|
||||||
|
<mat-option [value]="tag.id">
|
||||||
|
{{ tag.name }}
|
||||||
|
</mat-option>
|
||||||
|
}
|
||||||
|
</mat-autocomplete>
|
||||||
|
</mat-form-field>
|
@ -0,0 +1,3 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
123
libs/ui/src/lib/tags-selector/tags-selector.component.ts
Normal file
123
libs/ui/src/lib/tags-selector/tags-selector.component.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { COMMA, ENTER } from '@angular/cdk/keycodes';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
CUSTOM_ELEMENTS_SCHEMA,
|
||||||
|
ElementRef,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
OnChanges,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
Output,
|
||||||
|
signal,
|
||||||
|
ViewChild
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import {
|
||||||
|
MatAutocompleteModule,
|
||||||
|
MatAutocompleteSelectedEvent
|
||||||
|
} from '@angular/material/autocomplete';
|
||||||
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { Tag } from '@prisma/client';
|
||||||
|
import { BehaviorSubject, Subject, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatAutocompleteModule,
|
||||||
|
MatChipsModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
ReactiveFormsModule
|
||||||
|
],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||||
|
selector: 'gf-tags-selector',
|
||||||
|
styleUrls: ['./tags-selector.component.scss'],
|
||||||
|
templateUrl: 'tags-selector.component.html'
|
||||||
|
})
|
||||||
|
export class GfTagsSelectorComponent implements OnInit, OnChanges, OnDestroy {
|
||||||
|
@Input() tags: Tag[];
|
||||||
|
@Input() tagsAvailable: Tag[];
|
||||||
|
|
||||||
|
@Output() tagsChanged = new EventEmitter<Tag[]>();
|
||||||
|
|
||||||
|
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
|
||||||
|
|
||||||
|
public filteredOptions: Subject<Tag[]> = new BehaviorSubject([]);
|
||||||
|
public readonly separatorKeysCodes: number[] = [COMMA, ENTER];
|
||||||
|
public readonly tagInputControl = new FormControl('');
|
||||||
|
public readonly tagsSelected = signal<Tag[]>([]);
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
this.tagInputControl.valueChanges
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((value) => {
|
||||||
|
this.filteredOptions.next(this.filterTags(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnInit() {
|
||||||
|
this.tagsSelected.set(this.tags);
|
||||||
|
this.updateFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnChanges() {
|
||||||
|
this.tagsSelected.set(this.tags);
|
||||||
|
this.updateFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onAddTag(event: MatAutocompleteSelectedEvent) {
|
||||||
|
const tag = this.tagsAvailable.find(({ id }) => {
|
||||||
|
return id === event.option.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.tagsSelected.update((tags) => {
|
||||||
|
return [...(tags ?? []), tag];
|
||||||
|
});
|
||||||
|
|
||||||
|
this.tagsChanged.emit(this.tagsSelected());
|
||||||
|
this.tagInput.nativeElement.value = '';
|
||||||
|
this.tagInputControl.setValue(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onRemoveTag(tag: Tag) {
|
||||||
|
this.tagsSelected.update((tags) => {
|
||||||
|
return tags.filter(({ id }) => {
|
||||||
|
return id !== tag.id;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.tagsChanged.emit(this.tagsSelected());
|
||||||
|
this.updateFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private filterTags(query: string = ''): Tag[] {
|
||||||
|
const tags = this.tagsSelected() ?? [];
|
||||||
|
const tagIds = tags.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.tagsAvailable.filter(({ id, name }) => {
|
||||||
|
return (
|
||||||
|
!tagIds.includes(id) && name.toLowerCase().includes(query.toLowerCase())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateFilters() {
|
||||||
|
this.filteredOptions.next(this.filterTags());
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user