mirror of https://github.com/ghostfolio/ghostfolio
committed by
Thomas Kaul
5 changed files with 227 additions and 4 deletions
@ -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); |
|||
}); |
|||
}); |
|||
@ -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; |
|||
} |
|||
Loading…
Reference in new issue