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