From 12130daf2ced2fe32afd273665bdaefb3e09f1ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Mon, 20 Oct 2025 20:17:05 +0200 Subject: [PATCH] Task/extract portfolio filter sub form of assistant to reusable component (#5618) * Extract portfolio filter sub form of assistant to reusable component * Update changelog --- CHANGELOG.md | 1 + .../src/lib/interfaces/user.interface.ts | 9 +- .../src/lib/assistant/assistant.component.ts | 123 ++++-------- libs/ui/src/lib/assistant/assistant.html | 170 ++++++----------- .../ui/src/lib/portfolio-filter-form/index.ts | 2 + .../portfolio-filter-form/interfaces/index.ts | 1 + .../portfolio-filter-form-value.interface.ts | 8 + .../portfolio-filter-form.component.html | 75 ++++++++ .../portfolio-filter-form.component.scss | 3 + ...portfolio-filter-form.component.stories.ts | 79 ++++++++ .../portfolio-filter-form.component.ts | 177 ++++++++++++++++++ 11 files changed, 449 insertions(+), 199 deletions(-) create mode 100644 libs/ui/src/lib/portfolio-filter-form/index.ts create mode 100644 libs/ui/src/lib/portfolio-filter-form/interfaces/index.ts create mode 100644 libs/ui/src/lib/portfolio-filter-form/interfaces/portfolio-filter-form-value.interface.ts create mode 100644 libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html create mode 100644 libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.scss create mode 100644 libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.stories.ts create mode 100644 libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 98e166106..bce710f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Extracted the portfolio filter form of the assistant to a reusable component - Formatted the holdings table in the _Copy AI prompt to clipboard for analysis_ action on the analysis page (experimental) - Formatted the holdings table in the _Copy portfolio data to clipboard for AI prompt_ action on the analysis page (experimental) - Improved the language localization for German (`de`) diff --git a/libs/common/src/lib/interfaces/user.interface.ts b/libs/common/src/lib/interfaces/user.interface.ts index a48317fad..2e0906895 100644 --- a/libs/common/src/lib/interfaces/user.interface.ts +++ b/libs/common/src/lib/interfaces/user.interface.ts @@ -1,6 +1,9 @@ -import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type'; +import { + AccountWithPlatform, + SubscriptionType +} from '@ghostfolio/common/types'; -import { Access, Account, Tag } from '@prisma/client'; +import { Access, Tag } from '@prisma/client'; import { SubscriptionOffer } from './subscription-offer.interface'; import { SystemMessage } from './system-message.interface'; @@ -9,7 +12,7 @@ import { UserSettings } from './user-settings.interface'; // TODO: Compare with UserWithSettings export interface User { access: Pick[]; - accounts: Account[]; + accounts: AccountWithPlatform[]; activitiesCount: number; dateOfFirstActivity: Date; id: string; diff --git a/libs/ui/src/lib/assistant/assistant.component.ts b/libs/ui/src/lib/assistant/assistant.component.ts index 8c04c306f..eaf96f496 100644 --- a/libs/ui/src/lib/assistant/assistant.component.ts +++ b/libs/ui/src/lib/assistant/assistant.component.ts @@ -1,11 +1,10 @@ -import { GfSymbolPipe } from '@ghostfolio/client/pipes/symbol/symbol.pipe'; import { AdminService } from '@ghostfolio/client/services/admin.service'; import { DataService } from '@ghostfolio/client/services/data.service'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { Filter, PortfolioPosition, User } from '@ghostfolio/common/interfaces'; import { InternalRoute } from '@ghostfolio/common/routes/interfaces/internal-route.interface'; import { internalRoutes } from '@ghostfolio/common/routes/routes'; -import { DateRange } from '@ghostfolio/common/types'; +import { AccountWithPlatform, DateRange } from '@ghostfolio/common/types'; import { FocusKeyManager } from '@angular/cdk/a11y'; import { @@ -25,19 +24,14 @@ import { ViewChild, ViewChildren } from '@angular/core'; -import { - FormBuilder, - FormControl, - FormsModule, - ReactiveFormsModule -} from '@angular/forms'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatMenuTrigger } from '@angular/material/menu'; import { MatSelectModule } from '@angular/material/select'; import { RouterModule } from '@angular/router'; import { IonIcon } from '@ionic/angular/standalone'; -import { Account, AssetClass, DataSource } from '@prisma/client'; +import { AssetClass, DataSource } from '@prisma/client'; import { differenceInYears } from 'date-fns'; import Fuse from 'fuse.js'; import { addIcons } from 'ionicons'; @@ -60,8 +54,11 @@ import { tap } from 'rxjs/operators'; -import { GfEntityLogoComponent } from '../entity-logo/entity-logo.component'; import { translate } from '../i18n'; +import { + GfPortfolioFilterFormComponent, + PortfolioFilterFormValue +} from '../portfolio-filter-form'; import { GfAssistantListItemComponent } from './assistant-list-item/assistant-list-item.component'; import { SearchMode } from './enums/search-mode'; import { @@ -75,8 +72,7 @@ import { imports: [ FormsModule, GfAssistantListItemComponent, - GfEntityLogoComponent, - GfSymbolPipe, + GfPortfolioFilterFormComponent, IonIcon, MatButtonModule, MatFormFieldModule, @@ -141,16 +137,10 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { public static readonly SEARCH_RESULTS_DEFAULT_LIMIT = 5; - public accounts: Account[] = []; + public accounts: AccountWithPlatform[] = []; public assetClasses: Filter[] = []; public dateRangeFormControl = new FormControl(undefined); public dateRangeOptions: DateRangeOption[] = []; - public filterForm = this.formBuilder.group({ - account: new FormControl(undefined), - assetClass: new FormControl(undefined), - holding: new FormControl(undefined), - tag: new FormControl(undefined) - }); public holdings: PortfolioPosition[] = []; public isLoading = { accounts: false, @@ -160,6 +150,14 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { }; public isOpen = false; public placeholder = $localize`Find account, holding or page...`; + public portfolioFilterFormControl = new FormControl( + { + account: null, + assetClass: null, + holding: null, + tag: null + } + ); public searchFormControl = new FormControl(''); public searchResults: SearchResults = { accounts: [], @@ -186,8 +184,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { public constructor( private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, - private formBuilder: FormBuilder + private dataService: DataService ) { addIcons({ closeCircleOutline, closeOutline, searchOutline }); } @@ -244,7 +241,6 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { ); } - // Accounts const accounts$: Observable> = this.searchAccounts(searchTerm).pipe( map((accounts) => ({ @@ -263,7 +259,6 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { }) ); - // Asset profiles const assetProfiles$: Observable> = this .hasPermissionToAccessAdminControl ? this.searchAssetProfiles(searchTerm).pipe( @@ -292,7 +287,6 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { }) ); - // Holdings const holdings$: Observable> = this.searchHoldings(searchTerm).pipe( map((holdings) => ({ @@ -311,7 +305,6 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { }) ); - // Quick links const quickLinks$: Observable> = of( this.searchQuickLinks(searchTerm) ).pipe( @@ -327,7 +320,6 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { }) ); - // Merge all results return merge(accounts$, assetProfiles$, holdings$, quickLinks$).pipe( scan( (acc: SearchResults, curr: Partial) => ({ @@ -362,22 +354,11 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { quickLinks: [] }; this.changeDetectorRef.markForCheck(); - }, - complete: () => { - this.isLoading = { - accounts: false, - assetProfiles: false, - holdings: false, - quickLinks: false - }; - this.changeDetectorRef.markForCheck(); } }); } public ngOnChanges() { - this.accounts = this.user?.accounts ?? []; - this.dateRangeOptions = [ { label: $localize`Today`, @@ -445,7 +426,11 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { this.dateRangeFormControl.setValue(this.user?.settings?.dateRange ?? null); - this.filterForm.disable({ emitEvent: false }); + if (this.hasPermissionToChangeFilters) { + this.portfolioFilterFormControl.enable({ emitEvent: false }); + } else { + this.portfolioFilterFormControl.disable({ emitEvent: false }); + } this.tags = this.user?.tags @@ -459,29 +444,6 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { type: 'TAG' }; }) ?? []; - - if (this.tags.length === 0) { - this.filterForm.get('tag').disable({ emitEvent: false }); - } - } - - public hasFilter(aFormValue: { [key: string]: string }) { - return Object.values(aFormValue).some((value) => { - return !!value; - }); - } - - public holdingComparisonFunction( - option: PortfolioPosition, - value: PortfolioPosition - ): boolean { - if (value === null) { - return false; - } - - return ( - getAssetProfileIdentifier(option) === getAssetProfileIdentifier(value) - ); } public initialize() { @@ -527,36 +489,35 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { .sort((a, b) => { return a.name?.localeCompare(b.name); }); - this.setFilterFormValues(); - if (this.hasPermissionToChangeFilters) { - this.filterForm.enable({ emitEvent: false }); - } + this.setPortfolioFilterFormValues(); this.changeDetectorRef.markForCheck(); }); } public onApplyFilters() { + const filterValue = this.portfolioFilterFormControl.value; + this.filtersChanged.emit([ { - id: this.filterForm.get('account').value, + id: filterValue?.account, type: 'ACCOUNT' }, { - id: this.filterForm.get('assetClass').value, + id: filterValue?.assetClass, type: 'ASSET_CLASS' }, { - id: this.filterForm.get('holding').value?.dataSource, + id: filterValue?.holding?.dataSource, type: 'DATA_SOURCE' }, { - id: this.filterForm.get('holding').value?.symbol, + id: filterValue?.holding?.symbol, type: 'SYMBOL' }, { - id: this.filterForm.get('tag').value, + id: filterValue?.tag, type: 'TAG' } ]); @@ -569,12 +530,15 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { } public onCloseAssistant() { + this.portfolioFilterFormControl.reset(); this.setIsOpen(false); this.closed.emit(); } public onResetFilters() { + this.portfolioFilterFormControl.reset(); + this.filtersChanged.emit( this.filterTypes.map((type) => { return { @@ -786,7 +750,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { }); } - private setFilterFormValues() { + private setPortfolioFilterFormValues() { const dataSource = this.user?.settings?.[ 'filters.dataSource' ] as DataSource; @@ -800,16 +764,11 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { ); }); - this.filterForm.setValue( - { - account: this.user?.settings?.['filters.accounts']?.[0] ?? null, - assetClass: this.user?.settings?.['filters.assetClasses']?.[0] ?? null, - holding: selectedHolding ?? null, - tag: this.user?.settings?.['filters.tags']?.[0] ?? null - }, - { - emitEvent: false - } - ); + this.portfolioFilterFormControl.setValue({ + account: this.user?.settings?.['filters.accounts']?.[0] ?? null, + assetClass: this.user?.settings?.['filters.assetClasses']?.[0] ?? null, + holding: selectedHolding ?? null, + tag: this.user?.settings?.['filters.tags']?.[0] ?? null + }); } } diff --git a/libs/ui/src/lib/assistant/assistant.html b/libs/ui/src/lib/assistant/assistant.html index 5954ce369..e0a8f2fc9 100644 --- a/libs/ui/src/lib/assistant/assistant.html +++ b/libs/ui/src/lib/assistant/assistant.html @@ -164,119 +164,61 @@ } -
- @if (!searchFormControl.value) { -
- - Date Range - - @for (range of dateRangeOptions; track range) { - {{ range.label }} - } - - -
-
-
- - Account - - - @for (account of accounts; track account.id) { - -
- @if (account.platform?.url) { - - } - {{ account.name }} -
-
- } -
-
-
-
- - Holding - - {{ - filterForm.get('holding')?.value?.name - }} - - @for (holding of holdings; track holding.name) { - -
- {{ holding.name }} -
- {{ holding.symbol | gfSymbol }} · - {{ holding.currency }} -
-
- } -
-
-
-
- - Tag - - - @for (tag of tags; track tag.id) { - {{ tag.label }} - } - - -
-
- - Asset Class - - - @for (assetClass of assetClasses; track assetClass.id) { - {{ - assetClass.label - }} - } - - -
-
- - - -
+ @if (!searchFormControl.value) { +
+ + Date Range + + @for ( + dateRangeOption of dateRangeOptions; + track dateRangeOption.value + ) { + {{ + dateRangeOption.label + }} + } + + +
+
+ +
+ + +
- } - +
+ }
diff --git a/libs/ui/src/lib/portfolio-filter-form/index.ts b/libs/ui/src/lib/portfolio-filter-form/index.ts new file mode 100644 index 000000000..51d22c034 --- /dev/null +++ b/libs/ui/src/lib/portfolio-filter-form/index.ts @@ -0,0 +1,2 @@ +export * from './interfaces'; +export * from './portfolio-filter-form.component'; diff --git a/libs/ui/src/lib/portfolio-filter-form/interfaces/index.ts b/libs/ui/src/lib/portfolio-filter-form/interfaces/index.ts new file mode 100644 index 000000000..62feaa56a --- /dev/null +++ b/libs/ui/src/lib/portfolio-filter-form/interfaces/index.ts @@ -0,0 +1 @@ +export * from './portfolio-filter-form-value.interface'; diff --git a/libs/ui/src/lib/portfolio-filter-form/interfaces/portfolio-filter-form-value.interface.ts b/libs/ui/src/lib/portfolio-filter-form/interfaces/portfolio-filter-form-value.interface.ts new file mode 100644 index 000000000..21ff0ae3b --- /dev/null +++ b/libs/ui/src/lib/portfolio-filter-form/interfaces/portfolio-filter-form-value.interface.ts @@ -0,0 +1,8 @@ +import { PortfolioPosition } from '@ghostfolio/common/interfaces'; + +export interface PortfolioFilterFormValue { + account: string; + assetClass: string; + holding: PortfolioPosition; + tag: string; +} diff --git a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html new file mode 100644 index 000000000..e017d33d6 --- /dev/null +++ b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html @@ -0,0 +1,75 @@ +
+
+ + Account + + + @for (account of accounts; track account.id) { + +
+ @if (account.platform?.url) { + + } + {{ account.name }} +
+
+ } +
+
+
+
+ + Holding + + {{ + filterForm.get('holding')?.value?.name + }} + + @for (holding of holdings; track holding.name) { + +
+ {{ holding.name }} +
+ {{ holding.symbol | gfSymbol }} · {{ holding.currency }} +
+
+ } +
+
+
+
+ + Tag + + + @for (tag of tags; track tag.id) { + {{ tag.label }} + } + + +
+
+ + Asset Class + + + @for (assetClass of assetClasses; track assetClass.id) { + {{ + assetClass.label + }} + } + + +
+
diff --git a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.scss b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.stories.ts b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.stories.ts new file mode 100644 index 000000000..710a4e9c5 --- /dev/null +++ b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.stories.ts @@ -0,0 +1,79 @@ +import '@angular/localize/init'; +import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; + +import { GfPortfolioFilterFormComponent } from './portfolio-filter-form.component'; + +const meta: Meta = { + title: 'Portfolio Filter Form', + component: GfPortfolioFilterFormComponent, + decorators: [ + moduleMetadata({ + imports: [GfPortfolioFilterFormComponent] + }) + ] +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + accounts: [ + { + id: '733110b6-7c55-44eb-8cc5-c4c3e9d48a79', + name: 'Trading Account', + platform: { + name: 'Interactive Brokers', + url: 'https://interactivebrokers.com' + } + }, + { + id: '24ba27d6-e04b-4fb4-b856-b24c2ef0422a', + name: 'Investment Account', + platform: { + name: 'Fidelity', + url: 'https://fidelity.com' + } + } + ] as any, + assetClasses: [ + { id: 'COMMODITY', label: 'Commodity', type: 'ASSET_CLASS' }, + { id: 'EQUITY', label: 'Equity', type: 'ASSET_CLASS' }, + { id: 'FIXED_INCOME', label: 'Fixed Income', type: 'ASSET_CLASS' } + ] as any, + holdings: [ + { + currency: 'USD', + dataSource: 'YAHOO', + name: 'Apple Inc.', + symbol: 'AAPL' + }, + { + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Corporation', + symbol: 'MSFT' + } + ] as any, + tags: [ + { + id: 'EMERGENCY_FUND', + label: 'Emergency Fund', + type: 'TAG' + }, + { + id: 'RETIREMENT_FUND', + label: 'Retirement Fund', + type: 'TAG' + } + ] as any, + disabled: false + } +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true + } +}; diff --git a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts new file mode 100644 index 000000000..794f43d4d --- /dev/null +++ b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts @@ -0,0 +1,177 @@ +import { GfSymbolPipe } from '@ghostfolio/client/pipes/symbol/symbol.pipe'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; +import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; +import { AccountWithPlatform } from '@ghostfolio/common/types'; + +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + OnChanges, + OnDestroy, + OnInit, + forwardRef +} from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + FormsModule, + NG_VALUE_ACCESSOR, + ReactiveFormsModule +} from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { Subject, takeUntil } from 'rxjs'; + +import { GfEntityLogoComponent } from '../entity-logo/entity-logo.component'; +import { PortfolioFilterFormValue } from './interfaces'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormsModule, + GfEntityLogoComponent, + GfSymbolPipe, + MatFormFieldModule, + MatSelectModule, + ReactiveFormsModule + ], + providers: [ + { + multi: true, + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => GfPortfolioFilterFormComponent) + } + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-portfolio-filter-form', + styleUrls: ['./portfolio-filter-form.component.scss'], + templateUrl: './portfolio-filter-form.component.html' +}) +export class GfPortfolioFilterFormComponent + implements ControlValueAccessor, OnInit, OnChanges, OnDestroy +{ + @Input() accounts: AccountWithPlatform[] = []; + @Input() assetClasses: Filter[] = []; + @Input() holdings: PortfolioPosition[] = []; + @Input() tags: Filter[] = []; + @Input() disabled = false; + + public filterForm: FormGroup; + + private unsubscribeSubject = new Subject(); + + public constructor( + private changeDetectorRef: ChangeDetectorRef, + private formBuilder: FormBuilder + ) { + this.filterForm = this.formBuilder.group({ + account: new FormControl(null), + assetClass: new FormControl(null), + holding: new FormControl(null), + tag: new FormControl(null) + }); + } + + public ngOnInit() { + this.filterForm.valueChanges + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((value) => { + this.onChange(value as PortfolioFilterFormValue); + this.onTouched(); + }); + } + + public hasFilters() { + const formValue = this.filterForm.value; + + return Object.values(formValue).some((value) => { + return !!value; + }); + } + + public holdingComparisonFunction( + option: PortfolioPosition, + value: PortfolioPosition + ) { + if (value === null) { + return false; + } + + return ( + getAssetProfileIdentifier(option) === getAssetProfileIdentifier(value) + ); + } + + public ngOnChanges() { + if (this.disabled) { + this.filterForm.disable({ emitEvent: false }); + } else { + this.filterForm.enable({ emitEvent: false }); + } + + const tagControl = this.filterForm.get('tag'); + + if (this.tags.length === 0) { + tagControl?.disable({ emitEvent: false }); + } else if (!this.disabled) { + tagControl?.enable({ emitEvent: false }); + } + + this.changeDetectorRef.markForCheck(); + } + + public registerOnChange(fn: (value: PortfolioFilterFormValue) => void) { + this.onChange = fn; + } + + public registerOnTouched(fn: () => void) { + this.onTouched = fn; + } + + public setDisabledState(isDisabled: boolean) { + this.disabled = isDisabled; + + if (this.disabled) { + this.filterForm.disable({ emitEvent: false }); + } else { + this.filterForm.enable({ emitEvent: false }); + } + + this.changeDetectorRef.markForCheck(); + } + + public writeValue(value: PortfolioFilterFormValue | null) { + if (value) { + this.filterForm.setValue( + { + account: value.account ?? null, + assetClass: value.assetClass ?? null, + holding: value.holding ?? null, + tag: value.tag ?? null + }, + { emitEvent: false } + ); + } else { + this.filterForm.reset({}, { emitEvent: false }); + } + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private onChange = (_value: PortfolioFilterFormValue): void => { + // ControlValueAccessor onChange callback + }; + + private onTouched = (): void => { + // ControlValueAccessor onTouched callback + }; +}