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
- Removed the deprecated `auth` endpoint of the login with _Security Token_ (`GET`)
- Simplified the `getHistorical()` function response in the data provider interface
<<<<<<< HEAD
- 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

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 { translate } from '@ghostfolio/ui/i18n';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfLocalizedNumberDirective } from '@ghostfolio/ui/localized-number';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { AdminService, DataService } from '@ghostfolio/ui/services';
@ -108,6 +109,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
GfEntityLogoComponent,
GfHistoricalMarketDataEditorComponent,
GfLineChartComponent,
GfLocalizedNumberDirective,
GfPortfolioProportionChartComponent,
GfSymbolAutocompleteComponent,
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>
<input
formControlName="defaultMarketPrice"
gfLocalizedNumber
matInput
type="number"
[locale]="user?.settings?.locale"
/>
</mat-form-field>
</div>

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

@ -331,7 +331,8 @@ export class GfAccountsPageComponent implements OnInit {
TransferBalanceDialogParams
>(GfTransferBalanceDialogComponent, {
data: {
accounts: this.accounts
accounts: this.accounts,
locale: this.user?.settings?.locale
},
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 { 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 { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { validateObjectForForm } from '@ghostfolio/common/utils';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { translate } from '@ghostfolio/ui/i18n';
import { GfLocalizedNumberDirective } from '@ghostfolio/ui/localized-number';
import { DataService } from '@ghostfolio/ui/services';
import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector';
@ -28,6 +32,7 @@ import {
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MAT_DATE_LOCALE } from '@angular/material/core';
import {
MAT_DIALOG_DATA,
MatDialogModule,
@ -48,6 +53,7 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
CommonModule,
GfCurrencySelectorComponent,
GfEntityLogoComponent,
GfLocalizedNumberDirective,
GfTagsSelectorComponent,
MatAutocompleteModule,
MatButtonModule,
@ -77,15 +83,25 @@ export class GfCreateOrUpdateAccountDialogComponent {
inject<MatDialogRef<GfCreateOrUpdateAccountDialogComponent>>(MatDialogRef);
private readonly formBuilder = inject(FormBuilder);
private readonly userService = inject(UserService);
protected locale = inject<string>(MAT_DATE_LOCALE);
public ngOnInit() {
const { currencies } = this.dataService.fetchInfo();
this.currencies = currencies;
<<<<<<< HEAD
this.hasPermissionToCreateOwnTag = hasPermission(
this.data.user?.permissions,
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.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>
<input
formControlName="balance"
gfLocalizedNumber
matInput
type="number"
[locale]="locale"
(keydown.enter)="$event.stopPropagation()"
/>
<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 {
accounts: Account[];
locale?: string;
}
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 { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { GfLocalizedNumberDirective } from '@ghostfolio/ui/localized-number';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
@ -10,6 +11,7 @@ import {
Validators
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DATE_LOCALE } from '@angular/material/core';
import {
MAT_DIALOG_DATA,
MatDialogModule,
@ -18,7 +20,6 @@ import {
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { Account } from '@prisma/client';
import {
TransferBalanceDialogParams,
@ -30,6 +31,7 @@ import {
host: { class: 'h-100' },
imports: [
GfEntityLogoComponent,
GfLocalizedNumberDirective,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
@ -42,10 +44,12 @@ import {
templateUrl: 'transfer-balance-dialog.html'
})
export class GfTransferBalanceDialogComponent {
protected readonly accounts: Account[] =
inject<TransferBalanceDialogParams>(MAT_DIALOG_DATA).accounts;
private readonly data = inject<TransferBalanceDialogParams>(MAT_DIALOG_DATA);
protected readonly accounts = this.data.accounts;
protected currency: string;
protected locale = this.data.locale ?? inject<string>(MAT_DATE_LOCALE);
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>
<input
formControlName="balance"
gfLocalizedNumber
matInput
type="number"
[locale]="locale"
(keydown.enter)="$event.stopPropagation()"
/>
<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 { ASSET_CLASS_MAPPING, DEFAULT_LOCALE } from '@ghostfolio/common/config';
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 { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { translate } from '@ghostfolio/ui/i18n';
import { GfLocalizedNumberDirective } from '@ghostfolio/ui/localized-number';
import { DataService } from '@ghostfolio/ui/services';
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete';
import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector';
@ -113,7 +113,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
private readonly dialogRef =
inject<MatDialogRef<GfCreateOrUpdateActivityDialogComponent>>(MatDialogRef);
private readonly formBuilder = inject(FormBuilder);
private locale = inject<string>(MAT_DATE_LOCALE);
protected locale = inject<string>(MAT_DATE_LOCALE);
private readonly userService = inject(UserService);
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-label i18n>Quantity</mat-label>
<input formControlName="quantity" gfLocalizedNumber matInput />
<input
formControlName="quantity"
gfLocalizedNumber
matInput
[locale]="locale"
/>
</mat-form-field>
</div>
<div
@ -209,7 +214,12 @@
}
}
</mat-label>
<input formControlName="unitPrice" gfLocalizedNumber matInput />
<input
formControlName="unitPrice"
gfLocalizedNumber
matInput
[locale]="locale"
/>
<div
class="ml-2"
matTextSuffix
@ -254,7 +264,12 @@
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Fee</mat-label>
<input formControlName="fee" gfLocalizedNumber matInput />
<input
formControlName="fee"
gfLocalizedNumber
matInput
[locale]="locale"
/>
<div
class="ml-2"
matTextSuffix

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

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

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

@ -51,7 +51,12 @@
<td *matFooterCellDef class="px-2" mat-footer-cell>
<div class="d-flex justify-content-end">
<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>
{{ accountCurrency() }}
</div>

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

@ -42,11 +42,13 @@ import {
} from 'ionicons/icons';
import { get, isNil } from 'lodash';
import { GfLocalizedNumberDirective } from '../localized-number';
import { GfValueComponent } from '../value';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
GfLocalizedNumberDirective,
GfValueComponent,
IonIcon,
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 { isNil } from 'lodash';
import { GfLocalizedNumberDirective } from '../../localized-number';
import { HistoricalMarketDataEditorDialogParams } from './interfaces/interfaces';
@Component({
@ -34,6 +35,7 @@ import { HistoricalMarketDataEditorDialogParams } from './interfaces/interfaces'
host: { class: 'h-100' },
imports: [
FormsModule,
GfLocalizedNumberDirective,
IonIcon,
MatButtonModule,
MatDatepickerModule,
@ -54,7 +56,7 @@ export class GfHistoricalMarketDataEditorDialogComponent implements OnInit {
protected readonly marketPrice = signal(this.data.marketPrice);
private readonly destroyRef = inject(DestroyRef);
private readonly locale =
protected readonly locale =
this.data.user.settings.locale ?? inject<string>(MAT_DATE_LOCALE);
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-label i18n>Market Price</mat-label>
<input
gfLocalizedNumber
matInput
name="marketPrice"
type="number"
[locale]="locale"
[ngModel]="marketPrice()"
(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);
fixture.detectChanges();
expect(input.value).toBe('1234.5');
expect(input.value).toBe('1,234.5');
host.control.setValue(null);
fixture.detectChanges();
@ -90,6 +90,41 @@ describe('GfLocalizedNumberDirective', () => {
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([

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