mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
20 changed files with 366 additions and 21 deletions
@ -0,0 +1 @@ |
|||
export * from './localized-number.directive'; |
|||
@ -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: ` |
|||
<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('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); |
|||
}); |
|||
}); |
|||
@ -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<string>(); |
|||
|
|||
private readonly document = inject(DOCUMENT); |
|||
private readonly elementRef = |
|||
inject<ElementRef<HTMLInputElement>>(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; |
|||
} |
|||
Loading…
Reference in new issue