mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
- 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 #5605pull/5618/head
11 changed files with 650 additions and 176 deletions
@ -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 |
|||
``` |
|||
@ -0,0 +1,2 @@ |
|||
export * from './interfaces'; |
|||
export * from './portfolio-filter-form.component'; |
|||
@ -0,0 +1 @@ |
|||
export * from './portfolio-filter-form-value.interface'; |
|||
@ -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; |
|||
} |
|||
@ -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> |
|||
@ -0,0 +1,5 @@ |
|||
.gf-portfolio-filter-form { |
|||
.gf-spacer { |
|||
flex: 1 1 auto; |
|||
} |
|||
} |
|||
@ -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(); |
|||
}); |
|||
}); |
|||
@ -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 |
|||
} |
|||
}; |
|||
@ -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…
Reference in new issue