From e7f57d6dc05bc6e510b491b3d6d580495e92c87f Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:31:53 +0200 Subject: [PATCH] Feature/support country codes in entity logo component (#7884) * Add support for country codes to entity logo component * Update changelog --- CHANGELOG.md | 1 + libs/common/src/lib/helper.spec.ts | 20 ++++++++ libs/common/src/lib/helper.ts | 17 ++++--- .../account-selector.component.html | 5 +- .../currency-selector.component.html | 13 ++++-- .../currency-selector.component.ts | 15 +++--- .../entity-logo/entity-logo.component.html | 17 ++++++- .../entity-logo/entity-logo.component.scss | 12 +++-- .../entity-logo.component.stories.ts | 8 ++++ .../lib/entity-logo/entity-logo.component.ts | 12 +++-- .../holdings-table.component.html | 1 + .../holdings-table.component.stories.ts | 2 +- .../holdings-table.component.ts | 12 ++++- libs/ui/src/lib/mocks/holdings.ts | 46 ++++++++++++++++--- 14 files changed, 144 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a3027dd15..99f556ba1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added a hint for the performance calculation type to the analysis page +- Added support for country codes to the entity logo component and used it for cash holdings and currency selectors ### Changed diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index 58ac417c4a..d1130f0d75 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -6,6 +6,7 @@ import { canApplyFiltersToAccess, extractNumberFromString, getCountryCodeFromCurrency, + getEmojiFlag, getNumberFormatGroup, getStringOrNull, getStringOrUndefined, @@ -122,6 +123,25 @@ describe('Helper', () => { }); }); + describe('Get emoji flag', () => { + it('Country code', () => { + expect(getEmojiFlag('US')).toEqual('πŸ‡ΊπŸ‡Έ'); + expect(getEmojiFlag('us')).toEqual('πŸ‡ΊπŸ‡Έ'); + }); + + it('European Union code', () => { + expect(getEmojiFlag('EU')).toEqual('πŸ‡ͺπŸ‡Ί'); + }); + + it('Invalid country code', () => { + expect(getEmojiFlag('XX')).toEqual(undefined); + }); + + it('Empty country code', () => { + expect(getEmojiFlag('')).toEqual(undefined); + }); + }); + describe('Get number format group', () => { let languageGetter: jest.SpyInstance; diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 44f595036e..6e50911322 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -11,6 +11,7 @@ import { } from '@prisma/client'; import { Big } from 'big.js'; import { isISO4217CurrencyCode, isUUID } from 'class-validator'; +import { countries } from 'countries-list'; import { addDays, getDate, @@ -88,6 +89,8 @@ const USER_SETTINGS_KEYS_OF_AUTHENTICATED_USER: (keyof UserSettings)[] = [ 'viewMode' ]; +const VALID_COUNTRY_CODES = new Set([...Object.keys(countries), 'EU']); + export function applyAssetProfileOverrides>( assetProfile: T, assetProfileOverrides: AssetProfileOverrides | null @@ -439,15 +442,15 @@ export function getDateWithTimeFormatString(aLocale?: string) { } export function getEmojiFlag(aCountryCode: string) { - if (!aCountryCode) { - return aCountryCode; + const countryCode = aCountryCode?.toUpperCase(); + + if (!countryCode || !VALID_COUNTRY_CODES.has(countryCode)) { + return undefined; } - return aCountryCode - .toUpperCase() - .replace(/./g, (character) => - String.fromCodePoint(127397 + character.charCodeAt(0)) - ); + return countryCode.replace(/./g, (character) => { + return String.fromCodePoint(127397 + character.charCodeAt(0)); + }); } export function getLocale() { diff --git a/libs/ui/src/lib/account-selector/account-selector.component.html b/libs/ui/src/lib/account-selector/account-selector.component.html index 9d84693d05..cfa2616048 100644 --- a/libs/ui/src/lib/account-selector/account-selector.component.html +++ b/libs/ui/src/lib/account-selector/account-selector.component.html @@ -11,11 +11,10 @@ (closed)="onPanelClosed()" > -
- @if (selectedAccount()) { +
+ @if (selectedAccount()?.platform?.url) { diff --git a/libs/ui/src/lib/currency-selector/currency-selector.component.html b/libs/ui/src/lib/currency-selector/currency-selector.component.html index 2100b01de3..c3e622dadb 100644 --- a/libs/ui/src/lib/currency-selector/currency-selector.component.html +++ b/libs/ui/src/lib/currency-selector/currency-selector.component.html @@ -1,5 +1,8 @@ -@if (emojiFlagOfSelectedCurrency) { - {{ emojiFlagOfSelectedCurrency }} +@if (countryCodeOfSelectedCurrency) { + } - {{ getEmojiFlagFromCurrency(currency) }} + {{ currency }} diff --git a/libs/ui/src/lib/currency-selector/currency-selector.component.ts b/libs/ui/src/lib/currency-selector/currency-selector.component.ts index 27bd3ad792..de0cd6ff30 100644 --- a/libs/ui/src/lib/currency-selector/currency-selector.component.ts +++ b/libs/ui/src/lib/currency-selector/currency-selector.component.ts @@ -1,7 +1,4 @@ -import { - getCountryCodeFromCurrency, - getEmojiFlag -} from '@ghostfolio/common/helper'; +import { getCountryCodeFromCurrency } from '@ghostfolio/common/helper'; import { FocusMonitor } from '@angular/cdk/a11y'; import { @@ -40,6 +37,7 @@ import { import { MatInput, MatInputModule } from '@angular/material/input'; import { map, startWith } from 'rxjs/operators'; +import { GfEntityLogoComponent } from '../entity-logo/entity-logo.component'; import { AbstractMatFormField } from '../shared/abstract-mat-form-field'; @Component({ @@ -51,6 +49,7 @@ import { AbstractMatFormField } from '../shared/abstract-mat-form-field'; }, imports: [ FormsModule, + GfEntityLogoComponent, MatAutocompleteModule, MatFormFieldModule, MatInputModule, @@ -99,12 +98,12 @@ export class GfCurrencySelectorComponent return { elementRef: this.formField.getConnectedOverlayOrigin() }; } - public get emojiFlagOfSelectedCurrency() { + public get countryCodeOfSelectedCurrency() { const selectedCurrency = this.currencies().find((currency) => { return currency === this.control.value; }); - return this.getEmojiFlagFromCurrency(selectedCurrency); + return getCountryCodeFromCurrency(selectedCurrency); } public override get empty() { @@ -126,8 +125,8 @@ export class GfCurrencySelectorComponent this.input().focus(); } - public getEmojiFlagFromCurrency(aCurrency = '') { - return getEmojiFlag(getCountryCodeFromCurrency(aCurrency)); + public getCountryCodeFromCurrency(aCurrency = '') { + return getCountryCodeFromCurrency(aCurrency); } public ngOnInit() { diff --git a/libs/ui/src/lib/entity-logo/entity-logo.component.html b/libs/ui/src/lib/entity-logo/entity-logo.component.html index fe58f8226c..6a486e2125 100644 --- a/libs/ui/src/lib/entity-logo/entity-logo.component.html +++ b/libs/ui/src/lib/entity-logo/entity-logo.component.html @@ -1,4 +1,19 @@ -@if (src && !hasError) { +@if (emojiFlag) { + +} @else if (src && !hasError) { ; +export const FlagByCountryCode: Story = { + args: { + countryCode: 'US', + size: 'large', + tooltip: 'United States' + } +}; + export const LogoByAssetProfileIdentifier: Story = { args: { dataSource: 'YAHOO', diff --git a/libs/ui/src/lib/entity-logo/entity-logo.component.ts b/libs/ui/src/lib/entity-logo/entity-logo.component.ts index ef8e721056..8a788e0c80 100644 --- a/libs/ui/src/lib/entity-logo/entity-logo.component.ts +++ b/libs/ui/src/lib/entity-logo/entity-logo.component.ts @@ -1,3 +1,4 @@ +import { getEmojiFlag } from '@ghostfolio/common/helper'; import { EntityLogoImageSourceService } from '@ghostfolio/ui/entity-logo/entity-logo-image-source.service'; import { @@ -11,12 +12,14 @@ import { DataSource } from '@prisma/client'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'align-items-center d-flex' }, schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-entity-logo', styleUrls: ['./entity-logo.component.scss'], templateUrl: './entity-logo.component.html' }) export class GfEntityLogoComponent implements OnChanges { + @Input() countryCode: string; @Input() dataSource: DataSource; @Input() hasPlaceholder = false; @Input() size: 'large'; @@ -24,6 +27,7 @@ export class GfEntityLogoComponent implements OnChanges { @Input() tooltip: string; @Input() url: string; + public emojiFlag?: string; public hasError = false; public src?: string; @@ -32,17 +36,19 @@ export class GfEntityLogoComponent implements OnChanges { ) {} public ngOnChanges() { + this.emojiFlag = undefined; this.hasError = false; + this.src = undefined; - if (this.dataSource && this.symbol) { + if (this.countryCode) { + this.emojiFlag = getEmojiFlag(this.countryCode); + } else if (this.dataSource && this.symbol) { this.src = this.imageSourceService.getLogoUrlByAssetProfileIdentifier({ dataSource: this.dataSource, symbol: this.symbol }); } else if (this.url) { this.src = this.imageSourceService.getLogoUrlByUrl(this.url); - } else { - this.src = undefined; } } diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.html b/libs/ui/src/lib/holdings-table/holdings-table.component.html index 69be6f8961..2001a1b990 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.html +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.html @@ -17,6 +17,7 @@ [class.text-nowrap]="mode() === 'simple'" > ; export const Loading: Story = { args: { - holdings: undefined, hasPermissionToOpenDetails: false, hasPermissionToShowQuantities: true, hasPermissionToShowValues: true, + holdings: undefined, locale: 'en-US', pageSize: Number.MAX_SAFE_INTEGER } diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.ts b/libs/ui/src/lib/holdings-table/holdings-table.component.ts index ddf6a2f224..eac1c9d918 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.ts +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.ts @@ -1,7 +1,9 @@ import { canOpenHoldingDetail, + getCountryCodeFromCurrency, getLocale, - getLowercase + getLowercase, + isCashPosition } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, @@ -120,6 +122,14 @@ export class GfHoldingsTableComponent { return this.hasPermissionToOpenDetails() && canOpenHoldingDetail(holding); } + protected getCountryCodeForCashPosition({ + assetProfile + }: PortfolioPosition): string { + return isCashPosition(assetProfile) + ? getCountryCodeFromCurrency(assetProfile.currency) + : ''; + } + protected onOpenHoldingDialog({ dataSource, symbol diff --git a/libs/ui/src/lib/mocks/holdings.ts b/libs/ui/src/lib/mocks/holdings.ts index 11f3bec0e8..0acb52e5f9 100644 --- a/libs/ui/src/lib/mocks/holdings.ts +++ b/libs/ui/src/lib/mocks/holdings.ts @@ -3,7 +3,7 @@ import { PortfolioPosition } from '@ghostfolio/common/interfaces'; export const holdings: PortfolioPosition[] = [ { activitiesCount: 1, - allocationInPercentage: 0.042990776363386086, + allocationInPercentage: 0.06447799495516787, assetProfile: { assetClass: 'EQUITY', assetClassLabel: 'Equity', @@ -48,7 +48,7 @@ export const holdings: PortfolioPosition[] = [ }, { activitiesCount: 2, - allocationInPercentage: 0.02377401948293552, + allocationInPercentage: 0.03565651141834935, assetProfile: { assetClass: 'EQUITY', assetClassLabel: 'Equity', @@ -93,7 +93,7 @@ export const holdings: PortfolioPosition[] = [ }, { activitiesCount: 1, - allocationInPercentage: 0.08038536990007467, + allocationInPercentage: 0.12056277911976934, assetProfile: { assetClass: 'EQUITY', assetClassLabel: 'Equity', @@ -138,7 +138,7 @@ export const holdings: PortfolioPosition[] = [ }, { activitiesCount: 1, - allocationInPercentage: 0.19216416482928922, + allocationInPercentage: 0.28820973005222217, assetProfile: { assetClass: 'LIQUIDITY', assetClassLabel: 'Liquidity', @@ -171,7 +171,7 @@ export const holdings: PortfolioPosition[] = [ }, { activitiesCount: 1, - allocationInPercentage: 0.04307127421937313, + allocationInPercentage: 0.06459872644204223, assetProfile: { assetClass: 'EQUITY', assetClassLabel: 'Equity', @@ -216,7 +216,7 @@ export const holdings: PortfolioPosition[] = [ }, { activitiesCount: 1, - allocationInPercentage: 0.18762679306394897, + allocationInPercentage: 0.28140453464652826, assetProfile: { assetClass: 'EQUITY', assetClassLabel: 'Equity', @@ -261,7 +261,7 @@ export const holdings: PortfolioPosition[] = [ }, { activitiesCount: 5, - allocationInPercentage: 0.053051250766657634, + allocationInPercentage: 0.0795667947557967, assetProfile: { assetClass: 'EQUITY', assetClassLabel: 'Equity', @@ -303,5 +303,37 @@ export const holdings: PortfolioPosition[] = [ quantity: 50, tags: [], valueInBaseCurrency: 15092 + }, + { + activitiesCount: 3, + allocationInPercentage: 0.06552292861012407, + assetProfile: { + assetClass: 'LIQUIDITY', + assetClassLabel: 'Liquidity', + assetSubClass: 'CASH', + assetSubClassLabel: 'Cash', + countries: [], + currency: 'USD', + dataSource: 'YAHOO', + holdings: [], + name: 'USD', + sectors: [], + symbol: 'USD' + }, + dateOfFirstActivity: new Date('2024-01-01T00:00:00.000Z'), + dividend: 0, + grossPerformance: 0, + grossPerformancePercent: 0, + grossPerformancePercentWithCurrencyEffect: 0, + grossPerformanceWithCurrencyEffect: 0, + investment: 12428.2, + marketPrice: 1, + netPerformance: 0, + netPerformancePercent: 0, + netPerformancePercentWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + quantity: 12428.2, + tags: [], + valueInBaseCurrency: 12428.2 } ];