Compare commits

...

7 Commits

Author SHA1 Message Date
github-actions[bot] 1cc4e50143 Update locales 2 days ago
Thomas Kaul 0fee18908f
Release 2.244.0 (#6409) 2 days ago
Thomas Kaul 98fb0b86af
Task/improve usability of asset profile details dialog for currencies (#6406) 2 days ago
Kenrick Tandrian 9493f79f8e
Task/improve type safety in portfolio filter form component (#6404) 2 days ago
Thomas Kaul f2c81ada90
Task/refactor $queryRawUnsafe() in data provider service (#6408) 2 days ago
github-actions[bot] 3f14e5ad3a
Task/update locales (#6356) 2 days ago
Thomas Kaul a7434c9ba7
Task/remove deprecated fee ratio X-ray rule (#6364) 2 days ago
  1. 8
      CHANGELOG.md
  2. 6
      apps/api/src/app/admin/admin.service.ts
  3. 8
      apps/api/src/app/portfolio/portfolio.service.ts
  4. 8
      apps/api/src/app/user/user.service.ts
  5. 104
      apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts
  6. 33
      apps/api/src/services/data-provider/data-provider.service.ts
  7. 24
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  8. 11
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  9. 9
      apps/client/src/app/pages/i18n/i18n-page.html
  10. 282
      apps/client/src/locales/messages.ca.xlf
  11. 282
      apps/client/src/locales/messages.de.xlf
  12. 282
      apps/client/src/locales/messages.es.xlf
  13. 282
      apps/client/src/locales/messages.fr.xlf
  14. 282
      apps/client/src/locales/messages.it.xlf
  15. 282
      apps/client/src/locales/messages.ko.xlf
  16. 282
      apps/client/src/locales/messages.nl.xlf
  17. 282
      apps/client/src/locales/messages.pl.xlf
  18. 282
      apps/client/src/locales/messages.pt.xlf
  19. 282
      apps/client/src/locales/messages.tr.xlf
  20. 282
      apps/client/src/locales/messages.uk.xlf
  21. 279
      apps/client/src/locales/messages.xlf
  22. 282
      apps/client/src/locales/messages.zh.xlf
  23. 1
      libs/common/src/lib/interfaces/x-ray-rules-settings.interface.ts
  24. 12
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html
  25. 4
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.stories.ts
  26. 69
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts
  27. 4
      package-lock.json
  28. 2
      package.json

8
CHANGELOG.md

@ -5,7 +5,13 @@ 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 ## 2.244.0 - 2026-02-28
### Changed
- Improved the usability of the asset profile details dialog in the admin control panel for currencies
- Removed the deprecated static portfolio analysis rule: _Fees_ (Fee Ratio)
- Refactored queries in the data provider service to use Prisma’s safe query methods
### Fixed ### Fixed

6
apps/api/src/app/admin/admin.service.ts

@ -470,7 +470,9 @@ export class AdminService {
let currency: EnhancedSymbolProfile['currency'] = '-'; let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
if (isCurrency(getCurrencyFromSymbol(symbol))) { const isCurrencyAssetProfile = isCurrency(getCurrencyFromSymbol(symbol));
if (isCurrencyAssetProfile) {
currency = getCurrencyFromSymbol(symbol); currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } = ({ activitiesCount, dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(currency)); await this.orderService.getStatisticsByCurrency(currency));
@ -508,6 +510,8 @@ export class AdminService {
dataSource, dataSource,
dateOfFirstActivity, dateOfFirstActivity,
symbol, symbol,
assetClass: isCurrencyAssetProfile ? AssetClass.LIQUIDITY : undefined,
assetSubClass: isCurrencyAssetProfile ? AssetSubClass.CASH : undefined,
isActive: true isActive: true
} }
}; };

8
apps/api/src/app/portfolio/portfolio.service.ts

@ -13,7 +13,6 @@ import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rul
import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets'; import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets';
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets'; import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { FeeRatioTotalInvestmentVolume } from '@ghostfolio/api/models/rules/fees/fee-ratio-total-investment-volume'; import { FeeRatioTotalInvestmentVolume } from '@ghostfolio/api/models/rules/fees/fee-ratio-total-investment-volume';
import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power'; import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power';
import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific'; import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific';
@ -1310,13 +1309,6 @@ export class PortfolioService {
}), }),
rules: await this.rulesService.evaluate( rules: await this.rulesService.evaluate(
[ [
new FeeRatioInitialInvestment(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
summary.committedFunds,
summary.fees
),
new FeeRatioTotalInvestmentVolume( new FeeRatioTotalInvestmentVolume(
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService, this.i18nService,

8
apps/api/src/app/user/user.service.ts

@ -12,7 +12,6 @@ import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rul
import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets'; import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets';
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets'; import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { FeeRatioTotalInvestmentVolume } from '@ghostfolio/api/models/rules/fees/fee-ratio-total-investment-volume'; import { FeeRatioTotalInvestmentVolume } from '@ghostfolio/api/models/rules/fees/fee-ratio-total-investment-volume';
import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power'; import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power';
import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific'; import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific';
@ -377,13 +376,6 @@ export class UserService {
undefined, undefined,
undefined undefined
).getSettings(user.settings.settings), ).getSettings(user.settings.settings),
FeeRatioInitialInvestment: new FeeRatioInitialInvestment(
undefined,
undefined,
undefined,
undefined,
undefined
).getSettings(user.settings.settings),
FeeRatioTotalInvestmentVolume: new FeeRatioTotalInvestmentVolume( FeeRatioTotalInvestmentVolume: new FeeRatioTotalInvestmentVolume(
undefined, undefined,
undefined, undefined,

104
apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts

@ -1,104 +0,0 @@
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces';
/**
* @deprecated This rule is deprecated in favor of FeeRatioTotalInvestmentVolume
*/
export class FeeRatioInitialInvestment extends Rule<Settings> {
private fees: number;
private totalInvestment: number;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
totalInvestment: number,
fees: number
) {
super(exchangeRateDataService, {
languageCode,
key: FeeRatioInitialInvestment.name
});
this.fees = fees;
this.totalInvestment = totalInvestment;
}
public evaluate(ruleSettings: Settings) {
const feeRatio = this.totalInvestment
? this.fees / this.totalInvestment
: 0;
if (feeRatio > ruleSettings.thresholdMax) {
return {
evaluation: this.i18nService.getTranslation({
id: 'rule.feeRatioInitialInvestment.false',
languageCode: this.getLanguageCode(),
placeholders: {
feeRatio: (ruleSettings.thresholdMax * 100).toFixed(2),
thresholdMax: (feeRatio * 100).toPrecision(3)
}
}),
value: false
};
}
return {
evaluation: this.i18nService.getTranslation({
id: 'rule.feeRatioInitialInvestment.true',
languageCode: this.getLanguageCode(),
placeholders: {
feeRatio: (feeRatio * 100).toPrecision(3),
thresholdMax: (ruleSettings.thresholdMax * 100).toFixed(2)
}
}),
value: true
};
}
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.fees.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() {
return {
threshold: {
max: 0.1,
min: 0,
step: 0.0025,
unit: '%'
},
thresholdMax: true
};
}
public getName() {
return this.i18nService.getTranslation({
id: 'rule.feeRatioInitialInvestment',
languageCode: this.getLanguageCode()
});
}
public getSettings({
baseCurrency,
locale,
xRayRules
}: UserSettings): Settings {
return {
baseCurrency,
locale,
isActive: xRayRules?.[this.getKey()]?.isActive ?? true,
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.01
};
}
}
interface Settings extends RuleSettings {
baseCurrency: string;
thresholdMax: number;
}

33
apps/api/src/services/data-provider/data-provider.service.ts

@ -30,7 +30,7 @@ import {
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client'; import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { eachDayOfInterval, format, isValid } from 'date-fns'; import { eachDayOfInterval, format, isValid } from 'date-fns';
import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash'; import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash';
@ -347,36 +347,35 @@ export class DataProviderService implements OnModuleInit {
const granularityQuery = const granularityQuery =
aGranularity === 'month' aGranularity === 'month'
? `AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')` ? Prisma.sql`AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')`
: ''; : Prisma.empty;
const rangeQuery = const rangeQuery =
from && to from && to
? `AND date >= '${format(from, DATE_FORMAT)}' AND date <= '${format( ? Prisma.sql`AND date >= ${format(from, DATE_FORMAT)}::timestamp AND date <= ${format(
to, to,
DATE_FORMAT DATE_FORMAT
)}'` )}::timestamp`
: ''; : Prisma.empty;
const dataSources = aItems.map(({ dataSource }) => { const dataSources = aItems.map(({ dataSource }) => {
return dataSource; return dataSource;
}); });
const symbols = aItems.map(({ symbol }) => { const symbols = aItems.map(({ symbol }) => {
return symbol; return symbol;
}); });
try { try {
const queryRaw = ` const marketDataByGranularity: MarketData[] = await this.prismaService
SELECT * .$queryRaw`
FROM "MarketData" SELECT *
WHERE "dataSource" IN ('${dataSources.join(`','`)}') FROM "MarketData"
AND "symbol" IN ('${symbols.join( WHERE "dataSource"::text IN (${Prisma.join(dataSources)})
`','` AND "symbol" IN (${Prisma.join(symbols)})
)}') ${granularityQuery} ${rangeQuery} ${granularityQuery}
ORDER BY date;`; ${rangeQuery}
ORDER BY date;`;
const marketDataByGranularity: MarketData[] =
await this.prismaService.$queryRawUnsafe(queryRaw);
response = marketDataByGranularity.reduce((r, marketData) => { response = marketDataByGranularity.reduce((r, marketData) => {
const { date, marketPrice, symbol } = marketData; const { date, marketPrice, symbol } = marketData;

24
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -5,7 +5,11 @@ import {
PROPERTY_IS_DATA_GATHERING_ENABLED PROPERTY_IS_DATA_GATHERING_ENABLED
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos'; import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import {
DATE_FORMAT,
getCurrencyFromSymbol,
isCurrency
} from '@ghostfolio/common/helper';
import { import {
AdminMarketDataDetails, AdminMarketDataDetails,
AssetClassSelectorOption, AssetClassSelectorOption,
@ -138,7 +142,6 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
}); });
public assetSubClassOptions: AssetClassSelectorOption[] = []; public assetSubClassOptions: AssetClassSelectorOption[] = [];
public assetProfile: AdminMarketDataDetails['assetProfile']; public assetProfile: AdminMarketDataDetails['assetProfile'];
public assetProfileForm = this.formBuilder.group({ public assetProfileForm = this.formBuilder.group({
@ -180,12 +183,14 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
); );
public benchmarks: Partial<SymbolProfile>[]; public benchmarks: Partial<SymbolProfile>[];
public canEditAssetProfile = true;
public countries: { public countries: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
public currencies: string[] = []; public currencies: string[] = [];
public dateRangeOptions = [ public dateRangeOptions = [
{ {
label: $localize`Current week` + ' (' + $localize`WTD` + ')', label: $localize`Current week` + ' (' + $localize`WTD` + ')',
@ -260,7 +265,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
} }
public get canSaveAssetProfileIdentifier() { public get canSaveAssetProfileIdentifier() {
return !this.assetProfileForm.dirty; return !this.assetProfileForm.dirty && this.canEditAssetProfile;
} }
public ngOnInit() { public ngOnInit() {
@ -324,6 +329,11 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
this.assetClassLabel = translate(this.assetProfile?.assetClass); this.assetClassLabel = translate(this.assetProfile?.assetClass);
this.assetSubClassLabel = translate(this.assetProfile?.assetSubClass); this.assetSubClassLabel = translate(this.assetProfile?.assetSubClass);
this.canEditAssetProfile = !isCurrency(
getCurrencyFromSymbol(this.data.symbol)
);
this.countries = {}; this.countries = {};
this.isBenchmark = this.benchmarks.some(({ id }) => { this.isBenchmark = this.benchmarks.some(({ id }) => {
@ -390,6 +400,10 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
url: this.assetProfile?.url ?? '' url: this.assetProfile?.url ?? ''
}); });
if (!this.canEditAssetProfile) {
this.assetProfileForm.disable();
}
this.assetProfileForm.markAsPristine(); this.assetProfileForm.markAsPristine();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
@ -399,7 +413,9 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
public onCancelEditAssetProfileIdentifierMode() { public onCancelEditAssetProfileIdentifierMode() {
this.isEditAssetProfileIdentifierMode = false; this.isEditAssetProfileIdentifierMode = false;
this.assetProfileForm.enable(); if (this.canEditAssetProfile) {
this.assetProfileForm.enable();
}
this.assetProfileIdentifierForm.reset(); this.assetProfileIdentifierForm.reset();
} }

11
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -300,6 +300,7 @@
</div> </div>
<form <form
#assetProfileFormElement #assetProfileFormElement
[class.d-none]="!canEditAssetProfile"
[formGroup]="assetProfileForm" [formGroup]="assetProfileForm"
(keyup.enter)="assetProfileForm.valid && onSubmitAssetProfileForm()" (keyup.enter)="assetProfileForm.valid && onSubmitAssetProfileForm()"
(ngSubmit)="onSubmitAssetProfileForm()" (ngSubmit)="onSubmitAssetProfileForm()"
@ -358,7 +359,9 @@
<mat-checkbox <mat-checkbox
color="primary" color="primary"
[checked]="isBenchmark" [checked]="isBenchmark"
[disabled]="isEditAssetProfileIdentifierMode" [disabled]="
!canEditAssetProfile || isEditAssetProfileIdentifierMode
"
(change)=" (change)="
isBenchmark isBenchmark
? onUnsetBenchmark({ ? onUnsetBenchmark({
@ -581,7 +584,11 @@
<mat-checkbox <mat-checkbox
color="primary" color="primary"
[checked]="isDataGatheringEnabled && (assetProfile?.isActive ?? false)" [checked]="isDataGatheringEnabled && (assetProfile?.isActive ?? false)"
[disabled]="!isDataGatheringEnabled || isEditAssetProfileIdentifierMode" [disabled]="
!canEditAssetProfile ||
!isDataGatheringEnabled ||
isEditAssetProfileIdentifierMode
"
(change)="onToggleIsActive($event)" (change)="onToggleIsActive($event)"
> >
<ng-container i18n>Data Gathering</ng-container> <ng-container i18n>Data Gathering</ng-container>

9
apps/client/src/app/pages/i18n/i18n-page.html

@ -149,15 +149,6 @@
<li i18n="@@rule.emergencyFundSetup.true"> <li i18n="@@rule.emergencyFundSetup.true">
An emergency fund has been set up An emergency fund has been set up
</li> </li>
<li i18n="@@rule.feeRatioInitialInvestment">Fee Ratio (legacy)</li>
<li i18n="@@rule.feeRatioInitialInvestment.false">
The fees do exceed $&#123;thresholdMax&#125;% of your initial investment
($&#123;feeRatio&#125;%)
</li>
<li i18n="@@rule.feeRatioInitialInvestment.true">
The fees do not exceed $&#123;thresholdMax&#125;% of your initial
investment ($&#123;feeRatio&#125;%)
</li>
<li i18n="@@rule.feeRatioTotalInvestmentVolume">Fee Ratio</li> <li i18n="@@rule.feeRatioTotalInvestmentVolume">Fee Ratio</li>
<li i18n="@@rule.feeRatioTotalInvestmentVolume.false"> <li i18n="@@rule.feeRatioTotalInvestmentVolume.false">
The fees do exceed $&#123;thresholdMax&#125;% of your total investment The fees do exceed $&#123;thresholdMax&#125;% of your total investment

282
apps/client/src/locales/messages.ca.xlf

File diff suppressed because it is too large

282
apps/client/src/locales/messages.de.xlf

File diff suppressed because it is too large

282
apps/client/src/locales/messages.es.xlf

File diff suppressed because it is too large

282
apps/client/src/locales/messages.fr.xlf

File diff suppressed because it is too large

282
apps/client/src/locales/messages.it.xlf

File diff suppressed because it is too large

282
apps/client/src/locales/messages.ko.xlf

File diff suppressed because it is too large

282
apps/client/src/locales/messages.nl.xlf

File diff suppressed because it is too large

282
apps/client/src/locales/messages.pl.xlf

File diff suppressed because it is too large

282
apps/client/src/locales/messages.pt.xlf

File diff suppressed because it is too large

282
apps/client/src/locales/messages.tr.xlf

File diff suppressed because it is too large

282
apps/client/src/locales/messages.uk.xlf

File diff suppressed because it is too large

279
apps/client/src/locales/messages.xlf

File diff suppressed because it is too large

282
apps/client/src/locales/messages.zh.xlf

File diff suppressed because it is too large

1
libs/common/src/lib/interfaces/x-ray-rules-settings.interface.ts

@ -9,7 +9,6 @@ export interface XRayRulesSettings {
EconomicMarketClusterRiskDevelopedMarkets?: RuleSettings; EconomicMarketClusterRiskDevelopedMarkets?: RuleSettings;
EconomicMarketClusterRiskEmergingMarkets?: RuleSettings; EconomicMarketClusterRiskEmergingMarkets?: RuleSettings;
EmergencyFundSetup?: RuleSettings; EmergencyFundSetup?: RuleSettings;
FeeRatioInitialInvestment?: RuleSettings;
FeeRatioTotalInvestmentVolume?: RuleSettings; FeeRatioTotalInvestmentVolume?: RuleSettings;
RegionalMarketClusterRiskAsiaPacific?: RuleSettings; RegionalMarketClusterRiskAsiaPacific?: RuleSettings;
RegionalMarketClusterRiskEmergingMarkets?: RuleSettings; RegionalMarketClusterRiskEmergingMarkets?: RuleSettings;

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

@ -4,14 +4,14 @@
<mat-label i18n>Account</mat-label> <mat-label i18n>Account</mat-label>
<mat-select formControlName="account"> <mat-select formControlName="account">
<mat-option [value]="null" /> <mat-option [value]="null" />
@for (account of accounts; track account.id) { @for (account of accounts(); track account.id) {
<mat-option [value]="account.id"> <mat-option [value]="account.id">
<div class="d-flex"> <div class="d-flex">
@if (account.platform?.url) { @if (account.platform?.url) {
<gf-entity-logo <gf-entity-logo
class="mr-1" class="mr-1"
[tooltip]="account.platform?.name" [tooltip]="account.platform?.name ?? ''"
[url]="account.platform?.url" [url]="account.platform?.url ?? ''"
/> />
} }
<span>{{ account.name }}</span> <span>{{ account.name }}</span>
@ -32,7 +32,7 @@
filterForm.get('holding')?.value?.name filterForm.get('holding')?.value?.name
}}</mat-select-trigger> }}</mat-select-trigger>
<mat-option [value]="null" /> <mat-option [value]="null" />
@for (holding of holdings; track holding.name) { @for (holding of holdings(); track holding.name) {
<mat-option [value]="holding"> <mat-option [value]="holding">
<div class="line-height-1 text-truncate"> <div class="line-height-1 text-truncate">
<span <span
@ -53,7 +53,7 @@
<mat-label i18n>Tag</mat-label> <mat-label i18n>Tag</mat-label>
<mat-select formControlName="tag"> <mat-select formControlName="tag">
<mat-option [value]="null" /> <mat-option [value]="null" />
@for (tag of tags; track tag.id) { @for (tag of tags(); track tag.id) {
<mat-option [value]="tag.id">{{ tag.label }}</mat-option> <mat-option [value]="tag.id">{{ tag.label }}</mat-option>
} }
</mat-select> </mat-select>
@ -64,7 +64,7 @@
<mat-label i18n>Asset Class</mat-label> <mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass"> <mat-select formControlName="assetClass">
<mat-option [value]="null" /> <mat-option [value]="null" />
@for (assetClass of assetClasses; track assetClass.id) { @for (assetClass of assetClasses(); track assetClass.id) {
<mat-option [value]="assetClass.id">{{ <mat-option [value]="assetClass.id">{{
assetClass.label assetClass.label
}}</mat-option> }}</mat-option>

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

@ -40,7 +40,7 @@ export const Default: Story = {
{ id: 'COMMODITY', label: 'Commodity', type: 'ASSET_CLASS' }, { id: 'COMMODITY', label: 'Commodity', type: 'ASSET_CLASS' },
{ id: 'EQUITY', label: 'Equity', type: 'ASSET_CLASS' }, { id: 'EQUITY', label: 'Equity', type: 'ASSET_CLASS' },
{ id: 'FIXED_INCOME', label: 'Fixed Income', type: 'ASSET_CLASS' } { id: 'FIXED_INCOME', label: 'Fixed Income', type: 'ASSET_CLASS' }
] as any, ],
holdings: [ holdings: [
{ {
currency: 'USD', currency: 'USD',
@ -66,7 +66,7 @@ export const Default: Story = {
label: 'Retirement Fund', label: 'Retirement Fund',
type: 'TAG' type: 'TAG'
} }
] as any, ],
disabled: false disabled: false
} }
}; };

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

@ -8,12 +8,15 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
Input, DestroyRef,
OnChanges, OnChanges,
OnDestroy,
OnInit, OnInit,
forwardRef forwardRef,
inject,
input,
model
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
ControlValueAccessor, ControlValueAccessor,
FormBuilder, FormBuilder,
@ -25,7 +28,6 @@ import {
} from '@angular/forms'; } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { Subject, takeUntil } from 'rxjs';
import { GfEntityLogoComponent } from '../entity-logo/entity-logo.component'; import { GfEntityLogoComponent } from '../entity-logo/entity-logo.component';
import { PortfolioFilterFormValue } from './interfaces'; import { PortfolioFilterFormValue } from './interfaces';
@ -53,33 +55,37 @@ import { PortfolioFilterFormValue } from './interfaces';
templateUrl: './portfolio-filter-form.component.html' templateUrl: './portfolio-filter-form.component.html'
}) })
export class GfPortfolioFilterFormComponent export class GfPortfolioFilterFormComponent
implements ControlValueAccessor, OnInit, OnChanges, OnDestroy implements ControlValueAccessor, OnChanges, OnInit
{ {
@Input() accounts: AccountWithPlatform[] = []; public readonly accounts = input<AccountWithPlatform[]>([]);
@Input() assetClasses: Filter[] = []; public readonly assetClasses = input<Filter[]>([]);
@Input() holdings: PortfolioPosition[] = []; public readonly disabled = model(false);
@Input() tags: Filter[] = []; public readonly holdings = input<PortfolioPosition[]>([]);
@Input() disabled = false; public readonly tags = input<Filter[]>([]);
public filterForm: FormGroup; public filterForm: FormGroup<{
account: FormControl<string | null>;
private unsubscribeSubject = new Subject<void>(); assetClass: FormControl<string | null>;
holding: FormControl<PortfolioPosition | null>;
public constructor( tag: FormControl<string | null>;
private changeDetectorRef: ChangeDetectorRef, }>;
private formBuilder: FormBuilder
) { private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);
public constructor() {
this.filterForm = this.formBuilder.group({ this.filterForm = this.formBuilder.group({
account: new FormControl<string>(null), account: new FormControl<string | null>(null),
assetClass: new FormControl<string>(null), assetClass: new FormControl<string | null>(null),
holding: new FormControl<PortfolioPosition>(null), holding: new FormControl<PortfolioPosition | null>(null),
tag: new FormControl<string>(null) tag: new FormControl<string | null>(null)
}); });
} }
public ngOnInit() { public ngOnInit() {
this.filterForm.valueChanges this.filterForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((value) => { .subscribe((value) => {
this.onChange(value as PortfolioFilterFormValue); this.onChange(value as PortfolioFilterFormValue);
this.onTouched(); this.onTouched();
@ -108,7 +114,7 @@ export class GfPortfolioFilterFormComponent
} }
public ngOnChanges() { public ngOnChanges() {
if (this.disabled) { if (this.disabled()) {
this.filterForm.disable({ emitEvent: false }); this.filterForm.disable({ emitEvent: false });
} else { } else {
this.filterForm.enable({ emitEvent: false }); this.filterForm.enable({ emitEvent: false });
@ -116,9 +122,9 @@ export class GfPortfolioFilterFormComponent
const tagControl = this.filterForm.get('tag'); const tagControl = this.filterForm.get('tag');
if (this.tags.length === 0) { if (this.tags().length === 0) {
tagControl?.disable({ emitEvent: false }); tagControl?.disable({ emitEvent: false });
} else if (!this.disabled) { } else if (!this.disabled()) {
tagControl?.enable({ emitEvent: false }); tagControl?.enable({ emitEvent: false });
} }
@ -134,9 +140,9 @@ export class GfPortfolioFilterFormComponent
} }
public setDisabledState(isDisabled: boolean) { public setDisabledState(isDisabled: boolean) {
this.disabled = isDisabled; this.disabled.set(isDisabled);
if (this.disabled) { if (this.disabled()) {
this.filterForm.disable({ emitEvent: false }); this.filterForm.disable({ emitEvent: false });
} else { } else {
this.filterForm.enable({ emitEvent: false }); this.filterForm.enable({ emitEvent: false });
@ -161,11 +167,6 @@ export class GfPortfolioFilterFormComponent
} }
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
private onChange = (_value: PortfolioFilterFormValue): void => { private onChange = (_value: PortfolioFilterFormValue): void => {
// ControlValueAccessor onChange callback // ControlValueAccessor onChange callback

4
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.243.0", "version": "2.244.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.243.0", "version": "2.244.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.243.0", "version": "2.244.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",

Loading…
Cancel
Save