Compare commits

...

2 Commits

Author SHA1 Message Date
Abhishek Garg 48f1c064bc
Bugfix/handle X-ray rule exception when market price is missing (#6397) 4 days ago
Kenrick Tandrian 386a77c8f7
Task/improve type safety in assistant components (#6396) 4 days ago
  1. 6
      CHANGELOG.md
  2. 2
      apps/api/src/models/rule.ts
  3. 24
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts
  4. 2
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html
  5. 102
      libs/ui/src/lib/assistant/assistant.component.ts
  6. 2
      libs/ui/src/lib/assistant/assistant.html
  7. 8
      libs/ui/src/lib/portfolio-filter-form/interfaces/portfolio-filter-form-value.interface.ts

6
CHANGELOG.md

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Fixed
- Fixed an exception by adding a fallback for missing market price values on the _X-ray_ page
## 2.243.0 - 2026-02-23 ## 2.243.0 - 2026-02-23
### Changed ### Changed

2
apps/api/src/models/rule.ts

@ -57,7 +57,7 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
previousValue + previousValue +
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
new Big(currentValue.quantity) new Big(currentValue.quantity)
.mul(currentValue.marketPrice) .mul(currentValue.marketPrice ?? 0)
.toNumber(), .toNumber(),
currentValue.currency, currentValue.currency,
baseCurrency baseCurrency

24
libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts

@ -7,12 +7,12 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
ElementRef, ElementRef,
EventEmitter,
HostBinding, HostBinding,
Input, Input,
OnChanges, OnChanges,
Output, ViewChild,
ViewChild inject,
output
} from '@angular/core'; } from '@angular/core';
import { Params, RouterModule } from '@angular/router'; import { Params, RouterModule } from '@angular/router';
@ -33,21 +33,23 @@ export class GfAssistantListItemComponent
implements FocusableOption, OnChanges implements FocusableOption, OnChanges
{ {
@HostBinding('attr.tabindex') tabindex = -1; @HostBinding('attr.tabindex') tabindex = -1;
@HostBinding('class.has-focus') get getHasFocus() {
return this.hasFocus;
}
@Input() item: SearchResultItem; @Input() item: SearchResultItem;
@Output() clicked = new EventEmitter<void>(); @ViewChild('link') public linkElement: ElementRef<HTMLAnchorElement>;
@ViewChild('link') public linkElement: ElementRef;
public hasFocus = false; public hasFocus = false;
public queryParams: Params; public queryParams: Params;
public routerLink: string[]; public routerLink: string[];
public constructor(private changeDetectorRef: ChangeDetectorRef) {} protected readonly clicked = output<void>();
private readonly changeDetectorRef = inject(ChangeDetectorRef);
@HostBinding('class.has-focus')
public get getHasFocus() {
return this.hasFocus;
}
public ngOnChanges() { public ngOnChanges() {
if (this.item?.mode === SearchMode.ACCOUNT) { if (this.item?.mode === SearchMode.ACCOUNT) {
@ -65,7 +67,7 @@ export class GfAssistantListItemComponent
}; };
this.routerLink = this.routerLink =
internalRoutes.adminControl.subRoutes.marketData.routerLink; internalRoutes.adminControl.subRoutes?.marketData.routerLink ?? [];
} else if (this.item?.mode === SearchMode.HOLDING) { } else if (this.item?.mode === SearchMode.HOLDING) {
this.queryParams = { this.queryParams = {
dataSource: this.item.dataSource, dataSource: this.item.dataSource,

2
libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html

@ -8,7 +8,7 @@
@if (item && isAsset(item)) { @if (item && isAsset(item)) {
<br /> <br />
<small class="text-muted" <small class="text-muted"
>{{ item?.symbol | gfSymbol }} · {{ item?.currency }} >{{ item?.symbol ?? '' | gfSymbol }} · {{ item?.currency }}
@if (item?.assetSubClassString) { @if (item?.assetSubClassString) {
· {{ item.assetSubClassString }} · {{ item.assetSubClassString }}
} }

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

@ -12,16 +12,15 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
ElementRef, ElementRef,
EventEmitter,
HostListener, HostListener,
Input, Input,
OnChanges, OnChanges,
OnDestroy, OnDestroy,
OnInit, OnInit,
Output,
QueryList, QueryList,
ViewChild, ViewChild,
ViewChildren ViewChildren,
output
} from '@angular/core'; } from '@angular/core';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@ -86,37 +85,7 @@ import {
templateUrl: './assistant.html' templateUrl: './assistant.html'
}) })
export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
@HostListener('document:keydown', ['$event']) onKeydown( public static readonly SEARCH_RESULTS_DEFAULT_LIMIT = 5;
event: KeyboardEvent
) {
if (!this.isOpen) {
return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
for (const item of this.assistantListItems) {
item.removeFocus();
}
this.keyManager.onKeydown(event);
const currentAssistantListItem = this.getCurrentAssistantListItem();
if (currentAssistantListItem?.linkElement) {
currentAssistantListItem.linkElement.nativeElement?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
} else if (event.key === 'Enter') {
const currentAssistantListItem = this.getCurrentAssistantListItem();
if (currentAssistantListItem?.linkElement) {
currentAssistantListItem.linkElement.nativeElement?.click();
event.stopPropagation();
}
}
}
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToAccessAdminControl: boolean; @Input() hasPermissionToAccessAdminControl: boolean;
@ -124,21 +93,16 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
@Input() hasPermissionToChangeFilters: boolean; @Input() hasPermissionToChangeFilters: boolean;
@Input() user: User; @Input() user: User;
@Output() closed = new EventEmitter<void>();
@Output() dateRangeChanged = new EventEmitter<DateRange>();
@Output() filtersChanged = new EventEmitter<Filter[]>();
@ViewChild('menuTrigger') menuTriggerElement: MatMenuTrigger; @ViewChild('menuTrigger') menuTriggerElement: MatMenuTrigger;
@ViewChild('search', { static: true }) searchElement: ElementRef; @ViewChild('search', { static: true })
searchElement: ElementRef<HTMLInputElement>;
@ViewChildren(GfAssistantListItemComponent) @ViewChildren(GfAssistantListItemComponent)
assistantListItems: QueryList<GfAssistantListItemComponent>; assistantListItems: QueryList<GfAssistantListItemComponent>;
public static readonly SEARCH_RESULTS_DEFAULT_LIMIT = 5;
public accounts: AccountWithPlatform[] = []; public accounts: AccountWithPlatform[] = [];
public assetClasses: Filter[] = []; public assetClasses: Filter[] = [];
public dateRangeFormControl = new FormControl<string>(undefined); public dateRangeFormControl = new FormControl<string | null>(null);
public dateRangeOptions: DateRangeOption[] = []; public dateRangeOptions: DateRangeOption[] = [];
public holdings: PortfolioPosition[] = []; public holdings: PortfolioPosition[] = [];
public isLoading = { public isLoading = {
@ -166,6 +130,10 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
}; };
public tags: Filter[] = []; public tags: Filter[] = [];
protected readonly closed = output<void>();
protected readonly dateRangeChanged = output<DateRange>();
protected readonly filtersChanged = output<Filter[]>();
private readonly PRESELECTION_DELAY = 100; private readonly PRESELECTION_DELAY = 100;
private filterTypes: Filter['type'][] = [ private filterTypes: Filter['type'][] = [
@ -188,6 +156,37 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
addIcons({ closeCircleOutline, closeOutline, searchOutline }); addIcons({ closeCircleOutline, closeOutline, searchOutline });
} }
@HostListener('document:keydown', ['$event'])
public onKeydown(event: KeyboardEvent) {
if (!this.isOpen) {
return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
for (const item of this.assistantListItems) {
item.removeFocus();
}
this.keyManager.onKeydown(event);
const currentAssistantListItem = this.getCurrentAssistantListItem();
if (currentAssistantListItem?.linkElement) {
currentAssistantListItem.linkElement.nativeElement?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
} else if (event.key === 'Enter') {
const currentAssistantListItem = this.getCurrentAssistantListItem();
if (currentAssistantListItem?.linkElement) {
currentAssistantListItem.linkElement.nativeElement?.click();
event.stopPropagation();
}
}
}
public ngOnInit() { public ngOnInit() {
this.assetClasses = Object.keys(AssetClass).map((assetClass) => { this.assetClasses = Object.keys(AssetClass).map((assetClass) => {
return { return {
@ -482,7 +481,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
.subscribe(({ holdings }) => { .subscribe(({ holdings }) => {
this.holdings = holdings this.holdings = holdings
.filter(({ assetSubClass }) => { .filter(({ assetSubClass }) => {
return !['CASH'].includes(assetSubClass); return assetSubClass && !['CASH'].includes(assetSubClass);
}) })
.sort((a, b) => { .sort((a, b) => {
return a.name?.localeCompare(b.name); return a.name?.localeCompare(b.name);
@ -499,23 +498,23 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
this.filtersChanged.emit([ this.filtersChanged.emit([
{ {
id: filterValue?.account, id: filterValue?.account ?? '',
type: 'ACCOUNT' type: 'ACCOUNT'
}, },
{ {
id: filterValue?.assetClass, id: filterValue?.assetClass ?? '',
type: 'ASSET_CLASS' type: 'ASSET_CLASS'
}, },
{ {
id: filterValue?.holding?.dataSource, id: filterValue?.holding?.dataSource ?? '',
type: 'DATA_SOURCE' type: 'DATA_SOURCE'
}, },
{ {
id: filterValue?.holding?.symbol, id: filterValue?.holding?.symbol ?? '',
type: 'SYMBOL' type: 'SYMBOL'
}, },
{ {
id: filterValue?.tag, id: filterValue?.tag ?? '',
type: 'TAG' type: 'TAG'
} }
]); ]);
@ -541,7 +540,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
this.filterTypes.map((type) => { this.filterTypes.map((type) => {
return { return {
type, type,
id: null id: ''
}; };
}) })
); );
@ -673,7 +672,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
dataSource, dataSource,
name, name,
symbol, symbol,
assetSubClassString: translate(assetSubClass), assetSubClassString: translate(assetSubClass ?? ''),
mode: SearchMode.ASSET_PROFILE as const mode: SearchMode.ASSET_PROFILE as const
}; };
} }
@ -705,7 +704,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
dataSource, dataSource,
name, name,
symbol, symbol,
assetSubClassString: translate(assetSubClass), assetSubClassString: translate(assetSubClass ?? ''),
mode: SearchMode.HOLDING as const mode: SearchMode.HOLDING as const
}; };
} }
@ -755,6 +754,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
const symbol = this.user?.settings?.['filters.symbol']; const symbol = this.user?.settings?.['filters.symbol'];
const selectedHolding = this.holdings.find((holding) => { const selectedHolding = this.holdings.find((holding) => {
return ( return (
!!(dataSource && symbol) &&
getAssetProfileIdentifier({ getAssetProfileIdentifier({
dataSource: holding.dataSource, dataSource: holding.dataSource,
symbol: holding.symbol symbol: holding.symbol

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

@ -186,7 +186,7 @@
<div class="p-3"> <div class="p-3">
<gf-portfolio-filter-form <gf-portfolio-filter-form
#portfolioFilterForm #portfolioFilterForm
[accounts]="user?.accounts" [accounts]="user?.accounts ?? []"
[assetClasses]="assetClasses" [assetClasses]="assetClasses"
[formControl]="portfolioFilterFormControl" [formControl]="portfolioFilterFormControl"
[holdings]="holdings" [holdings]="holdings"

8
libs/ui/src/lib/portfolio-filter-form/interfaces/portfolio-filter-form-value.interface.ts

@ -1,8 +1,8 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
export interface PortfolioFilterFormValue { export interface PortfolioFilterFormValue {
account: string; account: string | null;
assetClass: string; assetClass: string | null;
holding: PortfolioPosition; holding: PortfolioPosition | null;
tag: string; tag: string | null;
} }

Loading…
Cancel
Save