Browse Source

Extract portfolio filters sub form of assistant to reusable component

- Create GfPortfolioFilterFormComponent with ControlValueAccessor
- Implement portfolio filter form with account, assetClass, holding, tag filters
- Add proper TypeScript typing using AccountWithValue
- Update assistant component to use new reusable filter component
- Add comprehensive unit tests

Fixes #5605
pull/5618/head
Germán Martín 3 months ago
parent
commit
1028d57ce7
  1. 124
      libs/ui/src/lib/assistant/assistant.component.ts
  2. 142
      libs/ui/src/lib/assistant/assistant.html
  3. 130
      libs/ui/src/lib/portfolio-filter-form/README.md
  4. 2
      libs/ui/src/lib/portfolio-filter-form/index.ts
  5. 1
      libs/ui/src/lib/portfolio-filter-form/interfaces/index.ts
  6. 8
      libs/ui/src/lib/portfolio-filter-form/interfaces/portfolio-filter-form-value.interface.ts
  7. 95
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html
  8. 5
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.scss
  9. 69
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.spec.ts
  10. 70
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.stories.ts
  11. 180
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts

124
libs/ui/src/lib/assistant/assistant.component.ts

@ -5,7 +5,7 @@ import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Filter, PortfolioPosition, User } from '@ghostfolio/common/interfaces'; import { Filter, PortfolioPosition, User } from '@ghostfolio/common/interfaces';
import { InternalRoute } from '@ghostfolio/common/routes/interfaces/internal-route.interface'; import { InternalRoute } from '@ghostfolio/common/routes/interfaces/internal-route.interface';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { DateRange } from '@ghostfolio/common/types'; import { AccountWithValue, DateRange } from '@ghostfolio/common/types';
import { FocusKeyManager } from '@angular/cdk/a11y'; import { FocusKeyManager } from '@angular/cdk/a11y';
import { import {
@ -25,19 +25,14 @@ import {
ViewChild, ViewChild,
ViewChildren ViewChildren
} from '@angular/core'; } from '@angular/core';
import { import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
FormBuilder,
FormControl,
FormsModule,
ReactiveFormsModule
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatMenuTrigger } from '@angular/material/menu'; import { MatMenuTrigger } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone'; 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 { differenceInYears } from 'date-fns';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
@ -60,8 +55,11 @@ import {
tap tap
} from 'rxjs/operators'; } from 'rxjs/operators';
import { GfEntityLogoComponent } from '../entity-logo/entity-logo.component';
import { translate } from '../i18n'; import { translate } from '../i18n';
import {
GfPortfolioFilterFormComponent,
PortfolioFilterFormValue
} from '../portfolio-filter-form';
import { GfAssistantListItemComponent } from './assistant-list-item/assistant-list-item.component'; import { GfAssistantListItemComponent } from './assistant-list-item/assistant-list-item.component';
import { SearchMode } from './enums/search-mode'; import { SearchMode } from './enums/search-mode';
import { import {
@ -75,7 +73,7 @@ import {
imports: [ imports: [
FormsModule, FormsModule,
GfAssistantListItemComponent, GfAssistantListItemComponent,
GfEntityLogoComponent, GfPortfolioFilterFormComponent,
GfSymbolModule, GfSymbolModule,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
@ -128,6 +126,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
@Input() hasPermissionToChangeDateRange: boolean; @Input() hasPermissionToChangeDateRange: boolean;
@Input() hasPermissionToChangeFilters: boolean; @Input() hasPermissionToChangeFilters: boolean;
@Input() user: User; @Input() user: User;
@Input() accountsWithValue: AccountWithValue[] = [];
@Output() closed = new EventEmitter<void>(); @Output() closed = new EventEmitter<void>();
@Output() dateRangeChanged = new EventEmitter<DateRange>(); @Output() dateRangeChanged = new EventEmitter<DateRange>();
@ -141,16 +140,18 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
public static readonly SEARCH_RESULTS_DEFAULT_LIMIT = 5; public static readonly SEARCH_RESULTS_DEFAULT_LIMIT = 5;
public accounts: Account[] = []; public accounts: AccountWithValue[] = [];
public assetClasses: Filter[] = []; public assetClasses: Filter[] = [];
public dateRangeFormControl = new FormControl<string>(undefined); public dateRangeFormControl = new FormControl<string>(undefined);
public dateRangeOptions: IDateRangeOption[] = []; public dateRangeOptions: IDateRangeOption[] = [];
public filterForm = this.formBuilder.group({ public portfolioFilterFormControl = new FormControl<PortfolioFilterFormValue>(
account: new FormControl<string>(undefined), {
assetClass: new FormControl<string>(undefined), account: null,
holding: new FormControl<PortfolioPosition>(undefined), assetClass: null,
tag: new FormControl<string>(undefined) holding: null,
}); tag: null
}
);
public holdings: PortfolioPosition[] = []; public holdings: PortfolioPosition[] = [];
public isLoading = { public isLoading = {
accounts: false, accounts: false,
@ -182,8 +183,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService
private formBuilder: FormBuilder
) { ) {
addIcons({ closeCircleOutline, closeOutline, searchOutline }); addIcons({ closeCircleOutline, closeOutline, searchOutline });
} }
@ -369,7 +369,29 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
} }
public ngOnChanges() { public ngOnChanges() {
this.accounts = this.user?.accounts ?? []; // Use accountsWithValue if provided, otherwise transform user.accounts as fallback
if (this.accountsWithValue?.length > 0) {
this.accounts = this.accountsWithValue;
} else {
// Transform basic accounts to AccountWithValue format for compatibility
this.accounts = (this.user?.accounts ?? []).map((account) => ({
...account,
allocationInPercentage: 0,
balanceInBaseCurrency: account.balance || 0,
dividendInBaseCurrency: 0,
interestInBaseCurrency: 0,
platform: account.platformId
? {
id: account.platformId,
name: account.platformId, // Fallback, ideally should be resolved
url: ''
}
: undefined,
transactionCount: 0,
value: account.balance || 0,
valueInBaseCurrency: account.balance || 0
})) as AccountWithValue[];
}
this.dateRangeOptions = [ this.dateRangeOptions = [
{ {
@ -438,8 +460,6 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
this.dateRangeFormControl.setValue(this.user?.settings?.dateRange ?? null); this.dateRangeFormControl.setValue(this.user?.settings?.dateRange ?? null);
this.filterForm.disable({ emitEvent: false });
this.tags = this.tags =
this.user?.tags this.user?.tags
?.filter(({ isUsed }) => { ?.filter(({ isUsed }) => {
@ -452,10 +472,6 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
type: 'TAG' type: 'TAG'
}; };
}) ?? []; }) ?? [];
if (this.tags.length === 0) {
this.filterForm.get('tag').disable({ emitEvent: false });
}
} }
public hasFilter(aFormValue: { [key: string]: string }) { public hasFilter(aFormValue: { [key: string]: string }) {
@ -464,19 +480,6 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
}); });
} }
public holdingComparisonFunction(
option: PortfolioPosition,
value: PortfolioPosition
): boolean {
if (value === null) {
return false;
}
return (
getAssetProfileIdentifier(option) === getAssetProfileIdentifier(value)
);
}
public initialize() { public initialize() {
this.isLoading = { this.isLoading = {
accounts: true, accounts: true,
@ -520,36 +523,33 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
.sort((a, b) => { .sort((a, b) => {
return a.name?.localeCompare(b.name); return a.name?.localeCompare(b.name);
}); });
this.setFilterFormValues(); this.setPortfolioFilterFormValues();
if (this.hasPermissionToChangeFilters) {
this.filterForm.enable({ emitEvent: false });
}
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
public onApplyFilters() { public onApplyFilters() {
const filterValue = this.portfolioFilterFormControl.value;
this.filtersChanged.emit([ this.filtersChanged.emit([
{ {
id: this.filterForm.get('account').value, id: filterValue?.account,
type: 'ACCOUNT' type: 'ACCOUNT'
}, },
{ {
id: this.filterForm.get('assetClass').value, id: filterValue?.assetClass,
type: 'ASSET_CLASS' type: 'ASSET_CLASS'
}, },
{ {
id: this.filterForm.get('holding').value?.dataSource, id: filterValue?.holding?.dataSource,
type: 'DATA_SOURCE' type: 'DATA_SOURCE'
}, },
{ {
id: this.filterForm.get('holding').value?.symbol, id: filterValue?.holding?.symbol,
type: 'SYMBOL' type: 'SYMBOL'
}, },
{ {
id: this.filterForm.get('tag').value, id: filterValue?.tag,
type: 'TAG' type: 'TAG'
} }
]); ]);
@ -568,6 +568,13 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
} }
public onResetFilters() { public onResetFilters() {
this.portfolioFilterFormControl.setValue({
account: null,
assetClass: null,
holding: null,
tag: null
});
this.filtersChanged.emit( this.filtersChanged.emit(
this.filterTypes.map((type) => { this.filterTypes.map((type) => {
return { return {
@ -723,7 +730,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
}); });
} }
private setFilterFormValues() { private setPortfolioFilterFormValues() {
const dataSource = this.user?.settings?.[ const dataSource = this.user?.settings?.[
'filters.dataSource' 'filters.dataSource'
] as DataSource; ] as DataSource;
@ -737,16 +744,11 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
); );
}); });
this.filterForm.setValue( this.portfolioFilterFormControl.setValue({
{ account: this.user?.settings?.['filters.accounts']?.[0] ?? null,
account: this.user?.settings?.['filters.accounts']?.[0] ?? null, assetClass: this.user?.settings?.['filters.assetClasses']?.[0] ?? null,
assetClass: this.user?.settings?.['filters.assetClasses']?.[0] ?? null, holding: selectedHolding ?? null,
holding: selectedHolding ?? null, tag: this.user?.settings?.['filters.tags']?.[0] ?? null
tag: this.user?.settings?.['filters.tags']?.[0] ?? null });
},
{
emitEvent: false
}
);
} }
} }

142
libs/ui/src/lib/assistant/assistant.html

@ -164,119 +164,31 @@
</div> </div>
} }
</div> </div>
<form [formGroup]="filterForm"> @if (!searchFormControl.value) {
@if (!searchFormControl.value) { <div class="date-range-selector-container p-3">
<div class="date-range-selector-container p-3"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-label i18n>Date Range</mat-label>
<mat-label i18n>Date Range</mat-label> <mat-select
<mat-select [formControl]="dateRangeFormControl"
[formControl]="dateRangeFormControl" (selectionChange)="onChangeDateRange($event.value)"
(selectionChange)="onChangeDateRange($event.value)" >
> @for (range of dateRangeOptions; track range) {
@for (range of dateRangeOptions; track range) { <mat-option [value]="range.value">{{ range.label }}</mat-option>
<mat-option [value]="range.value">{{ range.label }}</mat-option> }
} </mat-select>
</mat-select> </mat-form-field>
</mat-form-field> </div>
</div> <div class="p-3">
<div class="p-3"> <gf-portfolio-filter-form
<div class="mb-3"> [accounts]="accounts"
<mat-form-field appearance="outline" class="w-100 without-hint"> [assetClasses]="assetClasses"
<mat-label i18n>Account</mat-label> [disabled]="!hasPermissionToChangeFilters"
<mat-select formControlName="account"> [formControl]="portfolioFilterFormControl"
<mat-option [value]="null" /> [holdings]="holdings"
@for (account of accounts; track account.id) { [tags]="tags"
<mat-option [value]="account.id"> (applyFilters)="onApplyFilters()"
<div class="d-flex"> (resetFilters)="onResetFilters()"
@if (account.platform?.url) { />
<gf-entity-logo </div>
class="mr-1" }
[tooltip]="account.platform?.name"
[url]="account.platform?.url"
/>
}
<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">
<mat-label i18n>Holding</mat-label>
<mat-select
formControlName="holding"
[compareWith]="holdingComparisonFunction"
>
<mat-select-trigger>{{
filterForm.get('holding')?.value?.name
}}</mat-select-trigger>
<mat-option [value]="null" />
@for (holding of holdings; track holding.name) {
<mat-option [value]="holding">
<div class="line-height-1 text-truncate">
<span
><b>{{ holding.name }}</b></span
>
<br />
<small class="text-muted"
>{{ holding.symbol | gfSymbol }} ·
{{ holding.currency }}</small
>
</div>
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Tag</mat-label>
<mat-select formControlName="tag">
<mat-option [value]="null" />
@for (tag of tags; track tag.id) {
<mat-option [value]="tag.id">{{ tag.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass">
<mat-option [value]="null" />
@for (assetClass of assetClasses; track assetClass.id) {
<mat-option [value]="assetClass.id">{{
assetClass.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="d-flex w-100">
<button
i18n
mat-button
[disabled]="
!hasFilter(filterForm.value) || !hasPermissionToChangeFilters
"
(click)="onResetFilters()"
>
Reset Filters
</button>
<span class="gf-spacer"></span>
<button
color="primary"
i18n
mat-flat-button
[disabled]="!filterForm.dirty || !hasPermissionToChangeFilters"
(click)="onApplyFilters()"
>
Apply Filters
</button>
</div>
</div>
}
</form>
</div> </div>

130
libs/ui/src/lib/portfolio-filter-form/README.md

@ -0,0 +1,130 @@
# Portfolio Filter Form Component
## Overview
The `GfPortfolioFilterFormComponent` is a reusable Angular component that provides a form interface for filtering portfolio data. It implements `ControlValueAccessor` to work seamlessly with Angular reactive forms.
## Features
- **Account filtering**: Select specific accounts to filter by
- **Asset class filtering**: Filter by asset classes (Equity, Fixed Income, etc.)
- **Holding filtering**: Filter by specific holdings/securities
- **Tag filtering**: Filter by user-defined tags
- **Form validation**: Built-in validation and state management
- **Accessibility**: Full support for Angular forms and accessibility features
## Usage
### Basic Implementation
```typescript
import { GfPortfolioFilterFormComponent } from '@ghostfolio/ui/portfolio-filter-form';
@Component({
selector: 'my-component',
template: `
<gf-portfolio-filter-form
[accounts]="accounts"
[assetClasses]="assetClasses"
[holdings]="holdings"
[tags]="tags"
[disabled]="isDisabled"
(applyFilters)="onApplyFilters()"
(resetFilters)="onResetFilters()"
[formControl]="portfolioFiltersControl">
</gf-portfolio-filter-form>
`
})
export class MyComponent {
portfolioFiltersControl = new FormControl<PortfolioFilterFormValue>({
account: null,
assetClass: null,
holding: null,
tag: null
});
// ... other properties
}
```
### With Reactive Forms
```typescript
import { PortfolioFilterFormValue } from '@ghostfolio/ui/portfolio-filter-form';
import { FormControl } from '@angular/forms';
const filterControl = new FormControl<PortfolioFilterFormValue>({
account: null,
assetClass: null,
holding: null,
tag: null
});
// Subscribe to changes
filterControl.valueChanges.subscribe((filters) => {
console.log('Filter changes:', filters);
});
```
## Inputs
| Input | Type | Description |
| -------------- | --------------------- | ----------------------------------- |
| `accounts` | `Account[]` | Array of available accounts |
| `assetClasses` | `Filter[]` | Array of available asset classes |
| `holdings` | `PortfolioPosition[]` | Array of available holdings |
| `tags` | `Filter[]` | Array of available tags |
| `disabled` | `boolean` | Whether the form should be disabled |
## Outputs
| Output | Type | Description |
| -------------- | -------------------- | -------------------------------------------- |
| `applyFilters` | `EventEmitter<void>` | Emitted when Apply Filters button is clicked |
| `resetFilters` | `EventEmitter<void>` | Emitted when Reset Filters button is clicked |
## Interface
### PortfolioFilterFormValue
```typescript
interface PortfolioFilterFormValue {
account: string | null;
assetClass: string | null;
holding: PortfolioPosition | null;
tag: string | null;
}
```
## Implementation Details
- Implements `ControlValueAccessor` for seamless integration with Angular forms
- Uses Angular Material components for consistent UI
- Handles form state management internally
- Provides validation and dirty state tracking
- Supports disabled state management
## Testing
The component includes comprehensive unit tests covering:
- Component creation and initialization
- Form value management
- Event emission
- Filter detection logic
- ControlValueAccessor implementation
Run tests with:
```bash
nx test ui
```
## Storybook
Interactive component documentation and examples are available in Storybook:
```bash
nx run ui:storybook
```

2
libs/ui/src/lib/portfolio-filter-form/index.ts

@ -0,0 +1,2 @@
export * from './interfaces';
export * from './portfolio-filter-form.component';

1
libs/ui/src/lib/portfolio-filter-form/interfaces/index.ts

@ -0,0 +1 @@
export * from './portfolio-filter-form-value.interface';

8
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 | null;
assetClass: string | null;
holding: PortfolioPosition | null;
tag: string | null;
}

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

@ -0,0 +1,95 @@
<form class="gf-portfolio-filter-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">
@if (account.platform?.url) {
<gf-entity-logo
class="mr-1"
[tooltip]="account.platform?.name"
[url]="account.platform?.url"
/>
}
<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">
<mat-label i18n>Holding</mat-label>
<mat-select
formControlName="holding"
[compareWith]="holdingComparisonFunction"
>
<mat-select-trigger>{{
filterForm.get('holding')?.value?.name
}}</mat-select-trigger>
<mat-option [value]="null" />
@for (holding of holdings; track holding.name) {
<mat-option [value]="holding">
<div class="line-height-1 text-truncate">
<span
><b>{{ holding.name }}</b></span
>
<br />
<small class="text-muted"
>{{ holding.symbol | gfSymbol }} · {{ holding.currency }}</small
>
</div>
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Tag</mat-label>
<mat-select formControlName="tag">
<mat-option [value]="null" />
@for (tag of tags; track tag.id) {
<mat-option [value]="tag.id">{{ tag.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass">
<mat-option [value]="null" />
@for (assetClass of assetClasses; track assetClass.id) {
<mat-option [value]="assetClass.id">{{
assetClass.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="d-flex w-100">
<button
i18n
mat-button
[disabled]="!hasFilters() || disabled"
(click)="onResetFilters()"
>
Reset Filters
</button>
<span class="gf-spacer"></span>
<button
color="primary"
i18n
mat-flat-button
[disabled]="!filterForm.dirty || disabled"
(click)="onApplyFilters()"
>
Apply Filters
</button>
</div>
</form>

5
libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.scss

@ -0,0 +1,5 @@
.gf-portfolio-filter-form {
.gf-spacer {
flex: 1 1 auto;
}
}

69
libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.spec.ts

@ -0,0 +1,69 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { GfPortfolioFilterFormComponent } from './portfolio-filter-form.component';
// Mock $localize for testing
(global as any).$localize = (template: any) => {
return template.raw ? template.raw.join('') : template;
};
describe('GfPortfolioFilterFormComponent', () => {
let component: GfPortfolioFilterFormComponent;
let fixture: ComponentFixture<GfPortfolioFilterFormComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
GfPortfolioFilterFormComponent,
MatButtonModule,
MatFormFieldModule,
MatSelectModule,
NoopAnimationsModule,
ReactiveFormsModule
]
}).compileComponents();
fixture = TestBed.createComponent(GfPortfolioFilterFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should initialize with empty form values', () => {
expect(component.filterForm.value).toEqual({
account: null,
assetClass: null,
holding: null,
tag: null
});
});
it('should detect when filters are applied', () => {
component.filterForm.patchValue({ account: 'test-account-id' });
expect(component.hasFilters()).toBeTruthy();
});
it('should detect when no filters are applied', () => {
expect(component.hasFilters()).toBeFalsy();
});
it('should emit resetFilters event when onResetFilters is called', () => {
jest.spyOn(component.resetFilters, 'emit');
component.onResetFilters();
expect(component.resetFilters.emit).toHaveBeenCalled();
});
it('should emit applyFilters event when onApplyFilters is called', () => {
jest.spyOn(component.applyFilters, 'emit');
component.onApplyFilters();
expect(component.applyFilters.emit).toHaveBeenCalled();
});
});

70
libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.stories.ts

@ -0,0 +1,70 @@
import { Meta, moduleMetadata, StoryObj } from '@storybook/angular';
import { GfPortfolioFilterFormComponent } from './portfolio-filter-form.component';
const meta: Meta<GfPortfolioFilterFormComponent> = {
title: 'Portfolio Filter Form',
component: GfPortfolioFilterFormComponent,
decorators: [
moduleMetadata({
imports: [GfPortfolioFilterFormComponent]
})
]
};
export default meta;
type Story = StoryObj<GfPortfolioFilterFormComponent>;
export const Default: Story = {
args: {
accounts: [
{
id: '1',
name: 'Trading Account',
platform: {
name: 'Interactive Brokers',
url: 'https://interactivebrokers.com'
}
},
{
id: '2',
name: 'Investment Account',
platform: {
name: 'Fidelity',
url: 'https://fidelity.com'
}
}
] as any,
assetClasses: [
{ id: 'EQUITY', label: 'Equity', type: 'ASSET_CLASS' },
{ id: 'FIXED_INCOME', label: 'Fixed Income', type: 'ASSET_CLASS' },
{ id: 'COMMODITY', label: 'Commodity', type: 'ASSET_CLASS' }
] as any,
holdings: [
{
name: 'Apple Inc.',
symbol: 'AAPL',
currency: 'USD',
dataSource: 'YAHOO'
},
{
name: 'Microsoft Corporation',
symbol: 'MSFT',
currency: 'USD',
dataSource: 'YAHOO'
}
] as any,
tags: [
{ id: 'tech', label: 'Technology', type: 'TAG' },
{ id: 'dividend', label: 'Dividend', type: 'TAG' }
] as any,
disabled: false
}
};
export const Disabled: Story = {
args: {
...Default.args,
disabled: true
}
};

180
libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts

@ -0,0 +1,180 @@
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces';
import { AccountWithValue } from '@ghostfolio/common/types';
import {
CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
forwardRef
} from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
FormControl,
FormsModule,
NG_VALUE_ACCESSOR,
ReactiveFormsModule
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
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,
GfSymbolModule,
MatButtonModule,
MatFormFieldModule,
MatSelectModule,
ReactiveFormsModule
],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => GfPortfolioFilterFormComponent),
multi: true
}
],
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: AccountWithValue[] = [];
@Input() assetClasses: Filter[] = [];
@Input() holdings: PortfolioPosition[] = [];
@Input() tags: Filter[] = [];
@Input() disabled = false;
@Output() applyFilters = new EventEmitter<void>();
@Output() resetFilters = new EventEmitter<void>();
public filterForm = this.formBuilder.group({
account: new FormControl<string>(null),
assetClass: new FormControl<string>(null),
holding: new FormControl<PortfolioPosition>(null),
tag: new FormControl<string>(null)
});
private onChange: (value: PortfolioFilterFormValue) => void = () => {
// ControlValueAccessor callback - implemented by parent
};
private onTouched: () => void = () => {
// ControlValueAccessor callback - implemented by parent
};
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private formBuilder: FormBuilder
) {}
public ngOnInit() {
// Subscribe to form changes to notify parent component
this.filterForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((value) => {
this.onChange(value as PortfolioFilterFormValue);
this.onTouched();
});
}
public ngOnChanges() {
// Update form disabled state
if (this.disabled) {
this.filterForm.disable({ emitEvent: false });
} else {
this.filterForm.enable({ emitEvent: false });
}
// Disable tag field if no tags available
if (this.tags.length === 0) {
this.filterForm.get('tag')?.disable({ emitEvent: false });
}
this.changeDetectorRef.markForCheck();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
// ControlValueAccessor implementation
public writeValue(value: PortfolioFilterFormValue | null): void {
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 registerOnChange(fn: (value: PortfolioFilterFormValue) => void): void {
this.onChange = fn;
}
public registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
public setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
this.ngOnChanges();
}
// Helper methods
public hasFilters(): boolean {
const formValue = this.filterForm.value;
return Object.values(formValue).some((value) => !!value);
}
public holdingComparisonFunction(
option: PortfolioPosition,
value: PortfolioPosition
): boolean {
if (value === null) {
return false;
}
return (
getAssetProfileIdentifier(option) === getAssetProfileIdentifier(value)
);
}
public onApplyFilters(): void {
this.filterForm.markAsPristine();
this.onChange(this.filterForm.value as PortfolioFilterFormValue);
this.applyFilters.emit();
}
public onResetFilters(): void {
this.filterForm.reset({}, { emitEvent: true });
this.resetFilters.emit();
}
}
Loading…
Cancel
Save