Browse Source

fix(client): address review feedback for localized number directive

pull/7379/head
aaryamantriescode 1 month ago
parent
commit
ac11fd22bf
  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. 4
      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. 17
      libs/common/src/lib/helper.ts
  13. 7
      libs/ui/src/lib/account-balances/account-balances.component.html
  14. 2
      libs/ui/src/lib/account-balances/account-balances.component.ts
  15. 4
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts
  16. 3
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html
  17. 1
      libs/ui/src/lib/localized-number/index.ts
  18. 37
      libs/ui/src/lib/localized-number/localized-number.directive.spec.ts
  19. 57
      libs/ui/src/lib/localized-number/localized-number.directive.ts

4
CHANGELOG.md

@ -78,7 +78,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

@ -28,6 +28,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';
@ -108,6 +109,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

@ -519,8 +519,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

@ -331,7 +331,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,11 +1,15 @@
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 { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { validateObjectForForm } from '@ghostfolio/common/utils'; 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';
@ -28,6 +32,7 @@ import {
import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
import { MAT_DATE_LOCALE } from '@angular/material/core';
import { import {
MAT_DIALOG_DATA, MAT_DIALOG_DATA,
MatDialogModule, MatDialogModule,
@ -48,6 +53,7 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
CommonModule, CommonModule,
GfCurrencySelectorComponent, GfCurrencySelectorComponent,
GfEntityLogoComponent, GfEntityLogoComponent,
GfLocalizedNumberDirective,
GfTagsSelectorComponent, GfTagsSelectorComponent,
MatAutocompleteModule, MatAutocompleteModule,
MatButtonModule, MatButtonModule,
@ -77,15 +83,25 @@ 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;
<<<<<<< HEAD
this.hasPermissionToCreateOwnTag = hasPermission( this.hasPermissionToCreateOwnTag = hasPermission(
this.data.user?.permissions, this.data.user?.permissions,
permissions.createOwnTag permissions.createOwnTag
); );
=======
this.locale =
this.data.user?.settings?.locale ?? this.locale ?? DEFAULT_LOCALE;
this.hasPermissionToCreateOwnTag =
this.data.user?.settings?.isExperimentalFeatures &&
hasPermission(this.data.user?.permissions, permissions.createOwnTag);
>>>>>>> c5ab9c84a (fix(client): address review feedback for localized number directive)
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>

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

@ -1,4 +1,3 @@
import { GfLocalizedNumberDirective } from '@ghostfolio/client/directives/localized-number/localized-number.directive';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ASSET_CLASS_MAPPING, DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { ASSET_CLASS_MAPPING, DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
@ -11,6 +10,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';
@ -113,7 +113,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() {

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" gfLocalizedNumber matInput /> <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" gfLocalizedNumber matInput /> <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" gfLocalizedNumber matInput /> <input
formControlName="fee"
gfLocalizedNumber
matInput
[locale]="locale"
/>
<div <div
class="ml-2" class="ml-2"
matTextSuffix matTextSuffix

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,
@ -233,6 +233,7 @@ export function extractNumberFromString({
} }
} }
<<<<<<< HEAD
export function formatMonthAndYear({ export function formatMonthAndYear({
date, date,
locale locale
@ -244,6 +245,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';

37
apps/client/src/app/directives/localized-number/localized-number.directive.spec.ts → libs/ui/src/lib/localized-number/localized-number.directive.spec.ts

@ -82,7 +82,7 @@ describe('GfLocalizedNumberDirective', () => {
host.control.setValue(1234.5); host.control.setValue(1234.5);
fixture.detectChanges(); fixture.detectChanges();
expect(input.value).toBe('1234.5'); expect(input.value).toBe('1,234.5');
host.control.setValue(null); host.control.setValue(null);
fixture.detectChanges(); fixture.detectChanges();
@ -90,6 +90,41 @@ describe('GfLocalizedNumberDirective', () => {
expect(input.value).toBe(''); 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', () => { it('should keep required validation working', () => {
host.locale = 'de-DE'; host.locale = 'de-DE';
host.control.setValidators([ host.control.setValidators([

57
apps/client/src/app/directives/localized-number/localized-number.directive.ts → libs/ui/src/lib/localized-number/localized-number.directive.ts

@ -1,9 +1,18 @@
import { DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { extractNumberFromString } from '@ghostfolio/common/helper'; import {
extractNumberFromString,
formatNumberForLocale
} from '@ghostfolio/common/helper';
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
import { Directive, ElementRef, inject, input } from '@angular/core'; import {
import { ControlValueAccessor, NgControl } from '@angular/forms'; Directive,
ElementRef,
forwardRef,
inject,
input
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Directive({ @Directive({
host: { host: {
@ -12,6 +21,13 @@ import { ControlValueAccessor, NgControl } from '@angular/forms';
'[attr.inputmode]': '"decimal"', '[attr.inputmode]': '"decimal"',
'[attr.type]': '"text"' '[attr.type]': '"text"'
}, },
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => GfLocalizedNumberDirective),
multi: true
}
],
selector: 'input[gfLocalizedNumber]' selector: 'input[gfLocalizedNumber]'
}) })
export class GfLocalizedNumberDirective implements ControlValueAccessor { export class GfLocalizedNumberDirective implements ControlValueAccessor {
@ -21,15 +37,6 @@ export class GfLocalizedNumberDirective implements ControlValueAccessor {
private readonly elementRef = private readonly elementRef =
inject<ElementRef<HTMLInputElement>>(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() { public handleBlur() {
this.onTouched(); this.onTouched();
} }
@ -49,7 +56,8 @@ export class GfLocalizedNumberDirective implements ControlValueAccessor {
// 3. DEFAULT_LOCALE ('en-US') as the final fallback // 3. DEFAULT_LOCALE ('en-US') as the final fallback
const localeInput = this.locale(); const localeInput = this.locale();
const documentLang = this.document.documentElement.lang; const documentLang = this.document.documentElement.lang;
const resolvedLocale = localeInput ?? documentLang ?? DEFAULT_LOCALE; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const resolvedLocale = localeInput || documentLang || DEFAULT_LOCALE;
const parsedNumber = extractNumberFromString({ const parsedNumber = extractNumberFromString({
locale: resolvedLocale, locale: resolvedLocale,
@ -76,10 +84,25 @@ export class GfLocalizedNumberDirective implements ControlValueAccessor {
} }
public writeValue(value: number | null) { public writeValue(value: number | null) {
this.elementRef.nativeElement.value = if (
value === null || value === undefined || Number.isNaN(value) value === null ||
? '' value === undefined ||
: String(value); 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 onChange: (value: number | null) => void = () => undefined;
Loading…
Cancel
Save