Browse Source

Feature/support country codes in entity logo component (#7884)

* Add support for country codes to entity logo component

* Update changelog
pull/7888/head
Thomas Kaul 1 week ago
committed by GitHub
parent
commit
e7f57d6dc0
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 20
      libs/common/src/lib/helper.spec.ts
  3. 17
      libs/common/src/lib/helper.ts
  4. 5
      libs/ui/src/lib/account-selector/account-selector.component.html
  5. 13
      libs/ui/src/lib/currency-selector/currency-selector.component.html
  6. 15
      libs/ui/src/lib/currency-selector/currency-selector.component.ts
  7. 17
      libs/ui/src/lib/entity-logo/entity-logo.component.html
  8. 12
      libs/ui/src/lib/entity-logo/entity-logo.component.scss
  9. 8
      libs/ui/src/lib/entity-logo/entity-logo.component.stories.ts
  10. 12
      libs/ui/src/lib/entity-logo/entity-logo.component.ts
  11. 1
      libs/ui/src/lib/holdings-table/holdings-table.component.html
  12. 2
      libs/ui/src/lib/holdings-table/holdings-table.component.stories.ts
  13. 12
      libs/ui/src/lib/holdings-table/holdings-table.component.ts
  14. 46
      libs/ui/src/lib/mocks/holdings.ts

1
CHANGELOG.md

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added a hint for the performance calculation type to the analysis page - 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 ### Changed

20
libs/common/src/lib/helper.spec.ts

@ -6,6 +6,7 @@ import {
canApplyFiltersToAccess, canApplyFiltersToAccess,
extractNumberFromString, extractNumberFromString,
getCountryCodeFromCurrency, getCountryCodeFromCurrency,
getEmojiFlag,
getNumberFormatGroup, getNumberFormatGroup,
getStringOrNull, getStringOrNull,
getStringOrUndefined, 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', () => { describe('Get number format group', () => {
let languageGetter: jest.SpyInstance<string, [], any>; let languageGetter: jest.SpyInstance<string, [], any>;

17
libs/common/src/lib/helper.ts

@ -11,6 +11,7 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isISO4217CurrencyCode, isUUID } from 'class-validator'; import { isISO4217CurrencyCode, isUUID } from 'class-validator';
import { countries } from 'countries-list';
import { import {
addDays, addDays,
getDate, getDate,
@ -88,6 +89,8 @@ const USER_SETTINGS_KEYS_OF_AUTHENTICATED_USER: (keyof UserSettings)[] = [
'viewMode' 'viewMode'
]; ];
const VALID_COUNTRY_CODES = new Set([...Object.keys(countries), 'EU']);
export function applyAssetProfileOverrides<T extends Partial<SymbolProfile>>( export function applyAssetProfileOverrides<T extends Partial<SymbolProfile>>(
assetProfile: T, assetProfile: T,
assetProfileOverrides: AssetProfileOverrides | null assetProfileOverrides: AssetProfileOverrides | null
@ -439,15 +442,15 @@ export function getDateWithTimeFormatString(aLocale?: string) {
} }
export function getEmojiFlag(aCountryCode: string) { export function getEmojiFlag(aCountryCode: string) {
if (!aCountryCode) { const countryCode = aCountryCode?.toUpperCase();
return aCountryCode;
if (!countryCode || !VALID_COUNTRY_CODES.has(countryCode)) {
return undefined;
} }
return aCountryCode return countryCode.replace(/./g, (character) => {
.toUpperCase() return String.fromCodePoint(127397 + character.charCodeAt(0));
.replace(/./g, (character) => });
String.fromCodePoint(127397 + character.charCodeAt(0))
);
} }
export function getLocale() { export function getLocale() {

5
libs/ui/src/lib/account-selector/account-selector.component.html

@ -11,11 +11,10 @@
(closed)="onPanelClosed()" (closed)="onPanelClosed()"
> >
<mat-select-trigger> <mat-select-trigger>
<div class="d-flex"> <div class="align-items-center align-middle d-inline-flex line-height-1">
@if (selectedAccount()) { @if (selectedAccount()?.platform?.url) {
<gf-entity-logo <gf-entity-logo
class="mr-1" class="mr-1"
[hasPlaceholder]="true"
[tooltip]="selectedAccount()?.platform?.name ?? ''" [tooltip]="selectedAccount()?.platform?.name ?? ''"
[url]="selectedAccount()?.platform?.url ?? ''" [url]="selectedAccount()?.platform?.url ?? ''"
/> />

13
libs/ui/src/lib/currency-selector/currency-selector.component.html

@ -1,5 +1,8 @@
@if (emojiFlagOfSelectedCurrency) { @if (countryCodeOfSelectedCurrency) {
<span class="flex-shrink-0 mr-1">{{ emojiFlagOfSelectedCurrency }}</span> <gf-entity-logo
class="flex-shrink-0 mr-1"
[countryCode]="countryCodeOfSelectedCurrency"
/>
} }
<input <input
@ -19,7 +22,11 @@
@for (currency of filteredCurrencies; track currency) { @for (currency of filteredCurrencies; track currency) {
<mat-option class="line-height-1" [value]="currency"> <mat-option class="line-height-1" [value]="currency">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<span class="mr-1">{{ getEmojiFlagFromCurrency(currency) }}</span> <gf-entity-logo
class="mr-1"
[countryCode]="getCountryCodeFromCurrency(currency)"
[hasPlaceholder]="true"
/>
<span>{{ currency }}</span> <span>{{ currency }}</span>
</span> </span>
</mat-option> </mat-option>

15
libs/ui/src/lib/currency-selector/currency-selector.component.ts

@ -1,7 +1,4 @@
import { import { getCountryCodeFromCurrency } from '@ghostfolio/common/helper';
getCountryCodeFromCurrency,
getEmojiFlag
} from '@ghostfolio/common/helper';
import { FocusMonitor } from '@angular/cdk/a11y'; import { FocusMonitor } from '@angular/cdk/a11y';
import { import {
@ -40,6 +37,7 @@ import {
import { MatInput, MatInputModule } from '@angular/material/input'; import { MatInput, MatInputModule } from '@angular/material/input';
import { map, startWith } from 'rxjs/operators'; import { map, startWith } from 'rxjs/operators';
import { GfEntityLogoComponent } from '../entity-logo/entity-logo.component';
import { AbstractMatFormField } from '../shared/abstract-mat-form-field'; import { AbstractMatFormField } from '../shared/abstract-mat-form-field';
@Component({ @Component({
@ -51,6 +49,7 @@ import { AbstractMatFormField } from '../shared/abstract-mat-form-field';
}, },
imports: [ imports: [
FormsModule, FormsModule,
GfEntityLogoComponent,
MatAutocompleteModule, MatAutocompleteModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
@ -99,12 +98,12 @@ export class GfCurrencySelectorComponent
return { elementRef: this.formField.getConnectedOverlayOrigin() }; return { elementRef: this.formField.getConnectedOverlayOrigin() };
} }
public get emojiFlagOfSelectedCurrency() { public get countryCodeOfSelectedCurrency() {
const selectedCurrency = this.currencies().find((currency) => { const selectedCurrency = this.currencies().find((currency) => {
return currency === this.control.value; return currency === this.control.value;
}); });
return this.getEmojiFlagFromCurrency(selectedCurrency); return getCountryCodeFromCurrency(selectedCurrency);
} }
public override get empty() { public override get empty() {
@ -126,8 +125,8 @@ export class GfCurrencySelectorComponent
this.input().focus(); this.input().focus();
} }
public getEmojiFlagFromCurrency(aCurrency = '') { public getCountryCodeFromCurrency(aCurrency = '') {
return getEmojiFlag(getCountryCodeFromCurrency(aCurrency)); return getCountryCodeFromCurrency(aCurrency);
} }
public ngOnInit() { public ngOnInit() {

17
libs/ui/src/lib/entity-logo/entity-logo.component.html

@ -1,4 +1,19 @@
@if (src && !hasError) { @if (emojiFlag) {
<span
class="cursor-default d-block logo overflow-hidden position-relative"
role="img"
[attr.aria-label]="tooltip ? tooltip : countryCode"
[class.large]="size === 'large'"
[title]="tooltip ? tooltip : ''"
>
<span
aria-hidden="true"
class="align-items-center d-flex emoji justify-content-center line-height-1 position-absolute"
>
{{ emojiFlag }}
</span>
</span>
} @else if (src && !hasError) {
<img <img
class="logo" class="logo"
[class.large]="size === 'large'" [class.large]="size === 'large'"

12
libs/ui/src/lib/entity-logo/entity-logo.component.scss

@ -1,7 +1,4 @@
:host { :host {
align-items: center;
display: flex;
.logo { .logo {
border-radius: 0.2rem; border-radius: 0.2rem;
height: 0.8rem; height: 0.8rem;
@ -10,6 +7,15 @@
&.large { &.large {
height: 1.4rem; height: 1.4rem;
width: 1.4rem; width: 1.4rem;
.emoji {
font-size: 1.9rem;
}
}
.emoji {
font-size: 1.1rem;
inset: 0;
} }
} }
} }

8
libs/ui/src/lib/entity-logo/entity-logo.component.stories.ts

@ -28,6 +28,14 @@ export default {
type Story = StoryObj<GfEntityLogoComponent>; type Story = StoryObj<GfEntityLogoComponent>;
export const FlagByCountryCode: Story = {
args: {
countryCode: 'US',
size: 'large',
tooltip: 'United States'
}
};
export const LogoByAssetProfileIdentifier: Story = { export const LogoByAssetProfileIdentifier: Story = {
args: { args: {
dataSource: 'YAHOO', dataSource: 'YAHOO',

12
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 { EntityLogoImageSourceService } from '@ghostfolio/ui/entity-logo/entity-logo-image-source.service';
import { import {
@ -11,12 +12,14 @@ import { DataSource } from '@prisma/client';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'align-items-center d-flex' },
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-entity-logo', selector: 'gf-entity-logo',
styleUrls: ['./entity-logo.component.scss'], styleUrls: ['./entity-logo.component.scss'],
templateUrl: './entity-logo.component.html' templateUrl: './entity-logo.component.html'
}) })
export class GfEntityLogoComponent implements OnChanges { export class GfEntityLogoComponent implements OnChanges {
@Input() countryCode: string;
@Input() dataSource: DataSource; @Input() dataSource: DataSource;
@Input() hasPlaceholder = false; @Input() hasPlaceholder = false;
@Input() size: 'large'; @Input() size: 'large';
@ -24,6 +27,7 @@ export class GfEntityLogoComponent implements OnChanges {
@Input() tooltip: string; @Input() tooltip: string;
@Input() url: string; @Input() url: string;
public emojiFlag?: string;
public hasError = false; public hasError = false;
public src?: string; public src?: string;
@ -32,17 +36,19 @@ export class GfEntityLogoComponent implements OnChanges {
) {} ) {}
public ngOnChanges() { public ngOnChanges() {
this.emojiFlag = undefined;
this.hasError = false; 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({ this.src = this.imageSourceService.getLogoUrlByAssetProfileIdentifier({
dataSource: this.dataSource, dataSource: this.dataSource,
symbol: this.symbol symbol: this.symbol
}); });
} else if (this.url) { } else if (this.url) {
this.src = this.imageSourceService.getLogoUrlByUrl(this.url); this.src = this.imageSourceService.getLogoUrlByUrl(this.url);
} else {
this.src = undefined;
} }
} }

1
libs/ui/src/lib/holdings-table/holdings-table.component.html

@ -17,6 +17,7 @@
[class.text-nowrap]="mode() === 'simple'" [class.text-nowrap]="mode() === 'simple'"
> >
<gf-entity-logo <gf-entity-logo
[countryCode]="getCountryCodeForCashPosition(element)"
[dataSource]="element.assetProfile.dataSource" [dataSource]="element.assetProfile.dataSource"
[symbol]="element.assetProfile.symbol" [symbol]="element.assetProfile.symbol"
[tooltip]="element.assetProfile.name" [tooltip]="element.assetProfile.name"

2
libs/ui/src/lib/holdings-table/holdings-table.component.stories.ts

@ -45,10 +45,10 @@ type Story = StoryObj<GfHoldingsTableComponent>;
export const Loading: Story = { export const Loading: Story = {
args: { args: {
holdings: undefined,
hasPermissionToOpenDetails: false, hasPermissionToOpenDetails: false,
hasPermissionToShowQuantities: true, hasPermissionToShowQuantities: true,
hasPermissionToShowValues: true, hasPermissionToShowValues: true,
holdings: undefined,
locale: 'en-US', locale: 'en-US',
pageSize: Number.MAX_SAFE_INTEGER pageSize: Number.MAX_SAFE_INTEGER
} }

12
libs/ui/src/lib/holdings-table/holdings-table.component.ts

@ -1,7 +1,9 @@
import { import {
canOpenHoldingDetail, canOpenHoldingDetail,
getCountryCodeFromCurrency,
getLocale, getLocale,
getLowercase getLowercase,
isCashPosition
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
@ -120,6 +122,14 @@ export class GfHoldingsTableComponent {
return this.hasPermissionToOpenDetails() && canOpenHoldingDetail(holding); return this.hasPermissionToOpenDetails() && canOpenHoldingDetail(holding);
} }
protected getCountryCodeForCashPosition({
assetProfile
}: PortfolioPosition): string {
return isCashPosition(assetProfile)
? getCountryCodeFromCurrency(assetProfile.currency)
: '';
}
protected onOpenHoldingDialog({ protected onOpenHoldingDialog({
dataSource, dataSource,
symbol symbol

46
libs/ui/src/lib/mocks/holdings.ts

@ -3,7 +3,7 @@ import { PortfolioPosition } from '@ghostfolio/common/interfaces';
export const holdings: PortfolioPosition[] = [ export const holdings: PortfolioPosition[] = [
{ {
activitiesCount: 1, activitiesCount: 1,
allocationInPercentage: 0.042990776363386086, allocationInPercentage: 0.06447799495516787,
assetProfile: { assetProfile: {
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetClassLabel: 'Equity', assetClassLabel: 'Equity',
@ -48,7 +48,7 @@ export const holdings: PortfolioPosition[] = [
}, },
{ {
activitiesCount: 2, activitiesCount: 2,
allocationInPercentage: 0.02377401948293552, allocationInPercentage: 0.03565651141834935,
assetProfile: { assetProfile: {
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetClassLabel: 'Equity', assetClassLabel: 'Equity',
@ -93,7 +93,7 @@ export const holdings: PortfolioPosition[] = [
}, },
{ {
activitiesCount: 1, activitiesCount: 1,
allocationInPercentage: 0.08038536990007467, allocationInPercentage: 0.12056277911976934,
assetProfile: { assetProfile: {
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetClassLabel: 'Equity', assetClassLabel: 'Equity',
@ -138,7 +138,7 @@ export const holdings: PortfolioPosition[] = [
}, },
{ {
activitiesCount: 1, activitiesCount: 1,
allocationInPercentage: 0.19216416482928922, allocationInPercentage: 0.28820973005222217,
assetProfile: { assetProfile: {
assetClass: 'LIQUIDITY', assetClass: 'LIQUIDITY',
assetClassLabel: 'Liquidity', assetClassLabel: 'Liquidity',
@ -171,7 +171,7 @@ export const holdings: PortfolioPosition[] = [
}, },
{ {
activitiesCount: 1, activitiesCount: 1,
allocationInPercentage: 0.04307127421937313, allocationInPercentage: 0.06459872644204223,
assetProfile: { assetProfile: {
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetClassLabel: 'Equity', assetClassLabel: 'Equity',
@ -216,7 +216,7 @@ export const holdings: PortfolioPosition[] = [
}, },
{ {
activitiesCount: 1, activitiesCount: 1,
allocationInPercentage: 0.18762679306394897, allocationInPercentage: 0.28140453464652826,
assetProfile: { assetProfile: {
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetClassLabel: 'Equity', assetClassLabel: 'Equity',
@ -261,7 +261,7 @@ export const holdings: PortfolioPosition[] = [
}, },
{ {
activitiesCount: 5, activitiesCount: 5,
allocationInPercentage: 0.053051250766657634, allocationInPercentage: 0.0795667947557967,
assetProfile: { assetProfile: {
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetClassLabel: 'Equity', assetClassLabel: 'Equity',
@ -303,5 +303,37 @@ export const holdings: PortfolioPosition[] = [
quantity: 50, quantity: 50,
tags: [], tags: [],
valueInBaseCurrency: 15092 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
} }
]; ];

Loading…
Cancel
Save