diff --git a/CHANGELOG.md b/CHANGELOG.md index e8d6ea601..902fe4695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Added diff --git a/apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts b/apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts index a137f7abf..dc3f6d979 100644 --- a/apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts +++ b/apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts @@ -10,7 +10,7 @@ export class RegionalMarketClusterRiskNorthAmerica extends Rule { public constructor( protected exchangeRateDataService: ExchangeRateDataService, currentValueInBaseCurrency: number, - valueInBaseCurrency + northAmericaValueInBaseCurrency: number ) { super(exchangeRateDataService, { key: RegionalMarketClusterRiskNorthAmerica.name, @@ -18,7 +18,7 @@ export class RegionalMarketClusterRiskNorthAmerica extends Rule { }); this.currentValueInBaseCurrency = currentValueInBaseCurrency; - this.northAmericaValueInBaseCurrency = valueInBaseCurrency; + this.northAmericaValueInBaseCurrency = northAmericaValueInBaseCurrency; } public evaluate(ruleSettings: Settings) { diff --git a/apps/client/src/app/components/access-table/access-table.component.ts b/apps/client/src/app/components/access-table/access-table.component.ts index 32ae7bfef..34c5fbda2 100644 --- a/apps/client/src/app/components/access-table/access-table.component.ts +++ b/apps/client/src/app/components/access-table/access-table.component.ts @@ -14,6 +14,7 @@ import { } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTableDataSource } from '@angular/material/table'; +import ms from 'ms'; @Component({ selector: 'gf-access-table', @@ -64,7 +65,7 @@ export class AccessTableComponent implements OnChanges { '✅ ' + $localize`Link has been copied to the clipboard`, undefined, { - duration: 3000 + duration: ms('3 seconds') } ); } diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts index bb19ad96c..1467a1ba3 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts @@ -88,8 +88,14 @@ export class AssetProfileDialog implements OnDestroy, OnInit { public isBenchmark = false; public marketDataItems: MarketData[] = []; public modeValues = [ - { value: 'lazy', viewValue: $localize`Lazy` }, - { value: 'instant', viewValue: $localize`Instant` } + { + value: 'lazy', + viewValue: $localize`Lazy` + ' (' + $localize`end of day` + ')' + }, + { + value: 'instant', + viewValue: $localize`Instant` + ' (' + $localize`real-time` + ')' + } ]; public scraperConfiguationIsExpanded = signal(false); public sectors: { diff --git a/apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html b/apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html index ac777ffda..d3b0985fa 100644 --- a/apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html +++ b/apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html @@ -17,8 +17,8 @@ data provider for self-hosters, offering - 100’000+ tickers from over 50 exchanges, - is coming soon! + 80’000+ tickers from over 50 exchanges, is + coming soon!

Want to stay updated? Click below to get notified as soon as it’s available. 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 d13158898..297a990ec 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,26 +18,20 @@ 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'; import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, 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 { MatButtonModule } from '@angular/material/button'; import { MatChipsModule } from '@angular/material/chips'; import { @@ -53,8 +47,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,8 +64,8 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces'; GfDialogHeaderModule, GfLineChartComponent, GfPortfolioProportionChartComponent, + GfTagsSelectorComponent, GfValueComponent, - MatAutocompleteModule, MatButtonModule, MatChipsModule, MatDialogModule, @@ -85,8 +79,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 +94,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; @@ -122,7 +113,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { public sectors: { [name: string]: { name: string; value: number }; }; - public separatorKeysCodes: number[] = [COMMA, ENTER]; public sortColumn = 'date'; public sortDirection: SortDirection = 'desc'; public SymbolProfile: EnhancedSymbolProfile; @@ -319,17 +309,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,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) { this.router.navigate(['/portfolio', 'activities'], { queryParams: { activityId: aActivity.id, createDialog: true } @@ -480,12 +448,8 @@ 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 onTagsChanged(tags: Tag[]) { + this.activityForm.get('tags').setValue(tags); } public onUpdateActivity(aActivity: Activity) { @@ -500,14 +464,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..a20c9af7a 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/components/user-account-membership/user-account-membership.component.ts b/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts index 6139d173e..2214d91f9 100644 --- a/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts @@ -12,11 +12,7 @@ import { Component, OnDestroy } from '@angular/core'; -import { - MatSnackBar, - MatSnackBarRef, - TextOnlySnackBar -} from '@angular/material/snack-bar'; +import { MatSnackBar } from '@angular/material/snack-bar'; import ms, { StringValue } from 'ms'; import { StripeService } from 'ngx-stripe'; import { EMPTY, Subject } from 'rxjs'; @@ -41,7 +37,6 @@ export class UserAccountMembershipComponent implements OnDestroy { public price: number; public priceId: string; public routerLinkPricing = ['/' + $localize`:snake-case:pricing`]; - public snackBarRef: MatSnackBarRef; public trySubscriptionMail = 'mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards'; public user: User; @@ -186,22 +181,22 @@ export class UserAccountMembershipComponent implements OnDestroy { takeUntil(this.unsubscribeSubject) ) .subscribe(() => { - this.snackBarRef = this.snackBar.open( + const snackBarRef = this.snackBar.open( '✅ ' + $localize`Coupon code has been redeemed`, $localize`Reload`, { - duration: 3000 + duration: ms('3 seconds') } ); - this.snackBarRef + snackBarRef .afterDismissed() .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(() => { window.location.reload(); }); - this.snackBarRef + snackBarRef .onAction() .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(() => { diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts index c1472515f..ced617117 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts @@ -25,6 +25,7 @@ import { MatSlideToggleChange } from '@angular/material/slide-toggle'; import { MatSnackBar } from '@angular/material/snack-bar'; import { format, parseISO } from 'date-fns'; import { uniq } from 'lodash'; +import ms from 'ms'; import { EMPTY, Subject, throwError } from 'rxjs'; import { catchError, takeUntil } from 'rxjs/operators'; @@ -301,7 +302,9 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit { this.snackBar.open( $localize`Oops! There was an error setting up biometric authentication.`, undefined, - { duration: 3000 } + { + duration: ms('3 seconds') + } ); return throwError(() => { diff --git a/apps/client/src/app/core/http-response.interceptor.ts b/apps/client/src/app/core/http-response.interceptor.ts index 203d3adf5..018e441fc 100644 --- a/apps/client/src/app/core/http-response.interceptor.ts +++ b/apps/client/src/app/core/http-response.interceptor.ts @@ -19,6 +19,7 @@ import { } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { StatusCodes } from 'http-status-codes'; +import ms from 'ms'; import { Observable, throwError } from 'rxjs'; import { catchError, tap } from 'rxjs/operators'; @@ -54,13 +55,17 @@ export class HttpResponseInterceptor implements HttpInterceptor { ' ' + $localize`Please try again later.`, undefined, - { duration: 6000 } + { + duration: ms('6 seconds') + } ); } else if (!error.url.includes('/auth')) { this.snackBarRef = this.snackBar.open( $localize`This action is not allowed.`, undefined, - { duration: 6000 } + { + duration: ms('6 seconds') + } ); } @@ -79,7 +84,9 @@ export class HttpResponseInterceptor implements HttpInterceptor { ' ' + $localize`Please try again later.`, $localize`Okay`, - { duration: 6000 } + { + duration: ms('6 seconds') + } ); this.snackBarRef.afterDismissed().subscribe(() => { 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..555fbc7aa 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 @@ -3,24 +3,20 @@ import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { getDateFormatString } from '@ghostfolio/common/helper'; import { translate } from '@ghostfolio/ui/i18n'; -import { COMMA, ENTER } from '@angular/cdk/keycodes'; 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 } from 'rxjs'; +import { catchError, delay, takeUntil } from 'rxjs/operators'; import { DataService } from '../../../../services/data.service'; import { validateObjectForForm } from '../../../../util/form.util'; @@ -35,9 +31,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,12 +41,10 @@ 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'; public platforms: { id: string; name: string }[]; - public separatorKeysCodes: number[] = [COMMA, ENTER]; public tagsAvailable: Tag[] = []; public total = 0; public typesTranslationMap = new Map(); @@ -284,15 +275,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,29 +422,10 @@ 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 async onSubmit() { const activity: CreateOrderDto | UpdateOrderDto = { 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() { 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); - }); - } - 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..8fb2c1bed 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,14 +1,13 @@ 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'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatChipsModule } from '@angular/material/chips'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDialogModule } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -24,11 +23,10 @@ import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog FormsModule, GfAssetProfileIconComponent, GfSymbolAutocompleteComponent, + GfTagsSelectorComponent, GfValueComponent, - MatAutocompleteModule, MatButtonModule, MatCheckboxModule, - MatChipsModule, MatDatepickerModule, MatDialogModule, MatFormFieldModule, diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts index 2f5ead47a..82e78a180 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts @@ -23,6 +23,7 @@ import { MatStepper } from '@angular/material/stepper'; import { MatTableDataSource } from '@angular/material/table'; import { AssetClass } from '@prisma/client'; import { isArray, sortBy } from 'lodash'; +import ms from 'ms'; import { DeviceDetectorService } from 'ngx-device-detector'; import { Subject, takeUntil } from 'rxjs'; @@ -133,7 +134,7 @@ export class ImportActivitiesDialog implements OnDestroy { '✅ ' + $localize`Import has been completed`, undefined, { - duration: 3000 + duration: ms('3 seconds') } ); } catch (error) { @@ -142,7 +143,9 @@ export class ImportActivitiesDialog implements OnDestroy { ' ' + $localize`Please try again later.`, $localize`Okay`, - { duration: 3000 } + { + duration: ms('3 seconds') + } ); } finally { this.dialogRef.close(); diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts index caa0b15e6..7fbb1e621 100644 --- a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts @@ -261,7 +261,9 @@ export class GfHistoricalMarketDataEditorComponent this.snackBar.open( $localize`Oops! Could not parse historical data.`, undefined, - { duration: ms('3 seconds') } + { + duration: ms('3 seconds') + } ); } } diff --git a/libs/ui/src/lib/i18n.ts b/libs/ui/src/lib/i18n.ts index c0f95d457..7ea36123b 100644 --- a/libs/ui/src/lib/i18n.ts +++ b/libs/ui/src/lib/i18n.ts @@ -21,7 +21,7 @@ const locales = { MONTH: $localize`Month`, MONTHS: $localize`Months`, OTHER: $localize`Other`, - PROFESSIONAL_DATA_PROVIDER_TOOLTIP_PREMIUM: $localize`Get access to 100’000+ tickers from over 50 exchanges`, + PROFESSIONAL_DATA_PROVIDER_TOOLTIP_PREMIUM: $localize`Get access to 80’000+ tickers from over 50 exchanges`, PRESET_ID: $localize`Preset`, RETIREMENT_PROVISION: $localize`Retirement Provision`, SATELLITE: $localize`Satellite`, 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..55f8a39f2 --- /dev/null +++ b/libs/ui/src/lib/tags-selector/tags-selector.component.html @@ -0,0 +1,32 @@ + + Tags + + @for (tag of tagsSelected(); track tag.id) { + + {{ tag.name }} + + + } + + + + @for (tag of filteredOptions | async; 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..5d4e87f30 --- /dev/null +++ b/libs/ui/src/lib/tags-selector/tags-selector.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} 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..77c776ece --- /dev/null +++ b/libs/ui/src/lib/tags-selector/tags-selector.component.ts @@ -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(); + + @ViewChild('tagInput') tagInput: ElementRef; + + public filteredOptions: Subject = new BehaviorSubject([]); + public readonly separatorKeysCodes: number[] = [COMMA, ENTER]; + public readonly tagInputControl = new FormControl(''); + public readonly tagsSelected = signal([]); + + private unsubscribeSubject = new Subject(); + + 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()); + } +}