Browse Source

Merge branch 'ghostfolio:main' into feature/upgrade-nestjs-version11

pull/4270/merge^2
Haruka Kishida 3 months ago
committed by GitHub
parent
commit
d4d4217393
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 4
      apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts
  3. 3
      apps/client/src/app/components/access-table/access-table.component.ts
  4. 10
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  5. 4
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html
  6. 60
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  7. 37
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  8. 15
      apps/client/src/app/components/user-account-membership/user-account-membership.component.ts
  9. 5
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  10. 13
      apps/client/src/app/core/http-response.interceptor.ts
  11. 57
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  12. 37
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  13. 6
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.module.ts
  14. 7
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  15. 4
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts
  16. 2
      libs/ui/src/lib/i18n.ts
  17. 1
      libs/ui/src/lib/tags-selector/index.ts
  18. 32
      libs/ui/src/lib/tags-selector/tags-selector.component.html
  19. 3
      libs/ui/src/lib/tags-selector/tags-selector.component.scss
  20. 123
      libs/ui/src/lib/tags-selector/tags-selector.component.ts

6
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

4
apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts

@ -10,7 +10,7 @@ export class RegionalMarketClusterRiskNorthAmerica extends Rule<Settings> {
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
currentValueInBaseCurrency: number,
valueInBaseCurrency
northAmericaValueInBaseCurrency: number
) {
super(exchangeRateDataService, {
key: RegionalMarketClusterRiskNorthAmerica.name,
@ -18,7 +18,7 @@ export class RegionalMarketClusterRiskNorthAmerica extends Rule<Settings> {
});
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
this.northAmericaValueInBaseCurrency = valueInBaseCurrency;
this.northAmericaValueInBaseCurrency = northAmericaValueInBaseCurrency;
}
public evaluate(ruleSettings: Settings) {

3
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')
}
);
}

10
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: {

4
apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html

@ -17,8 +17,8 @@
<gf-premium-indicator class="d-inline-block ml-1" [enableLink]="false" />
</a>
data provider <strong>for self-hosters</strong>, offering
<strong>100’000+ tickers</strong> from over <strong>50 exchanges</strong>,
is coming soon!
<strong>80’000+ tickers</strong> from over <strong>50 exchanges</strong>, is
coming soon!
</p>
<p i18n>
Want to stay updated? Click below to get notified as soon as it’s available.

60
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<HTMLInputElement>;
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<Tag[]> = 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);
});
}
}

37
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -373,38 +373,11 @@
}"
>
<div class="col">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Tags</mat-label>
<mat-chip-grid #tagsChipList>
@for (tag of activityForm.get('tags')?.value; track tag.id) {
<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>
<gf-tags-selector
[tags]="activityForm.get('tags')?.value"
[tagsAvailable]="tagsAvailable"
(tagsChanged)="onTagsChanged($event)"
/>
</div>
</div>

15
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<TextOnlySnackBar>;
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(() => {

5
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(() => {

13
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(() => {

57
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<HTMLInputElement>;
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<Tag[]> = 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<Type, string>();
@ -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();

37
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

@ -379,38 +379,11 @@
</mat-form-field>
</div>
<div class="mb-3" [ngClass]="{ 'd-none': tagsAvailable?.length < 1 }">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Tags</mat-label>
<mat-chip-grid #tagsChipList>
@for (tag of activityForm.get('tags')?.value; track tag.id) {
<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>
<gf-tags-selector
[tags]="activityForm.get('tags')?.value"
[tagsAvailable]="tagsAvailable"
(tagsChanged)="onTagsChanged($event)"
/>
</div>
</div>
<div class="d-flex" mat-dialog-actions>

6
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,

7
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();

4
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')
}
);
}
}

2
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`,

1
libs/ui/src/lib/tags-selector/index.ts

@ -0,0 +1 @@
export * from './tags-selector.component';

32
libs/ui/src/lib/tags-selector/tags-selector.component.html

@ -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>

3
libs/ui/src/lib/tags-selector/tags-selector.component.scss

@ -0,0 +1,3 @@
:host {
display: block;
}

123
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<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…
Cancel
Save