Browse Source

Extract account selector to reusable component

pull/7653/head
Thomas Kaul 15 hours ago
parent
commit
18ebf33300
  1. 5
      apps/client/src/app/pages/accounts/transfer-balance/interfaces/interfaces.ts
  2. 25
      apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts
  3. 74
      apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html
  4. 11
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  5. 45
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  6. 44
      libs/ui/src/lib/account-selector/account-selector.component.html
  7. 131
      libs/ui/src/lib/account-selector/account-selector.component.stories.ts
  8. 109
      libs/ui/src/lib/account-selector/account-selector.component.ts
  9. 1
      libs/ui/src/lib/account-selector/index.ts
  10. 26
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html
  11. 7
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts

5
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 { FormControl, FormGroup } from '@angular/forms';
import { Account } from '@prisma/client';
export interface TransferBalanceDialogParams { export interface TransferBalanceDialogParams {
accounts: Account[]; accounts: AccountWithPlatform[];
} }
export type TransferBalanceForm = FormGroup<{ export type TransferBalanceForm = FormGroup<{

25
apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts

@ -1,5 +1,6 @@
import { TransferBalanceDto } from '@ghostfolio/common/dtos'; 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 { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { import {
@ -17,8 +18,6 @@ import {
} from '@angular/material/dialog'; } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { Account } from '@prisma/client';
import { import {
TransferBalanceDialogParams, TransferBalanceDialogParams,
@ -29,12 +28,11 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' }, host: { class: 'h-100' },
imports: [ imports: [
GfEntityLogoComponent, GfAccountSelectorComponent,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
MatSelectModule,
ReactiveFormsModule ReactiveFormsModule
], ],
selector: 'gf-transfer-balance-dialog', selector: 'gf-transfer-balance-dialog',
@ -42,11 +40,14 @@ import {
templateUrl: 'transfer-balance-dialog.html' templateUrl: 'transfer-balance-dialog.html'
}) })
export class GfTransferBalanceDialogComponent { export class GfTransferBalanceDialogComponent {
protected readonly accounts: Account[] = protected readonly accounts: AccountWithPlatform[] =
inject<TransferBalanceDialogParams>(MAT_DIALOG_DATA).accounts; inject<TransferBalanceDialogParams>(MAT_DIALOG_DATA).accounts;
protected currency: string; protected currency: string;
protected readonly labelFrom = $localize`From`;
protected readonly labelTo = $localize`To`;
protected readonly transferBalanceForm: TransferBalanceForm = new FormGroup( protected readonly transferBalanceForm: TransferBalanceForm = new FormGroup(
{ {
balance: new FormControl<number | string | null>('', Validators.required), balance: new FormControl<number | string | null>('', Validators.required),
@ -61,18 +62,6 @@ export class GfTransferBalanceDialogComponent {
private readonly dialogRef = private readonly dialogRef =
inject<MatDialogRef<GfTransferBalanceDialogComponent>>(MatDialogRef); inject<MatDialogRef<GfTransferBalanceDialogComponent>>(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() { public ngOnInit() {
this.transferBalanceForm.controls.fromAccount.valueChanges.subscribe( this.transferBalanceForm.controls.fromAccount.valueChanges.subscribe(
(id) => { (id) => {

74
apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html

@ -7,72 +7,18 @@
<h1 i18n mat-dialog-title>Transfer Cash Balance</h1> <h1 i18n mat-dialog-title>Transfer Cash Balance</h1>
<div class="flex-grow-1 py-3" mat-dialog-content> <div class="flex-grow-1 py-3" mat-dialog-content>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <gf-account-selector
<mat-label i18n>From</mat-label> formControlName="fromAccount"
<mat-select formControlName="fromAccount"> [accounts]="accounts"
<mat-select-trigger> [label]="labelFrom"
<div class="d-flex"> />
@if (selectedFromAccount) {
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="selectedFromAccount?.platform?.name"
[url]="selectedFromAccount?.platform?.url"
/>
}
<span>{{ selectedFromAccount?.name }}</span>
</div>
</mat-select-trigger>
@for (account of accounts; track account) {
<mat-option [value]="account.id">
<div class="d-flex">
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="account.platform?.name"
[url]="account.platform?.url"
/>
<span>{{ account.name }}</span>
</div>
</mat-option>
}
</mat-select>
</mat-form-field>
</div> </div>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <gf-account-selector
<mat-label i18n>To</mat-label> formControlName="toAccount"
<mat-select formControlName="toAccount"> [accounts]="accounts"
<mat-select-trigger> [label]="labelTo"
<div class="d-flex"> />
@if (selectedToAccount) {
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="selectedToAccount?.platform?.name"
[url]="selectedToAccount?.platform?.url"
/>
}
<span>{{ selectedToAccount?.name }}</span>
</div>
</mat-select-trigger>
@for (account of accounts; track account) {
<mat-option [value]="account.id">
<div class="d-flex">
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="account.platform?.name"
[url]="account.platform?.url"
/>
<span>{{ account.name }}</span>
</div>
</mat-option>
}
</mat-select>
</mat-form-field>
</div> </div>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">

11
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'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { validateObjectForForm } from '@ghostfolio/common/utils'; 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 { translate } from '@ghostfolio/ui/i18n';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete'; import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete';
@ -59,7 +59,7 @@ import { ActivityType } from './types/activity-type.type';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' }, host: { class: 'h-100' },
imports: [ imports: [
GfEntityLogoComponent, GfAccountSelectorComponent,
GfSymbolAutocompleteComponent, GfSymbolAutocompleteComponent,
GfTagsSelectorComponent, GfTagsSelectorComponent,
GfValueComponent, GfValueComponent,
@ -98,6 +98,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
protected hasPermissionToCreateOwnTag: boolean; protected hasPermissionToCreateOwnTag: boolean;
protected isLoading = false; protected isLoading = false;
protected readonly isToday = isToday; protected readonly isToday = isToday;
protected readonly labelAccount = $localize`Account`;
protected mode: 'create' | 'update'; protected mode: 'create' | 'update';
protected tagsAvailable: Tag[] = []; protected tagsAvailable: Tag[] = [];
protected total = 0; protected total = 0;
@ -121,12 +122,6 @@ export class GfCreateOrUpdateActivityDialogComponent {
addIcons({ calendarClearOutline, refreshOutline }); addIcons({ calendarClearOutline, refreshOutline });
} }
protected get selectedAccount() {
return this.data.accounts.find(({ id }) => {
return id === this.activityForm.get('accountId')?.value;
});
}
public ngOnInit() { public ngOnInit() {
this.currencyOfAssetProfile = this.data.activity?.assetProfile?.currency; this.currencyOfAssetProfile = this.data.activity?.assetProfile?.currency;
this.hasPermissionToCreateOwnTag = hasPermission( this.hasPermissionToCreateOwnTag = hasPermission(

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

@ -77,45 +77,14 @@
</mat-form-field> </mat-form-field>
</div> </div>
<div [class.mb-3]="mode === 'update'"> <div [class.mb-3]="mode === 'update'">
<mat-form-field <gf-account-selector
appearance="outline" formControlName="accountId"
class="w-100" [accounts]="data.accounts"
[class.mb-1]="mode === 'create'" [class.mb-1]="mode === 'create'"
[class.without-hint]="mode === 'create'" [hasHint]="mode !== 'create'"
> [hasNullOption]="true"
<mat-label i18n>Account</mat-label> [label]="labelAccount"
<mat-select formControlName="accountId"> />
<mat-select-trigger>
<div class="d-flex">
@if (selectedAccount) {
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="selectedAccount?.platform?.name"
[url]="selectedAccount?.platform?.url"
/>
}
<span>{{ selectedAccount?.name }}</span>
</div>
</mat-select-trigger>
<mat-option [value]="null" />
@for (account of data.accounts; track account) {
<mat-option [value]="account.id">
<div class="d-flex">
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="account.platform?.name"
[url]="account.platform?.url"
/>
<span>{{ account.name }}</span>
</div>
</mat-option>
}
</mat-select>
</mat-form-field>
</div> </div>
<div class="mb-3" [class.d-none]="mode === 'update'"> <div class="mb-3" [class.d-none]="mode === 'update'">
<mat-checkbox color="primary" formControlName="updateAccountBalance" i18n <mat-checkbox color="primary" formControlName="updateAccountBalance" i18n

44
libs/ui/src/lib/account-selector/account-selector.component.html

@ -0,0 +1,44 @@
<mat-form-field
appearance="outline"
class="w-100"
[class.without-hint]="!hasHint()"
>
<mat-label>{{ label() }}</mat-label>
<mat-select
[errorStateMatcher]="errorStateMatcher"
[formControl]="control"
(closed)="onPanelClosed()"
>
<mat-select-trigger>
<div class="d-flex">
@if (selectedAccount()) {
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="selectedAccount()?.platform?.name ?? ''"
[url]="selectedAccount()?.platform?.url ?? ''"
/>
}
<span>{{ selectedAccount()?.name }}</span>
</div>
</mat-select-trigger>
@if (hasNullOption()) {
<mat-option [value]="null" />
}
@for (account of accounts(); track account.id) {
<mat-option [value]="account.id">
<div class="d-flex">
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="account.platform?.name ?? ''"
[url]="account.platform?.url ?? ''"
/>
<span>{{ account.name }}</span>
</div>
</mat-option>
}
</mat-select>
</mat-form-field>

131
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<AccountSelectorStory> = {
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: `
<form [formGroup]="formGroup">
<gf-account-selector
formControlName="account"
[accounts]="accounts"
[hasHint]="hasHint"
[hasNullOption]="hasNullOption"
[label]="label"
/>
</form>
`
};
}
};
export default meta;
type Story = StoryObj<AccountSelectorStory>;
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
}
};

109
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<AccountWithPlatform[]>();
public readonly control = new FormControl<string | null>(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<string>();
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<string | null>(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
};
}

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

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

26
libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html

@ -1,24 +1,12 @@
<form [formGroup]="filterForm"> <form [formGroup]="filterForm">
<div class="mb-3"> <div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint"> <gf-account-selector
<mat-label i18n>Account</mat-label> formControlName="account"
<mat-select formControlName="account"> [accounts]="accounts()"
<mat-option [value]="null" /> [hasHint]="false"
@for (account of accounts(); track account.id) { [hasNullOption]="true"
<mat-option [value]="account.id"> [label]="labelAccount"
<div class="d-flex"> />
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="account.platform?.name ?? ''"
[url]="account.platform?.url ?? ''"
/>
<span>{{ account.name }}</span>
</div>
</mat-option>
}
</mat-select>
</mat-form-field>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">

7
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 { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select'; 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'; import { PortfolioFilterFormValue } from './interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
FormsModule, FormsModule,
GfEntityLogoComponent, GfAccountSelectorComponent,
MatFormFieldModule, MatFormFieldModule,
MatSelectModule, MatSelectModule,
ReactiveFormsModule ReactiveFormsModule
@ -68,6 +69,8 @@ export class GfPortfolioFilterFormComponent
tag: FormControl<string | null>; tag: FormControl<string | null>;
}>; }>;
public readonly labelAccount = translate('ACCOUNT');
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);

Loading…
Cancel
Save