Browse Source

Merge 1646826d49 into 0b9df572ba

pull/7379/merge
Aaryaman Vyas 4 weeks ago
committed by GitHub
parent
commit
0acb00387f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 2
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  3. 3
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  4. 3
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  5. 18
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  6. 3
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html
  7. 1
      apps/client/src/app/pages/accounts/transfer-balance/interfaces/interfaces.ts
  8. 10
      apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts
  9. 3
      apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html
  10. 7
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  11. 21
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  12. 21
      libs/common/src/lib/helper.spec.ts
  13. 17
      libs/common/src/lib/helper.ts
  14. 7
      libs/ui/src/lib/account-balances/account-balances.component.html
  15. 2
      libs/ui/src/lib/account-balances/account-balances.component.ts
  16. 4
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts
  17. 3
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html
  18. 1
      libs/ui/src/lib/localized-number/index.ts
  19. 147
      libs/ui/src/lib/localized-number/localized-number.directive.spec.ts
  20. 110
      libs/ui/src/lib/localized-number/localized-number.directive.ts

4
CHANGELOG.md

@ -241,7 +241,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Restricted the symbol data endpoint (`GET /api/v1/symbol/:dataSource/:symbol`) to authenticated users - Restricted the symbol data endpoint (`GET /api/v1/symbol/:dataSource/:symbol`) to authenticated users
- Removed the deprecated `auth` endpoint of the login with _Security Token_ (`GET`) - Removed the deprecated `auth` endpoint of the login with _Security Token_ (`GET`)
- Simplified the `getHistorical()` function response in the data provider interface - Simplified the `getHistorical()` function response in the data provider interface
<<<<<<< HEAD
- Upgraded `bull-board` from version `8.0.1` to `8.1.2` - Upgraded `bull-board` from version `8.0.1` to `8.1.2`
=======
- Fixed parsing of localized decimal numbers with thousands separators across number input fields
>>>>>>> c5ab9c84a (fix(client): address review feedback for localized number directive)
## 3.29.0 - 2026-07-18 ## 3.29.0 - 2026-07-18

2
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -32,6 +32,7 @@ import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor'; import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfLocalizedNumberDirective } from '@ghostfolio/ui/localized-number';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { AdminService, DataService } from '@ghostfolio/ui/services'; import { AdminService, DataService } from '@ghostfolio/ui/services';
@ -119,6 +120,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
GfEntityLogoComponent, GfEntityLogoComponent,
GfHistoricalMarketDataEditorComponent, GfHistoricalMarketDataEditorComponent,
GfLineChartComponent, GfLineChartComponent,
GfLocalizedNumberDirective,
GfPortfolioProportionChartComponent, GfPortfolioProportionChartComponent,
GfSymbolAutocompleteComponent, GfSymbolAutocompleteComponent,
GfValueComponent, GfValueComponent,

3
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -638,8 +638,9 @@
<mat-label i18n>Default Market Price</mat-label> <mat-label i18n>Default Market Price</mat-label>
<input <input
formControlName="defaultMarketPrice" formControlName="defaultMarketPrice"
gfLocalizedNumber
matInput matInput
type="number" [locale]="user?.settings?.locale"
/> />
</mat-form-field> </mat-form-field>
</div> </div>

3
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -328,7 +328,8 @@ export class GfAccountsPageComponent implements OnInit {
TransferBalanceDialogParams TransferBalanceDialogParams
>(GfTransferBalanceDialogComponent, { >(GfTransferBalanceDialogComponent, {
data: { data: {
accounts: this.accounts accounts: this.accounts,
locale: this.user?.settings?.locale
}, },
width: this.deviceType() === 'mobile' ? '100vw' : '50rem' width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
}); });

18
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts

@ -1,5 +1,8 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import {
DEFAULT_LOCALE,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos'; import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos';
import { getStringOrNull } from '@ghostfolio/common/helper'; import { getStringOrNull } from '@ghostfolio/common/helper';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -7,6 +10,7 @@ import { validateObjectForForm } from '@ghostfolio/common/utils';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector'; import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { GfLocalizedNumberDirective } from '@ghostfolio/ui/localized-number';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector'; import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector';
@ -48,6 +52,7 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
CommonModule, CommonModule,
GfCurrencySelectorComponent, GfCurrencySelectorComponent,
GfEntityLogoComponent, GfEntityLogoComponent,
GfLocalizedNumberDirective,
GfTagsSelectorComponent, GfTagsSelectorComponent,
MatAutocompleteModule, MatAutocompleteModule,
MatButtonModule, MatButtonModule,
@ -76,15 +81,18 @@ export class GfCreateOrUpdateAccountDialogComponent {
inject<MatDialogRef<GfCreateOrUpdateAccountDialogComponent>>(MatDialogRef); inject<MatDialogRef<GfCreateOrUpdateAccountDialogComponent>>(MatDialogRef);
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
protected locale = inject<string>(MAT_DATE_LOCALE);
public ngOnInit() { public ngOnInit() {
const { currencies } = this.dataService.fetchInfo(); const { currencies } = this.dataService.fetchInfo();
this.currencies = currencies; this.currencies = currencies;
this.hasPermissionToCreateOwnTag = hasPermission( this.locale =
this.data.user?.permissions, this.data.user?.settings?.locale ?? this.locale ?? DEFAULT_LOCALE;
permissions.createOwnTag
); this.hasPermissionToCreateOwnTag =
this.data.user?.settings?.isExperimentalFeatures &&
hasPermission(this.data.user?.permissions, permissions.createOwnTag);
this.tagsAvailable = [ this.tagsAvailable = [
...(this.data.user?.tags ?? []), ...(this.data.user?.tags ?? []),

3
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html

@ -34,8 +34,9 @@
<mat-label i18n>Cash Balance</mat-label> <mat-label i18n>Cash Balance</mat-label>
<input <input
formControlName="balance" formControlName="balance"
gfLocalizedNumber
matInput matInput
type="number" [locale]="locale"
(keydown.enter)="$event.stopPropagation()" (keydown.enter)="$event.stopPropagation()"
/> />
<span class="ml-2" matTextSuffix>{{ <span class="ml-2" matTextSuffix>{{

1
apps/client/src/app/pages/accounts/transfer-balance/interfaces/interfaces.ts

@ -3,6 +3,7 @@ import { Account } from '@prisma/client';
export interface TransferBalanceDialogParams { export interface TransferBalanceDialogParams {
accounts: Account[]; accounts: Account[];
locale?: string;
} }
export type TransferBalanceForm = FormGroup<{ export type TransferBalanceForm = FormGroup<{

10
apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts

@ -1,5 +1,6 @@
import { TransferBalanceDto } from '@ghostfolio/common/dtos'; import { TransferBalanceDto } from '@ghostfolio/common/dtos';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { GfLocalizedNumberDirective } from '@ghostfolio/ui/localized-number';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { import {
@ -10,6 +11,7 @@ import {
Validators Validators
} from '@angular/forms'; } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MAT_DATE_LOCALE } from '@angular/material/core';
import { import {
MAT_DIALOG_DATA, MAT_DIALOG_DATA,
MatDialogModule, MatDialogModule,
@ -18,7 +20,6 @@ import {
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { Account } from '@prisma/client';
import { import {
TransferBalanceDialogParams, TransferBalanceDialogParams,
@ -30,6 +31,7 @@ import {
host: { class: 'h-100' }, host: { class: 'h-100' },
imports: [ imports: [
GfEntityLogoComponent, GfEntityLogoComponent,
GfLocalizedNumberDirective,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,
@ -42,10 +44,12 @@ import {
templateUrl: 'transfer-balance-dialog.html' templateUrl: 'transfer-balance-dialog.html'
}) })
export class GfTransferBalanceDialogComponent { export class GfTransferBalanceDialogComponent {
protected readonly accounts: Account[] = private readonly data = inject<TransferBalanceDialogParams>(MAT_DIALOG_DATA);
inject<TransferBalanceDialogParams>(MAT_DIALOG_DATA).accounts;
protected readonly accounts = this.data.accounts;
protected currency: string; protected currency: string;
protected locale = this.data.locale ?? inject<string>(MAT_DATE_LOCALE);
protected readonly transferBalanceForm: TransferBalanceForm = new FormGroup( protected readonly transferBalanceForm: TransferBalanceForm = new FormGroup(
{ {

3
apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html

@ -53,8 +53,9 @@
<mat-label i18n>Value</mat-label> <mat-label i18n>Value</mat-label>
<input <input
formControlName="balance" formControlName="balance"
gfLocalizedNumber
matInput matInput
type="number" [locale]="locale"
(keydown.enter)="$event.stopPropagation()" (keydown.enter)="$event.stopPropagation()"
/> />
<span class="ml-2" matTextSuffix>{{ currency }}</span> <span class="ml-2" matTextSuffix>{{ currency }}</span>

7
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -13,6 +13,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { validateObjectForForm } from '@ghostfolio/common/utils'; import { validateObjectForForm } from '@ghostfolio/common/utils';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { GfLocalizedNumberDirective } from '@ghostfolio/ui/localized-number';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete'; import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete';
import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector'; import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector';
@ -60,6 +61,7 @@ import { ActivityType } from './types/activity-type.type';
host: { class: 'h-100' }, host: { class: 'h-100' },
imports: [ imports: [
GfEntityLogoComponent, GfEntityLogoComponent,
GfLocalizedNumberDirective,
GfSymbolAutocompleteComponent, GfSymbolAutocompleteComponent,
GfTagsSelectorComponent, GfTagsSelectorComponent,
GfValueComponent, GfValueComponent,
@ -114,7 +116,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
private readonly dialogRef = private readonly dialogRef =
inject<MatDialogRef<GfCreateOrUpdateActivityDialogComponent>>(MatDialogRef); inject<MatDialogRef<GfCreateOrUpdateActivityDialogComponent>>(MatDialogRef);
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);
private locale = inject<string>(MAT_DATE_LOCALE); protected locale = inject<string>(MAT_DATE_LOCALE);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
public constructor() { public constructor() {
@ -127,7 +129,8 @@ export class GfCreateOrUpdateActivityDialogComponent {
this.data.user?.permissions, this.data.user?.permissions,
permissions.createOwnTag 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.mode = this.data.activity?.id ? 'update' : 'create';
this.dateAdapter.setLocale(this.locale); this.dateAdapter.setLocale(this.locale);

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

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

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

@ -61,6 +61,27 @@ describe('Helper', () => {
).toEqual(-99999.99); ).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', () => { it('Get decimal number (comma notation) for locale where currency is not grouped by default', () => {
expect( expect(
extractNumberFromString({ locale: 'es-ES', value: '999,99' }) extractNumberFromString({ locale: 'es-ES', value: '999,99' })

17
libs/common/src/lib/helper.ts

@ -1,4 +1,4 @@
import { NumberParser } from '@internationalized/number'; import { NumberFormatter, NumberParser } from '@internationalized/number';
import { import {
Type as ActivityType, Type as ActivityType,
AssetProfileOverrides, AssetProfileOverrides,
@ -234,6 +234,7 @@ export function extractNumberFromString({
} }
} }
<<<<<<< HEAD
export function formatMonthAndYear({ export function formatMonthAndYear({
date, date,
locale locale
@ -245,6 +246,20 @@ export function formatMonthAndYear({
month: 'long', month: 'long',
year: 'numeric' year: 'numeric'
}).format(date); }).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[] { export function getAllActivityTypes(): ActivityType[] {

7
libs/ui/src/lib/account-balances/account-balances.component.html

@ -51,7 +51,12 @@
<td *matFooterCellDef class="px-2" mat-footer-cell> <td *matFooterCellDef class="px-2" mat-footer-cell>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<mat-form-field appearance="outline" class="without-hint"> <mat-form-field appearance="outline" class="without-hint">
<input formControlName="balance" matInput type="number" /> <input
formControlName="balance"
gfLocalizedNumber
matInput
[locale]="locale()"
/>
<div class="ml-2" matTextSuffix> <div class="ml-2" matTextSuffix>
{{ accountCurrency() }} {{ accountCurrency() }}
</div> </div>

2
libs/ui/src/lib/account-balances/account-balances.component.ts

@ -42,11 +42,13 @@ import {
} from 'ionicons/icons'; } from 'ionicons/icons';
import { get, isNil } from 'lodash'; import { get, isNil } from 'lodash';
import { GfLocalizedNumberDirective } from '../localized-number';
import { GfValueComponent } from '../value'; import { GfValueComponent } from '../value';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
GfLocalizedNumberDirective,
GfValueComponent, GfValueComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,

4
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 { calendarClearOutline, refreshOutline } from 'ionicons/icons';
import { isNil } from 'lodash'; import { isNil } from 'lodash';
import { GfLocalizedNumberDirective } from '../../localized-number';
import { HistoricalMarketDataEditorDialogParams } from './interfaces/interfaces'; import { HistoricalMarketDataEditorDialogParams } from './interfaces/interfaces';
@Component({ @Component({
@ -34,6 +35,7 @@ import { HistoricalMarketDataEditorDialogParams } from './interfaces/interfaces'
host: { class: 'h-100' }, host: { class: 'h-100' },
imports: [ imports: [
FormsModule, FormsModule,
GfLocalizedNumberDirective,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatDatepickerModule, MatDatepickerModule,
@ -54,7 +56,7 @@ export class GfHistoricalMarketDataEditorDialogComponent implements OnInit {
protected readonly marketPrice = signal(this.data.marketPrice); protected readonly marketPrice = signal(this.data.marketPrice);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly locale = protected readonly locale =
this.data.user.settings.locale ?? inject<string>(MAT_DATE_LOCALE); this.data.user.settings.locale ?? inject<string>(MAT_DATE_LOCALE);
public constructor( public constructor(

3
libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html

@ -25,9 +25,10 @@
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Market Price</mat-label> <mat-label i18n>Market Price</mat-label>
<input <input
gfLocalizedNumber
matInput matInput
name="marketPrice" name="marketPrice"
type="number" [locale]="locale"
[ngModel]="marketPrice()" [ngModel]="marketPrice()"
(ngModelChange)="marketPrice.set($event)" (ngModelChange)="marketPrice.set($event)"
/> />

1
libs/ui/src/lib/localized-number/index.ts

@ -0,0 +1 @@
export * from './localized-number.directive';

147
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: `
<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);
});
});

110
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<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…
Cancel
Save