{
).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' })
diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts
index 22f969d02..98331a88a 100644
--- a/libs/common/src/lib/helper.ts
+++ b/libs/common/src/lib/helper.ts
@@ -1,4 +1,4 @@
-import { NumberParser } from '@internationalized/number';
+import { NumberFormatter, NumberParser } from '@internationalized/number';
import {
Type as ActivityType,
AssetProfileOverrides,
@@ -234,6 +234,7 @@ export function extractNumberFromString({
}
}
+<<<<<<< HEAD
export function formatMonthAndYear({
date,
locale
@@ -245,6 +246,20 @@ export function formatMonthAndYear({
month: 'long',
year: 'numeric'
}).format(date);
+=======
+export function formatNumberForLocale({
+ locale = 'en-US',
+ value
+}: {
+ locale?: string;
+ value: number;
+}): string {
+ const formatter = new NumberFormatter(locale, {
+ maximumFractionDigits: 15
+ });
+
+ return formatter.format(value);
+>>>>>>> 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/libs/ui/src/lib/localized-number/localized-number.directive.spec.ts b/libs/ui/src/lib/localized-number/localized-number.directive.spec.ts
new file mode 100644
index 000000000..087520a0d
--- /dev/null
+++ b/libs/ui/src/lib/localized-number/localized-number.directive.spec.ts
@@ -0,0 +1,147 @@
+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('1,234.5');
+
+ host.control.setValue(null);
+ fixture.detectChanges();
+
+ 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([
+ (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/libs/ui/src/lib/localized-number/localized-number.directive.ts b/libs/ui/src/lib/localized-number/localized-number.directive.ts
new file mode 100644
index 000000000..71928c754
--- /dev/null
+++ b/libs/ui/src/lib/localized-number/localized-number.directive.ts
@@ -0,0 +1,110 @@
+import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
+import {
+ extractNumberFromString,
+ formatNumberForLocale
+} from '@ghostfolio/common/helper';
+
+import { DOCUMENT } from '@angular/common';
+import {
+ Directive,
+ ElementRef,
+ forwardRef,
+ inject,
+ input
+} from '@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+
+@Directive({
+ host: {
+ '(blur)': 'handleBlur()',
+ '(input)': 'handleInput()',
+ '[attr.inputmode]': '"decimal"',
+ '[attr.type]': '"text"'
+ },
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => GfLocalizedNumberDirective),
+ multi: true
+ }
+ ],
+ selector: 'input[gfLocalizedNumber]'
+})
+export class GfLocalizedNumberDirective implements ControlValueAccessor {
+ public readonly locale = input();
+
+ private readonly document = inject(DOCUMENT);
+ private readonly elementRef =
+ inject>(ElementRef);
+
+ 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;
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ 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) {
+ 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;
+ private onTouched: () => void = () => undefined;
+}
|