Browse Source

fix(client): correctly parse localized decimal numbers

pull/7379/head
aaryamantriescode 1 month ago
committed by Thomas Kaul
parent
commit
06b7d85c7c
  1. 112
      apps/client/src/app/directives/localized-number/localized-number.directive.spec.ts
  2. 87
      apps/client/src/app/directives/localized-number/localized-number.directive.ts
  3. 5
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  4. 6
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  5. 21
      libs/common/src/lib/helper.spec.ts

112
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: `
<input gfLocalizedNumber [formControl]="control" [locale]="locale" />
`
})
class TestHostComponent {
public control = new FormControl<number | null>(null);
public locale = 'en-US';
}
describe('GfLocalizedNumberDirective', () => {
let fixture: ComponentFixture<TestHostComponent>;
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);
});
});

87
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<string>();
private readonly document = inject(DOCUMENT);
private readonly elementRef =
inject<ElementRef<HTMLInputElement>>(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;
}

5
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);

6
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

@ -181,7 +181,7 @@
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Quantity</mat-label>
<input formControlName="quantity" matInput type="number" />
<input formControlName="quantity" gfLocalizedNumber matInput />
</mat-form-field>
</div>
<div
@ -209,7 +209,7 @@
}
}
</mat-label>
<input formControlName="unitPrice" matInput type="number" />
<input formControlName="unitPrice" gfLocalizedNumber matInput />
<div
class="ml-2"
matTextSuffix
@ -254,7 +254,7 @@
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Fee</mat-label>
<input formControlName="fee" matInput type="number" />
<input formControlName="fee" gfLocalizedNumber matInput />
<div
class="ml-2"
matTextSuffix

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

@ -53,6 +53,27 @@ describe('Helper', () => {
).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' })

Loading…
Cancel
Save