Browse Source

Merge f63c6ecb10 into 0a07db8261

pull/7653/merge
Thomas Kaul 10 hours ago
committed by GitHub
parent
commit
0d8546e4ee
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 5
      apps/client/src/app/pages/accounts/transfer-balance/interfaces/interfaces.ts
  3. 25
      apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts
  4. 70
      apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html
  5. 11
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  6. 43
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  7. 44
      libs/ui/src/lib/account-selector/account-selector.component.html
  8. 131
      libs/ui/src/lib/account-selector/account-selector.component.stories.ts
  9. 109
      libs/ui/src/lib/account-selector/account-selector.component.ts
  10. 1
      libs/ui/src/lib/account-selector/index.ts
  11. 24
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html
  12. 7
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts

1
CHANGELOG.md

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Moved the improved symbol lookup results by removing the currency from the name of cryptocurrencies from experimental to general availability
- Extracted the account selector to a reusable component
- Upgraded `ng-extract-i18n-merge` from `3.3.0` to `3.4.0`
### Fixed

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 { Account } from '@prisma/client';
export interface TransferBalanceDialogParams {
accounts: Account[];
accounts: AccountWithPlatform[];
}
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 { 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<TransferBalanceDialogParams>(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<number | string | null>('', Validators.required),
@ -61,18 +62,6 @@ export class GfTransferBalanceDialogComponent {
private readonly dialogRef =
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() {
this.transferBalanceForm.controls.fromAccount.valueChanges.subscribe(
(id) => {

70
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>
<div class="flex-grow-1 py-3" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>From</mat-label>
<mat-select formControlName="fromAccount">
<mat-select-trigger>
<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"
<gf-account-selector
formControlName="fromAccount"
[accounts]="accounts"
[label]="labelFrom"
/>
<span>{{ account.name }}</span>
</div>
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>To</mat-label>
<mat-select formControlName="toAccount">
<mat-select-trigger>
<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"
<gf-account-selector
formControlName="toAccount"
[accounts]="accounts"
[label]="labelTo"
/>
<span>{{ account.name }}</span>
</div>
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div>
<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';
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(

43
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>
</div>
<div [class.mb-3]="mode === 'update'">
<mat-form-field
appearance="outline"
class="w-100"
<gf-account-selector
formControlName="accountId"
[accounts]="data.accounts"
[class.mb-1]="mode === 'create'"
[class.without-hint]="mode === 'create'"
>
<mat-label i18n>Account</mat-label>
<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"
[hasHint]="mode !== 'create'"
[hasNullOption]="true"
[label]="labelAccount"
/>
<span>{{ account.name }}</span>
</div>
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mb-3" [class.d-none]="mode === 'update'">
<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';

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

@ -1,24 +1,12 @@
<form [formGroup]="filterForm">
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Account</mat-label>
<mat-select formControlName="account">
<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 ?? ''"
<gf-account-selector
formControlName="account"
[accounts]="accounts()"
[hasHint]="false"
[hasNullOption]="true"
[label]="labelAccount"
/>
<span>{{ account.name }}</span>
</div>
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mb-3">
<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 { 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<string | null>;
}>;
public readonly labelAccount = translate('ACCOUNT');
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);

Loading…
Cancel
Save