From 18ebf333000b5070d4cc49ff44d41eb531f4eda9 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:20:26 +0200 Subject: [PATCH] Extract account selector to reusable component --- .../transfer-balance/interfaces/interfaces.ts | 5 +- .../transfer-balance-dialog.component.ts | 25 +--- .../transfer-balance-dialog.html | 74 ++-------- ...ate-or-update-activity-dialog.component.ts | 11 +- .../create-or-update-activity-dialog.html | 45 +----- .../account-selector.component.html | 44 ++++++ .../account-selector.component.stories.ts | 131 ++++++++++++++++++ .../account-selector.component.ts | 109 +++++++++++++++ libs/ui/src/lib/account-selector/index.ts | 1 + .../portfolio-filter-form.component.html | 26 +--- .../portfolio-filter-form.component.ts | 7 +- 11 files changed, 327 insertions(+), 151 deletions(-) create mode 100644 libs/ui/src/lib/account-selector/account-selector.component.html create mode 100644 libs/ui/src/lib/account-selector/account-selector.component.stories.ts create mode 100644 libs/ui/src/lib/account-selector/account-selector.component.ts create mode 100644 libs/ui/src/lib/account-selector/index.ts diff --git a/apps/client/src/app/pages/accounts/transfer-balance/interfaces/interfaces.ts b/apps/client/src/app/pages/accounts/transfer-balance/interfaces/interfaces.ts index 51c42bc5d..53f8f522e 100644 --- a/apps/client/src/app/pages/accounts/transfer-balance/interfaces/interfaces.ts +++ b/apps/client/src/app/pages/accounts/transfer-balance/interfaces/interfaces.ts @@ -1,8 +1,9 @@ +import { AccountWithPlatform } from '@ghostfolio/common/types'; + import { FormControl, FormGroup } from '@angular/forms'; -import { Account } from '@prisma/client'; export interface TransferBalanceDialogParams { - accounts: Account[]; + accounts: AccountWithPlatform[]; } export type TransferBalanceForm = FormGroup<{ diff --git a/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts b/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts index 1682874dc..335ebd453 100644 --- a/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts +++ b/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts @@ -1,5 +1,6 @@ import { TransferBalanceDto } from '@ghostfolio/common/dtos'; -import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; +import { AccountWithPlatform } from '@ghostfolio/common/types'; +import { GfAccountSelectorComponent } from '@ghostfolio/ui/account-selector'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { @@ -17,8 +18,6 @@ import { } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { Account } from '@prisma/client'; import { TransferBalanceDialogParams, @@ -29,12 +28,11 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'h-100' }, imports: [ - GfEntityLogoComponent, + GfAccountSelectorComponent, MatButtonModule, MatDialogModule, MatFormFieldModule, MatInputModule, - MatSelectModule, ReactiveFormsModule ], selector: 'gf-transfer-balance-dialog', @@ -42,11 +40,14 @@ import { templateUrl: 'transfer-balance-dialog.html' }) export class GfTransferBalanceDialogComponent { - protected readonly accounts: Account[] = + protected readonly accounts: AccountWithPlatform[] = inject(MAT_DIALOG_DATA).accounts; protected currency: string; + protected readonly labelFrom = $localize`From`; + protected readonly labelTo = $localize`To`; + protected readonly transferBalanceForm: TransferBalanceForm = new FormGroup( { balance: new FormControl('', Validators.required), @@ -61,18 +62,6 @@ export class GfTransferBalanceDialogComponent { private readonly dialogRef = inject>(MatDialogRef); - protected get selectedFromAccount() { - return this.getAccountById( - this.transferBalanceForm.controls.fromAccount.value - ); - } - - protected get selectedToAccount() { - return this.getAccountById( - this.transferBalanceForm.controls.toAccount.value - ); - } - public ngOnInit() { this.transferBalanceForm.controls.fromAccount.valueChanges.subscribe( (id) => { diff --git a/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html b/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html index 941c9485a..0b0f88805 100644 --- a/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html +++ b/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html @@ -7,72 +7,18 @@

Transfer Cash Balance

- - From - - -
- @if (selectedFromAccount) { - - } - {{ selectedFromAccount?.name }} -
-
- - @for (account of accounts; track account) { - -
- - {{ account.name }} -
-
- } -
-
+
- - To - - -
- @if (selectedToAccount) { - - } - {{ selectedToAccount?.name }} -
-
- - @for (account of accounts; track account) { - -
- - {{ account.name }} -
-
- } -
-
+
diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts index 07f596711..2484f6afe 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 @@ -11,7 +11,7 @@ import { } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { validateObjectForForm } from '@ghostfolio/common/utils'; -import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; +import { GfAccountSelectorComponent } from '@ghostfolio/ui/account-selector'; import { translate } from '@ghostfolio/ui/i18n'; import { DataService } from '@ghostfolio/ui/services'; import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete'; @@ -59,7 +59,7 @@ import { ActivityType } from './types/activity-type.type'; changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'h-100' }, imports: [ - GfEntityLogoComponent, + GfAccountSelectorComponent, GfSymbolAutocompleteComponent, GfTagsSelectorComponent, GfValueComponent, @@ -98,6 +98,7 @@ export class GfCreateOrUpdateActivityDialogComponent { protected hasPermissionToCreateOwnTag: boolean; protected isLoading = false; protected readonly isToday = isToday; + protected readonly labelAccount = $localize`Account`; protected mode: 'create' | 'update'; protected tagsAvailable: Tag[] = []; protected total = 0; @@ -121,12 +122,6 @@ export class GfCreateOrUpdateActivityDialogComponent { addIcons({ calendarClearOutline, refreshOutline }); } - protected get selectedAccount() { - return this.data.accounts.find(({ id }) => { - return id === this.activityForm.get('accountId')?.value; - }); - } - public ngOnInit() { this.currencyOfAssetProfile = this.data.activity?.assetProfile?.currency; this.hasPermissionToCreateOwnTag = hasPermission( 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 9455a9dd6..8a797250c 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 @@ -77,45 +77,14 @@
- - Account - - -
- @if (selectedAccount) { - - } - {{ selectedAccount?.name }} -
-
- - - - @for (account of data.accounts; track account) { - -
- - {{ account.name }} -
-
- } -
-
+ [hasHint]="mode !== 'create'" + [hasNullOption]="true" + [label]="labelAccount" + />
+ {{ label() }} + + +
+ @if (selectedAccount()) { + + } + {{ selectedAccount()?.name }} +
+
+ + @if (hasNullOption()) { + + } + + @for (account of accounts(); track account.id) { + +
+ + {{ account.name }} +
+
+ } +
+ diff --git a/libs/ui/src/lib/account-selector/account-selector.component.stories.ts b/libs/ui/src/lib/account-selector/account-selector.component.stories.ts new file mode 100644 index 000000000..6908ef257 --- /dev/null +++ b/libs/ui/src/lib/account-selector/account-selector.component.stories.ts @@ -0,0 +1,131 @@ +import { AccountWithPlatform } from '@ghostfolio/common/types'; + +import { CommonModule } from '@angular/common'; +import { ANIMATION_MODULE_TYPE, importProvidersFrom } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import '@angular/localize/init'; +import { + applicationConfig, + Meta, + moduleMetadata, + StoryObj +} from '@storybook/angular'; + +import { EntityLogoImageSourceService } from '../entity-logo/entity-logo-image-source.service'; +import { EntityLogoImageSourceServiceMock } from '../mocks/entity-logo-image-source.service.mock'; +import { GfAccountSelectorComponent } from './account-selector.component'; + +const ACCOUNTS: AccountWithPlatform[] = [ + { + comment: null, + createdAt: new Date('2024-01-01'), + currency: 'USD', + id: '3ef7e6d9-4598-4eb2-b0e8-00e61cfc0ea6', + name: 'Coinbase Account', + platform: { + id: '9da3a8a7-4795-43e3-a6db-ccb914189737', + name: 'Coinbase', + url: 'https://coinbase.com' + }, + platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737', + updatedAt: new Date('2024-01-01'), + userId: '081aa387-487d-4438-83a4-3060eb2a016e' + }, + { + comment: null, + createdAt: new Date('2024-01-01'), + currency: 'CHF', + id: '9da3a8a7-4795-43e3-a6db-ccb914189737', + name: 'Ghostfolio Account', + platform: { + id: 'f3e9d0e5-1e0f-4a3c-8b12-1f2a3b4c5d6e', + name: 'Ghostfolio', + url: 'https://ghostfol.io' + }, + platformId: 'f3e9d0e5-1e0f-4a3c-8b12-1f2a3b4c5d6e', + updatedAt: new Date('2024-01-01'), + userId: '081aa387-487d-4438-83a4-3060eb2a016e' + }, + { + comment: null, + createdAt: new Date('2024-01-01'), + currency: 'EUR', + id: 'd191b2d5-9d5a-4c5f-9d55-6f0e5f6a7b8c', + name: 'Savings Account', + platformId: null, + updatedAt: new Date('2024-01-01'), + userId: '081aa387-487d-4438-83a4-3060eb2a016e' + } +]; + +type AccountSelectorStory = GfAccountSelectorComponent & { + value: string | null; +}; + +const meta: Meta = { + title: 'Account Selector', + component: GfAccountSelectorComponent, + decorators: [ + applicationConfig({ + providers: [ + importProvidersFrom(CommonModule), + { + provide: ANIMATION_MODULE_TYPE, + useValue: 'NoopAnimations' + }, + { + provide: EntityLogoImageSourceService, + useValue: new EntityLogoImageSourceServiceMock() + } + ] + }), + moduleMetadata({ + imports: [GfAccountSelectorComponent, ReactiveFormsModule] + }) + ], + render: ({ accounts, hasHint, hasNullOption, label, value }) => { + return { + props: { + accounts, + hasHint, + hasNullOption, + label, + formGroup: new FormGroup({ + account: new FormControl(value) + }) + }, + template: ` +
+ + + ` + }; + } +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + accounts: ACCOUNTS, + label: 'Account', + value: '9da3a8a7-4795-43e3-a6db-ccb914189737' + } +}; + +export const WithNullOption: Story = { + args: { + accounts: ACCOUNTS, + hasNullOption: true, + label: 'Account', + value: null + } +}; diff --git a/libs/ui/src/lib/account-selector/account-selector.component.ts b/libs/ui/src/lib/account-selector/account-selector.component.ts new file mode 100644 index 000000000..82b890cb8 --- /dev/null +++ b/libs/ui/src/lib/account-selector/account-selector.component.ts @@ -0,0 +1,109 @@ +import { AccountWithPlatform } from '@ghostfolio/common/types'; +import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; + +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + signal +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + ControlValueAccessor, + FormControl, + NgControl, + ReactiveFormsModule +} from '@angular/forms'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'd-block' }, + imports: [ + GfEntityLogoComponent, + MatFormFieldModule, + MatSelectModule, + ReactiveFormsModule + ], + selector: 'gf-account-selector', + templateUrl: './account-selector.component.html' +}) +export class GfAccountSelectorComponent implements ControlValueAccessor { + public readonly accounts = input.required(); + public readonly control = new FormControl(null); + public readonly errorStateMatcher: ErrorStateMatcher = { + isErrorState: () => { + const control = this.ngControl?.control; + + return !!(control?.invalid && (control.dirty || control.touched)); + } + }; + public readonly hasHint = input(true); + public readonly hasNullOption = input(false); + public readonly label = input.required(); + public readonly selectedAccount = computed(() => { + const selectedAccountId = this.selectedAccountId(); + + return this.accounts().find(({ id }) => { + return id === selectedAccountId; + }); + }); + + private readonly ngControl = inject(NgControl, { + optional: true, + self: true + }); + private readonly selectedAccountId = signal(null); + + public constructor() { + // Register as the value accessor manually, because injecting NgControl + // and providing NG_VALUE_ACCESSOR at the same time creates a cycle + if (this.ngControl) { + this.ngControl.valueAccessor = this; + } + + this.control.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe((accountId) => { + this.selectedAccountId.set(accountId); + this.onChange(accountId); + }); + } + + public onPanelClosed() { + this.onTouched(); + } + + public registerOnChange(fn: (accountId: string | null) => void) { + this.onChange = fn; + } + + public registerOnTouched(fn: () => void) { + this.onTouched = fn; + } + + public setDisabledState(isDisabled: boolean) { + if (isDisabled) { + this.control.disable({ emitEvent: false }); + } else { + this.control.enable({ emitEvent: false }); + } + } + + public writeValue(accountId: string | null) { + this.control.setValue(accountId ?? null, { emitEvent: false }); + this.selectedAccountId.set(accountId ?? null); + } + + private onChange: (accountId: string | null) => void = () => { + // ControlValueAccessor onChange callback + }; + + private onTouched = (): void => { + // ControlValueAccessor onTouched callback + }; +} diff --git a/libs/ui/src/lib/account-selector/index.ts b/libs/ui/src/lib/account-selector/index.ts new file mode 100644 index 000000000..35518905c --- /dev/null +++ b/libs/ui/src/lib/account-selector/index.ts @@ -0,0 +1 @@ +export * from './account-selector.component'; 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 index 6c8902112..fe43814f4 100644 --- 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 @@ -1,24 +1,12 @@
- - Account - - - @for (account of accounts(); track account.id) { - -
- - {{ account.name }} -
-
- } -
-
+
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 index 9d9002c67..3ebce0585 100644 --- 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 @@ -28,14 +28,15 @@ import { import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; -import { GfEntityLogoComponent } from '../entity-logo/entity-logo.component'; +import { GfAccountSelectorComponent } from '../account-selector/account-selector.component'; +import { translate } from '../i18n'; import { PortfolioFilterFormValue } from './interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, imports: [ FormsModule, - GfEntityLogoComponent, + GfAccountSelectorComponent, MatFormFieldModule, MatSelectModule, ReactiveFormsModule @@ -68,6 +69,8 @@ export class GfPortfolioFilterFormComponent tag: FormControl; }>; + public readonly labelAccount = translate('ACCOUNT'); + private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly destroyRef = inject(DestroyRef); private readonly formBuilder = inject(FormBuilder);