From 06b7d85c7cd156063a8818300f4588f3f2e4b15a Mon Sep 17 00:00:00 2001 From: aaryamantriescode Date: Sun, 19 Jul 2026 17:51:03 +0530 Subject: [PATCH 1/2] fix(client): correctly parse localized decimal numbers --- .../localized-number.directive.spec.ts | 112 ++++++++++++++++++ .../localized-number.directive.ts | 87 ++++++++++++++ ...ate-or-update-activity-dialog.component.ts | 5 +- .../create-or-update-activity-dialog.html | 6 +- libs/common/src/lib/helper.spec.ts | 21 ++++ 5 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 apps/client/src/app/directives/localized-number/localized-number.directive.spec.ts create mode 100644 apps/client/src/app/directives/localized-number/localized-number.directive.ts diff --git a/apps/client/src/app/directives/localized-number/localized-number.directive.spec.ts b/apps/client/src/app/directives/localized-number/localized-number.directive.spec.ts new file mode 100644 index 000000000..181398e9e --- /dev/null +++ b/apps/client/src/app/directives/localized-number/localized-number.directive.spec.ts @@ -0,0 +1,112 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; + +import { GfLocalizedNumberDirective } from './localized-number.directive'; + +@Component({ + imports: [GfLocalizedNumberDirective, ReactiveFormsModule], + template: ` + + ` +}) +class TestHostComponent { + public control = new FormControl(null); + public locale = 'en-US'; +} + +describe('GfLocalizedNumberDirective', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let input: HTMLInputElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestHostComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + input = fixture.nativeElement.querySelector('input'); + }); + + function typeValue(value: string) { + input.value = value; + input.dispatchEvent(new Event('input')); + fixture.detectChanges(); + } + + it('should force type="text" and inputmode="decimal"', () => { + expect(input.getAttribute('type')).toBe('text'); + expect(input.getAttribute('inputmode')).toBe('decimal'); + }); + + it('should parse English grouped numbers', () => { + host.locale = 'en-US'; + fixture.detectChanges(); + + typeValue('1,234.50'); + expect(host.control.value).toBe(1234.5); + + typeValue('1234.50'); + expect(host.control.value).toBe(1234.5); + }); + + it('should parse German grouped numbers', () => { + host.locale = 'de-DE'; + fixture.detectChanges(); + + typeValue('1.234,50'); + expect(host.control.value).toBe(1234.5); + + typeValue('1234,50'); + expect(host.control.value).toBe(1234.5); + + typeValue('12.345.678,90'); + expect(host.control.value).toBe(12345678.9); + }); + + it('should set null for empty or invalid input', () => { + typeValue(''); + expect(host.control.value).toBeNull(); + + typeValue(' '); + expect(host.control.value).toBeNull(); + + typeValue('abc'); + expect(host.control.value).toBeNull(); + }); + + it('should write programmatic values to the input', () => { + host.control.setValue(1234.5); + fixture.detectChanges(); + + expect(input.value).toBe('1234.5'); + + host.control.setValue(null); + fixture.detectChanges(); + + expect(input.value).toBe(''); + }); + + it('should keep required validation working', () => { + host.locale = 'de-DE'; + host.control.setValidators([ + (control) => { + return control.value === null || control.value === undefined + ? { required: true } + : null; + } + ]); + host.control.updateValueAndValidity(); + fixture.detectChanges(); + + typeValue(''); + expect(host.control.hasError('required')).toBe(true); + + typeValue('1.234,50'); + expect(host.control.hasError('required')).toBe(false); + expect(host.control.value).toBe(1234.5); + }); +}); diff --git a/apps/client/src/app/directives/localized-number/localized-number.directive.ts b/apps/client/src/app/directives/localized-number/localized-number.directive.ts new file mode 100644 index 000000000..cf8a80507 --- /dev/null +++ b/apps/client/src/app/directives/localized-number/localized-number.directive.ts @@ -0,0 +1,87 @@ +import { DEFAULT_LOCALE } from '@ghostfolio/common/config'; +import { extractNumberFromString } from '@ghostfolio/common/helper'; + +import { DOCUMENT } from '@angular/common'; +import { Directive, ElementRef, inject, input } from '@angular/core'; +import { ControlValueAccessor, NgControl } from '@angular/forms'; + +@Directive({ + host: { + '(blur)': 'handleBlur()', + '(input)': 'handleInput()', + '[attr.inputmode]': '"decimal"', + '[attr.type]': '"text"' + }, + selector: 'input[gfLocalizedNumber]' +}) +export class GfLocalizedNumberDirective implements ControlValueAccessor { + public readonly locale = input(); + + private readonly document = inject(DOCUMENT); + private readonly elementRef = + inject>(ElementRef); + + public constructor() { + const ngControl = inject(NgControl, { optional: true, self: true }); + + if (ngControl) { + // Replace DefaultValueAccessor so the FormControl stores a number + ngControl.valueAccessor = this; + } + } + + public handleBlur() { + this.onTouched(); + } + + public handleInput() { + const value = this.elementRef.nativeElement.value; + + if (!value?.trim()) { + this.onChange(null); + return; + } + + // Locale resolution priority: + // 1. explicit [locale] input from the template + // 2. document.documentElement.lang — set by Angular i18n to the active + // language (e.g. 'de' when the app runs under /de/) + // 3. DEFAULT_LOCALE ('en-US') as the final fallback + const localeInput = this.locale(); + const documentLang = this.document.documentElement.lang; + const resolvedLocale = localeInput ?? documentLang ?? DEFAULT_LOCALE; + + const parsedNumber = extractNumberFromString({ + locale: resolvedLocale, + value + }); + + this.onChange( + parsedNumber !== undefined && !Number.isNaN(parsedNumber) + ? parsedNumber + : null + ); + } + + public registerOnChange(fn: (value: number | null) => void) { + this.onChange = fn; + } + + public registerOnTouched(fn: () => void) { + this.onTouched = fn; + } + + public setDisabledState(isDisabled: boolean) { + this.elementRef.nativeElement.disabled = isDisabled; + } + + public writeValue(value: number | null) { + this.elementRef.nativeElement.value = + value === null || value === undefined || Number.isNaN(value) + ? '' + : String(value); + } + + private onChange: (value: number | null) => void = () => undefined; + private onTouched: () => void = () => undefined; +} diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts index 632db1cd4..bfb87177d 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts @@ -1,3 +1,4 @@ +import { GfLocalizedNumberDirective } from '@ghostfolio/client/directives/localized-number/localized-number.directive'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { ASSET_CLASS_MAPPING, DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; @@ -57,6 +58,7 @@ import { ActivityType } from './types/activity-type.type'; host: { class: 'h-100' }, imports: [ GfEntityLogoComponent, + GfLocalizedNumberDirective, GfSymbolAutocompleteComponent, GfTagsSelectorComponent, GfValueComponent, @@ -124,7 +126,8 @@ export class GfCreateOrUpdateActivityDialogComponent { this.data.user?.permissions, permissions.createOwnTag ); - this.locale = this.data.user.settings.locale ?? DEFAULT_LOCALE; + this.locale = + this.data.user.settings.locale ?? this.locale ?? DEFAULT_LOCALE; this.mode = this.data.activity?.id ? 'update' : 'create'; this.dateAdapter.setLocale(this.locale); diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html index b5dbf4669..81855a1e7 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html @@ -181,7 +181,7 @@ > Quantity - +
- +
Fee - +
{ ).toEqual(-99999.99); }); + it('Get decimal number with thousands separator (de-DE)', () => { + expect( + extractNumberFromString({ locale: 'de-DE', value: '1.234,50' }) + ).toEqual(1234.5); + expect( + extractNumberFromString({ locale: 'de-DE', value: '1234,50' }) + ).toEqual(1234.5); + expect( + extractNumberFromString({ locale: 'de-DE', value: '12.345.678,90' }) + ).toEqual(12345678.9); + }); + + it('Get decimal number with thousands separator (en-US)', () => { + expect( + extractNumberFromString({ locale: 'en-US', value: '1,234.50' }) + ).toEqual(1234.5); + expect( + extractNumberFromString({ locale: 'en-US', value: '1234.50' }) + ).toEqual(1234.5); + }); + it('Get decimal number (comma notation) for locale where currency is not grouped by default', () => { expect( extractNumberFromString({ locale: 'es-ES', value: '999,99' }) From ac11fd22bfbe1ed18e6150a82b5a6f3ec75cc26c Mon Sep 17 00:00:00 2001 From: aaryamantriescode Date: Tue, 28 Jul 2026 00:47:32 +0530 Subject: [PATCH 2/2] fix(client): address review feedback for localized number directive --- CHANGELOG.md | 4 ++ .../asset-profile-dialog.component.ts | 2 + .../asset-profile-dialog.html | 3 +- .../pages/accounts/accounts-page.component.ts | 3 +- ...eate-or-update-account-dialog.component.ts | 18 +++++- .../create-or-update-account-dialog.html | 3 +- .../transfer-balance/interfaces/interfaces.ts | 1 + .../transfer-balance-dialog.component.ts | 10 +++- .../transfer-balance-dialog.html | 3 +- ...ate-or-update-activity-dialog.component.ts | 4 +- .../create-or-update-activity-dialog.html | 21 ++++++- libs/common/src/lib/helper.ts | 17 +++++- .../account-balances.component.html | 7 ++- .../account-balances.component.ts | 2 + ...cal-market-data-editor-dialog.component.ts | 4 +- .../historical-market-data-editor-dialog.html | 3 +- libs/ui/src/lib/localized-number/index.ts | 1 + .../localized-number.directive.spec.ts | 37 +++++++++++- .../localized-number.directive.ts | 57 +++++++++++++------ 19 files changed, 165 insertions(+), 35 deletions(-) create mode 100644 libs/ui/src/lib/localized-number/index.ts rename {apps/client/src/app/directives => libs/ui/src/lib}/localized-number/localized-number.directive.spec.ts (77%) rename {apps/client/src/app/directives => libs/ui/src/lib}/localized-number/localized-number.directive.ts (62%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9388953a..8fe765bbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,7 +78,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Restricted the symbol data endpoint (`GET /api/v1/symbol/:dataSource/:symbol`) to authenticated users - Removed the deprecated `auth` endpoint of the login with _Security Token_ (`GET`) - Simplified the `getHistorical()` function response in the data provider interface +<<<<<<< HEAD - Upgraded `bull-board` from version `8.0.1` to `8.1.2` +======= +- Fixed parsing of localized decimal numbers with thousands separators across number input fields +>>>>>>> c5ab9c84a (fix(client): address review feedback for localized number directive) ## 3.29.0 - 2026-07-18 diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts index c5562740f..0b0fdb93b 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts @@ -28,6 +28,7 @@ import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor'; import { translate } from '@ghostfolio/ui/i18n'; import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; +import { GfLocalizedNumberDirective } from '@ghostfolio/ui/localized-number'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; import { AdminService, DataService } from '@ghostfolio/ui/services'; @@ -108,6 +109,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces'; GfEntityLogoComponent, GfHistoricalMarketDataEditorComponent, GfLineChartComponent, + GfLocalizedNumberDirective, GfPortfolioProportionChartComponent, GfSymbolAutocompleteComponent, GfValueComponent, diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index c9abdeeb7..e13c253c1 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html @@ -519,8 +519,9 @@ Default Market Price
diff --git a/apps/client/src/app/pages/accounts/accounts-page.component.ts b/apps/client/src/app/pages/accounts/accounts-page.component.ts index 1cf0e44a7..11c546596 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.component.ts +++ b/apps/client/src/app/pages/accounts/accounts-page.component.ts @@ -331,7 +331,8 @@ export class GfAccountsPageComponent implements OnInit { TransferBalanceDialogParams >(GfTransferBalanceDialogComponent, { data: { - accounts: this.accounts + accounts: this.accounts, + locale: this.user?.settings?.locale }, width: this.deviceType() === 'mobile' ? '100vw' : '50rem' }); diff --git a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts index de0172eac..5e196e955 100644 --- a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts +++ b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts @@ -1,11 +1,15 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; +import { + DEFAULT_LOCALE, + TAG_ID_EXCLUDE_FROM_ANALYSIS +} from '@ghostfolio/common/config'; import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { validateObjectForForm } from '@ghostfolio/common/utils'; import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { translate } from '@ghostfolio/ui/i18n'; +import { GfLocalizedNumberDirective } from '@ghostfolio/ui/localized-number'; import { DataService } from '@ghostfolio/ui/services'; import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector'; @@ -28,6 +32,7 @@ import { import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MAT_DATE_LOCALE } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialogModule, @@ -48,6 +53,7 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces'; CommonModule, GfCurrencySelectorComponent, GfEntityLogoComponent, + GfLocalizedNumberDirective, GfTagsSelectorComponent, MatAutocompleteModule, MatButtonModule, @@ -77,15 +83,25 @@ export class GfCreateOrUpdateAccountDialogComponent { inject>(MatDialogRef); private readonly formBuilder = inject(FormBuilder); private readonly userService = inject(UserService); + protected locale = inject(MAT_DATE_LOCALE); public ngOnInit() { const { currencies } = this.dataService.fetchInfo(); this.currencies = currencies; +<<<<<<< HEAD this.hasPermissionToCreateOwnTag = hasPermission( this.data.user?.permissions, permissions.createOwnTag ); +======= + this.locale = + this.data.user?.settings?.locale ?? this.locale ?? DEFAULT_LOCALE; + + this.hasPermissionToCreateOwnTag = + this.data.user?.settings?.isExperimentalFeatures && + hasPermission(this.data.user?.permissions, permissions.createOwnTag); +>>>>>>> c5ab9c84a (fix(client): address review feedback for localized number directive) this.tagsAvailable = [ ...(this.data.user?.tags ?? []), diff --git a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html index c707a9402..e0bc19f9c 100644 --- a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html +++ b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -34,8 +34,9 @@ Cash Balance {{ diff --git a/apps/client/src/app/pages/accounts/transfer-balance/interfaces/interfaces.ts b/apps/client/src/app/pages/accounts/transfer-balance/interfaces/interfaces.ts index 51c42bc5d..84d61e580 100644 --- a/apps/client/src/app/pages/accounts/transfer-balance/interfaces/interfaces.ts +++ b/apps/client/src/app/pages/accounts/transfer-balance/interfaces/interfaces.ts @@ -3,6 +3,7 @@ import { Account } from '@prisma/client'; export interface TransferBalanceDialogParams { accounts: Account[]; + locale?: string; } export type TransferBalanceForm = FormGroup<{ diff --git a/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts b/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts index cbf0e460d..b36879563 100644 --- a/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts +++ b/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts @@ -1,5 +1,6 @@ import { TransferBalanceDto } from '@ghostfolio/common/dtos'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; +import { GfLocalizedNumberDirective } from '@ghostfolio/ui/localized-number'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { @@ -10,6 +11,7 @@ import { Validators } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { MAT_DATE_LOCALE } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialogModule, @@ -18,7 +20,6 @@ import { import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; -import { Account } from '@prisma/client'; import { TransferBalanceDialogParams, @@ -30,6 +31,7 @@ import { host: { class: 'h-100' }, imports: [ GfEntityLogoComponent, + GfLocalizedNumberDirective, MatButtonModule, MatDialogModule, MatFormFieldModule, @@ -42,10 +44,12 @@ import { templateUrl: 'transfer-balance-dialog.html' }) export class GfTransferBalanceDialogComponent { - protected readonly accounts: Account[] = - inject(MAT_DIALOG_DATA).accounts; + private readonly data = inject(MAT_DIALOG_DATA); + + protected readonly accounts = this.data.accounts; protected currency: string; + protected locale = this.data.locale ?? inject(MAT_DATE_LOCALE); protected readonly transferBalanceForm: TransferBalanceForm = new FormGroup( { diff --git a/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html b/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html index 50c96be86..24f85a8a0 100644 --- a/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html +++ b/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html @@ -53,8 +53,9 @@ Value {{ currency }} diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts index bfb87177d..0e473e536 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts @@ -1,4 +1,3 @@ -import { GfLocalizedNumberDirective } from '@ghostfolio/client/directives/localized-number/localized-number.directive'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { ASSET_CLASS_MAPPING, DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; @@ -11,6 +10,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { validateObjectForForm } from '@ghostfolio/common/utils'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { translate } from '@ghostfolio/ui/i18n'; +import { GfLocalizedNumberDirective } from '@ghostfolio/ui/localized-number'; import { DataService } from '@ghostfolio/ui/services'; import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete'; import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector'; @@ -113,7 +113,7 @@ export class GfCreateOrUpdateActivityDialogComponent { private readonly dialogRef = inject>(MatDialogRef); private readonly formBuilder = inject(FormBuilder); - private locale = inject(MAT_DATE_LOCALE); + protected locale = inject(MAT_DATE_LOCALE); private readonly userService = inject(UserService); public constructor() { diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html index 81855a1e7..c41642968 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html @@ -181,7 +181,12 @@ > Quantity - +
- +
Fee - +
>>>>>> c5ab9c84a (fix(client): address review feedback for localized number directive) } export function getAllActivityTypes(): ActivityType[] { diff --git a/libs/ui/src/lib/account-balances/account-balances.component.html b/libs/ui/src/lib/account-balances/account-balances.component.html index 780653c67..d972eea69 100644 --- a/libs/ui/src/lib/account-balances/account-balances.component.html +++ b/libs/ui/src/lib/account-balances/account-balances.component.html @@ -51,7 +51,12 @@
- +
{{ accountCurrency() }}
diff --git a/libs/ui/src/lib/account-balances/account-balances.component.ts b/libs/ui/src/lib/account-balances/account-balances.component.ts index e27d29516..2742d9fd4 100644 --- a/libs/ui/src/lib/account-balances/account-balances.component.ts +++ b/libs/ui/src/lib/account-balances/account-balances.component.ts @@ -42,11 +42,13 @@ import { } from 'ionicons/icons'; import { get, isNil } from 'lodash'; +import { GfLocalizedNumberDirective } from '../localized-number'; import { GfValueComponent } from '../value'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, imports: [ + GfLocalizedNumberDirective, GfValueComponent, IonIcon, MatButtonModule, diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts index 1b7b99fa2..525f4a44a 100644 --- a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts @@ -27,6 +27,7 @@ import { addIcons } from 'ionicons'; import { calendarClearOutline, refreshOutline } from 'ionicons/icons'; import { isNil } from 'lodash'; +import { GfLocalizedNumberDirective } from '../../localized-number'; import { HistoricalMarketDataEditorDialogParams } from './interfaces/interfaces'; @Component({ @@ -34,6 +35,7 @@ import { HistoricalMarketDataEditorDialogParams } from './interfaces/interfaces' host: { class: 'h-100' }, imports: [ FormsModule, + GfLocalizedNumberDirective, IonIcon, MatButtonModule, MatDatepickerModule, @@ -54,7 +56,7 @@ export class GfHistoricalMarketDataEditorDialogComponent implements OnInit { protected readonly marketPrice = signal(this.data.marketPrice); private readonly destroyRef = inject(DestroyRef); - private readonly locale = + protected readonly locale = this.data.user.settings.locale ?? inject(MAT_DATE_LOCALE); public constructor( diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html index 7e8183664..858304eda 100644 --- a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html @@ -25,9 +25,10 @@ Market Price diff --git a/libs/ui/src/lib/localized-number/index.ts b/libs/ui/src/lib/localized-number/index.ts new file mode 100644 index 000000000..ca2e9de13 --- /dev/null +++ b/libs/ui/src/lib/localized-number/index.ts @@ -0,0 +1 @@ +export * from './localized-number.directive'; diff --git a/apps/client/src/app/directives/localized-number/localized-number.directive.spec.ts b/libs/ui/src/lib/localized-number/localized-number.directive.spec.ts similarity index 77% rename from apps/client/src/app/directives/localized-number/localized-number.directive.spec.ts rename to libs/ui/src/lib/localized-number/localized-number.directive.spec.ts index 181398e9e..087520a0d 100644 --- a/apps/client/src/app/directives/localized-number/localized-number.directive.spec.ts +++ b/libs/ui/src/lib/localized-number/localized-number.directive.spec.ts @@ -82,7 +82,7 @@ describe('GfLocalizedNumberDirective', () => { host.control.setValue(1234.5); fixture.detectChanges(); - expect(input.value).toBe('1234.5'); + expect(input.value).toBe('1,234.5'); host.control.setValue(null); fixture.detectChanges(); @@ -90,6 +90,41 @@ describe('GfLocalizedNumberDirective', () => { expect(input.value).toBe(''); }); + it('should write and parse correctly for German locale (de-DE)', () => { + host.locale = 'de-DE'; + fixture.detectChanges(); + + host.control.setValue(1234.5); + fixture.detectChanges(); + + expect(input.value).toBe('1.234,5'); + + typeValue('1.234,5'); + + expect(host.control.value).toBe(1234.5); + }); + + it('should preserve more than 3 fraction digits on write/parse', () => { + host.locale = 'de-DE'; + fixture.detectChanges(); + + host.control.setValue(12.345678); + fixture.detectChanges(); + + expect(input.value).toBe('12,345678'); + + typeValue(input.value); + + expect(host.control.value).toBe(12.345678); + }); + + it('should write empty string for non-numeric values', () => { + host.control.setValue('' as unknown as number); + fixture.detectChanges(); + + expect(input.value).toBe(''); + }); + it('should keep required validation working', () => { host.locale = 'de-DE'; host.control.setValidators([ diff --git a/apps/client/src/app/directives/localized-number/localized-number.directive.ts b/libs/ui/src/lib/localized-number/localized-number.directive.ts similarity index 62% rename from apps/client/src/app/directives/localized-number/localized-number.directive.ts rename to libs/ui/src/lib/localized-number/localized-number.directive.ts index cf8a80507..71928c754 100644 --- a/apps/client/src/app/directives/localized-number/localized-number.directive.ts +++ b/libs/ui/src/lib/localized-number/localized-number.directive.ts @@ -1,9 +1,18 @@ import { DEFAULT_LOCALE } from '@ghostfolio/common/config'; -import { extractNumberFromString } from '@ghostfolio/common/helper'; +import { + extractNumberFromString, + formatNumberForLocale +} from '@ghostfolio/common/helper'; import { DOCUMENT } from '@angular/common'; -import { Directive, ElementRef, inject, input } from '@angular/core'; -import { ControlValueAccessor, NgControl } from '@angular/forms'; +import { + Directive, + ElementRef, + forwardRef, + inject, + input +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; @Directive({ host: { @@ -12,6 +21,13 @@ import { ControlValueAccessor, NgControl } from '@angular/forms'; '[attr.inputmode]': '"decimal"', '[attr.type]': '"text"' }, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => GfLocalizedNumberDirective), + multi: true + } + ], selector: 'input[gfLocalizedNumber]' }) export class GfLocalizedNumberDirective implements ControlValueAccessor { @@ -21,15 +37,6 @@ export class GfLocalizedNumberDirective implements ControlValueAccessor { private readonly elementRef = inject>(ElementRef); - public constructor() { - const ngControl = inject(NgControl, { optional: true, self: true }); - - if (ngControl) { - // Replace DefaultValueAccessor so the FormControl stores a number - ngControl.valueAccessor = this; - } - } - public handleBlur() { this.onTouched(); } @@ -49,7 +56,8 @@ export class GfLocalizedNumberDirective implements ControlValueAccessor { // 3. DEFAULT_LOCALE ('en-US') as the final fallback const localeInput = this.locale(); const documentLang = this.document.documentElement.lang; - const resolvedLocale = localeInput ?? documentLang ?? DEFAULT_LOCALE; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const resolvedLocale = localeInput || documentLang || DEFAULT_LOCALE; const parsedNumber = extractNumberFromString({ locale: resolvedLocale, @@ -76,10 +84,25 @@ export class GfLocalizedNumberDirective implements ControlValueAccessor { } public writeValue(value: number | null) { - this.elementRef.nativeElement.value = - value === null || value === undefined || Number.isNaN(value) - ? '' - : String(value); + if ( + value === null || + value === undefined || + typeof value !== 'number' || + Number.isNaN(value) + ) { + this.elementRef.nativeElement.value = ''; + return; + } + + const localeInput = this.locale(); + const documentLang = this.document.documentElement.lang; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const resolvedLocale = localeInput || documentLang || DEFAULT_LOCALE; + + this.elementRef.nativeElement.value = formatNumberForLocale({ + locale: resolvedLocale, + value + }); } private onChange: (value: number | null) => void = () => undefined;