From 06b7d85c7cd156063a8818300f4588f3f2e4b15a Mon Sep 17 00:00:00 2001 From: aaryamantriescode Date: Sun, 19 Jul 2026 17:51:03 +0530 Subject: [PATCH] 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' })