From 47a054dd00b37469e12fa8716c182ad0421b66dc Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Wed, 18 Feb 2026 01:07:27 +0700 Subject: [PATCH] Task/improve type safety in historical market data editor component (#6337) * feat(lib): resolve ts errors * feat(lib): make days protected and readonly * feat(lib): create formatDay helper function * fix(lib): remove unused eslint-disable * feat(lib): change locale to input signal * feat(lib): change defaultDateFormat to computed signal * feat(lib): change deviceType to computed signal * feat(lib): change marketData to input signal * feat(lib): change historicalDataItems to computed signal * feat(nx): run ui test * feat(lib): update days to improve readability * feat(nx): revert test changes * fix(lib): change logic for locale * fix(lib): disable mutating the injected readonly data * fix(lib): implement takeUntilDestroyed * fix(lib): implement takeUntilDestroyed * fix(lib): organize imports --- ...cal-market-data-editor-dialog.component.ts | 47 ++++--- .../historical-market-data-editor-dialog.html | 3 +- .../interfaces/interfaces.ts | 2 +- ...storical-market-data-editor.component.html | 24 ++-- ...rical-market-data-editor.component.spec.ts | 60 ++++++++ ...historical-market-data-editor.component.ts | 133 ++++++++++-------- 6 files changed, 172 insertions(+), 97 deletions(-) create mode 100644 libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.spec.ts diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts index 7e7094dd3..3e5e3b2e9 100644 --- a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts @@ -5,10 +5,12 @@ import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA, - Inject, - OnDestroy, - OnInit + DestroyRef, + OnInit, + inject, + signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core'; @@ -23,7 +25,6 @@ import { MatInputModule } from '@angular/material/input'; import { IonIcon } from '@ionic/angular/standalone'; import { addIcons } from 'ionicons'; import { calendarClearOutline, refreshOutline } from 'ionicons/icons'; -import { Subject, takeUntil } from 'rxjs'; import { HistoricalMarketDataEditorDialogParams } from './interfaces/interfaces'; @@ -45,26 +46,27 @@ import { HistoricalMarketDataEditorDialogParams } from './interfaces/interfaces' styleUrls: ['./historical-market-data-editor-dialog.scss'], templateUrl: 'historical-market-data-editor-dialog.html' }) -export class GfHistoricalMarketDataEditorDialogComponent - implements OnDestroy, OnInit -{ - private unsubscribeSubject = new Subject(); +export class GfHistoricalMarketDataEditorDialogComponent implements OnInit { + public readonly data = + inject(MAT_DIALOG_DATA); + + protected readonly marketPrice = signal(this.data.marketPrice); + + private readonly destroyRef = inject(DestroyRef); + private readonly locale = + this.data.user.settings.locale ?? inject(MAT_DATE_LOCALE); public constructor( private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, - @Inject(MAT_DIALOG_DATA) - public data: HistoricalMarketDataEditorDialogParams, private dataService: DataService, - private dateAdapter: DateAdapter, - public dialogRef: MatDialogRef, - @Inject(MAT_DATE_LOCALE) private locale: string + private dateAdapter: DateAdapter, + public dialogRef: MatDialogRef ) { addIcons({ calendarClearOutline, refreshOutline }); } public ngOnInit() { - this.locale = this.data.user?.settings?.locale; this.dateAdapter.setLocale(this.locale); } @@ -79,15 +81,19 @@ export class GfHistoricalMarketDataEditorDialogComponent dateString: this.data.dateString, symbol: this.data.symbol }) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ marketPrice }) => { - this.data.marketPrice = marketPrice; + this.marketPrice.set(marketPrice); this.changeDetectorRef.markForCheck(); }); } public onUpdate() { + if (this.marketPrice() === undefined) { + return; + } + this.dataService .postMarketData({ dataSource: this.data.dataSource, @@ -95,20 +101,15 @@ export class GfHistoricalMarketDataEditorDialogComponent marketData: [ { date: this.data.dateString, - marketPrice: this.data.marketPrice + marketPrice: this.marketPrice() } ] }, symbol: this.data.symbol }) - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.dialogRef.close({ withRefresh: true }); }); } - - public ngOnDestroy() { - this.unsubscribeSubject.next(); - this.unsubscribeSubject.complete(); - } } diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html index 8e7e30649..7bb5827ef 100644 --- a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html @@ -28,7 +28,8 @@ matInput name="marketPrice" type="number" - [(ngModel)]="data.marketPrice" + [ngModel]="marketPrice()" + (ngModelChange)="marketPrice.set($event)" /> {{ data.currency }} diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts index 4248b3fdb..edb9a852f 100644 --- a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts @@ -6,7 +6,7 @@ export interface HistoricalMarketDataEditorDialogParams { currency: string; dataSource: DataSource; dateString: string; - marketPrice: number; + marketPrice?: number; symbol: string; user: User; } diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html index 3c2807146..91e3dd8d7 100644 --- a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html @@ -3,28 +3,24 @@
{{ itemByMonth.key }}
- @for (dayItem of days; track dayItem; let i = $index) { + @for (day of days; track day) {
diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.spec.ts b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.spec.ts new file mode 100644 index 000000000..7cb9636f0 --- /dev/null +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.spec.ts @@ -0,0 +1,60 @@ +import { DataService } from '@ghostfolio/ui/services'; + +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormBuilder } from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { DeviceDetectorService } from 'ngx-device-detector'; + +import { GfHistoricalMarketDataEditorComponent } from './historical-market-data-editor.component'; + +jest.mock( + './historical-market-data-editor-dialog/historical-market-data-editor-dialog.component', + () => ({ + GfHistoricalMarketDataEditorDialogComponent: class {} + }) +); + +describe('GfHistoricalMarketDataEditorComponent', () => { + let component: GfHistoricalMarketDataEditorComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GfHistoricalMarketDataEditorComponent], + providers: [ + FormBuilder, + { provide: DataService, useValue: {} }, + { + provide: DeviceDetectorService, + useValue: { + deviceInfo: signal({ deviceType: 'desktop' }) + } + }, + { provide: MatDialog, useValue: {} }, + { provide: MatSnackBar, useValue: {} } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(GfHistoricalMarketDataEditorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('formatDay', () => { + it('should pad single digit days with zero', () => { + expect(component.formatDay(1)).toBe('01'); + expect(component.formatDay(9)).toBe('09'); + }); + + it('should not pad double digit days', () => { + expect(component.formatDay(10)).toBe('10'); + expect(component.formatDay(31)).toBe('31'); + }); + }); +}); diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts index 098f4e295..cde180dd9 100644 --- a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts @@ -8,16 +8,21 @@ import { LineChartItem, User } from '@ghostfolio/common/interfaces'; import { DataService } from '@ghostfolio/ui/services'; import { CommonModule } from '@angular/common'; +import type { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, + computed, + DestroyRef, EventEmitter, + inject, + input, Input, OnChanges, - OnDestroy, OnInit, Output } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; @@ -40,7 +45,7 @@ import { first, last } from 'lodash'; import ms from 'ms'; import { DeviceDetectorService } from 'ngx-device-detector'; import { parse as csvToJson } from 'papaparse'; -import { EMPTY, Subject, takeUntil } from 'rxjs'; +import { EMPTY } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { GfHistoricalMarketDataEditorDialogComponent } from './historical-market-data-editor-dialog/historical-market-data-editor-dialog.component'; @@ -54,74 +59,80 @@ import { HistoricalMarketDataEditorDialogParams } from './historical-market-data templateUrl: './historical-market-data-editor.component.html' }) export class GfHistoricalMarketDataEditorComponent - implements OnChanges, OnDestroy, OnInit + implements OnChanges, OnInit { + private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format( + new Date(), + DATE_FORMAT + )};123.45`; + @Input() currency: string; @Input() dataSource: DataSource; @Input() dateOfFirstActivity: string; - @Input() locale = getLocale(); - @Input() marketData: MarketData[]; @Input() symbol: string; @Input() user: User; @Output() marketDataChanged = new EventEmitter(); - public days = Array(31); - public defaultDateFormat: string; - public deviceType: string; public historicalDataForm = this.formBuilder.group({ historicalData: this.formBuilder.group({ csvString: '' }) }); - public historicalDataItems: LineChartItem[]; public marketDataByMonth: { [yearMonth: string]: { - [day: string]: Pick & { day: number }; + [day: string]: { + date: Date; + day: number; + marketPrice?: number; + }; }; } = {}; - private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format( - new Date(), - DATE_FORMAT - )};123.45`; - - private unsubscribeSubject = new Subject(); + public readonly locale = input(getLocale()); + public readonly marketData = input.required(); + + protected readonly days = Array.from({ length: 31 }, (_, i) => i + 1); + protected readonly defaultDateFormat = computed(() => + getDateFormatString(this.locale()) + ); + + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly deviceType = computed( + () => this.deviceDetectorService.deviceInfo().deviceType + ); + private readonly historicalDataItems = computed(() => + this.marketData().map(({ date, marketPrice }) => { + return { + date: format(date, DATE_FORMAT), + value: marketPrice + }; + }) + ); public constructor( private dataService: DataService, - private deviceService: DeviceDetectorService, private dialog: MatDialog, private formBuilder: FormBuilder, private snackBar: MatSnackBar - ) { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; - } + ) {} public ngOnInit() { this.initializeHistoricalDataForm(); } public ngOnChanges() { - this.defaultDateFormat = getDateFormatString(this.locale); - - this.historicalDataItems = this.marketData.map(({ date, marketPrice }) => { - return { - date: format(date, DATE_FORMAT), - value: marketPrice - }; - }); - if (this.dateOfFirstActivity) { let date = parseISO(this.dateOfFirstActivity); - const missingMarketData: Partial[] = []; + const missingMarketData: { date: Date; marketPrice?: number }[] = []; - if (this.historicalDataItems?.[0]?.date) { + if (this.historicalDataItems()?.[0]?.date) { while ( isBefore( date, - parse(this.historicalDataItems[0].date, DATE_FORMAT, new Date()) + parse(this.historicalDataItems()[0].date, DATE_FORMAT, new Date()) ) ) { missingMarketData.push({ @@ -133,9 +144,10 @@ export class GfHistoricalMarketDataEditorComponent } } - const marketDataItems = [...missingMarketData, ...this.marketData]; + const marketDataItems = [...missingMarketData, ...this.marketData()]; - if (!isToday(last(marketDataItems)?.date)) { + const lastDate = last(marketDataItems)?.date; + if (!lastDate || !isToday(lastDate)) { marketDataItems.push({ date: new Date() }); } @@ -160,25 +172,34 @@ export class GfHistoricalMarketDataEditorComponent // Fill up missing months const dates = Object.keys(this.marketDataByMonth).sort(); + const startDateString = first(dates); const startDate = min([ parseISO(this.dateOfFirstActivity), - parseISO(first(dates)) + ...(startDateString ? [parseISO(startDateString)] : []) ]); - const endDate = parseISO(last(dates)); + const endDateString = last(dates); - let currentDate = startDate; + if (endDateString) { + const endDate = parseISO(endDateString); - while (isBefore(currentDate, endDate)) { - const key = format(currentDate, 'yyyy-MM'); - if (!this.marketDataByMonth[key]) { - this.marketDataByMonth[key] = {}; - } + let currentDate = startDate; - currentDate = addMonths(currentDate, 1); + while (isBefore(currentDate, endDate)) { + const key = format(currentDate, 'yyyy-MM'); + if (!this.marketDataByMonth[key]) { + this.marketDataByMonth[key] = {}; + } + + currentDate = addMonths(currentDate, 1); + } } } } + public formatDay(day: number): string { + return day < 10 ? `0${day}` : `${day}`; + } + public isDateOfInterest(aDateString: string) { // Date is valid and in the past const date = parse(aDateString, DATE_FORMAT, new Date()); @@ -201,7 +222,8 @@ export class GfHistoricalMarketDataEditorComponent const dialogRef = this.dialog.open< GfHistoricalMarketDataEditorDialogComponent, - HistoricalMarketDataEditorDialogParams + HistoricalMarketDataEditorDialogParams, + { withRefresh: boolean } >(GfHistoricalMarketDataEditorDialogComponent, { data: { marketPrice, @@ -211,13 +233,13 @@ export class GfHistoricalMarketDataEditorComponent symbol: this.symbol, user: this.user }, - height: this.deviceType === 'mobile' ? '98vh' : '80vh', - width: this.deviceType === 'mobile' ? '100vw' : '50rem' + height: this.deviceType() === 'mobile' ? '98vh' : '80vh', + width: this.deviceType() === 'mobile' ? '100vw' : '50rem' }); dialogRef .afterClosed() - .pipe(takeUntil(this.unsubscribeSubject)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ withRefresh } = { withRefresh: false }) => { this.marketDataChanged.emit(withRefresh); }); @@ -225,15 +247,15 @@ export class GfHistoricalMarketDataEditorComponent public onImportHistoricalData() { try { - const marketData = csvToJson( - this.historicalDataForm.controls['historicalData'].controls['csvString'] - .value, + const marketData = csvToJson( + this.historicalDataForm.controls.historicalData.controls.csvString + .value ?? '', { dynamicTyping: true, header: true, skipEmptyLines: true } - ).data as UpdateMarketDataDto[]; + ).data; this.dataService .postMarketData({ @@ -244,13 +266,13 @@ export class GfHistoricalMarketDataEditorComponent symbol: this.symbol }) .pipe( - catchError(({ error, message }) => { + catchError(({ error, message }: HttpErrorResponse) => { this.snackBar.open(`${error}: ${message[0]}`, undefined, { duration: ms('3 seconds') }); return EMPTY; }), - takeUntil(this.unsubscribeSubject) + takeUntilDestroyed(this.destroyRef) ) .subscribe(() => { this.initializeHistoricalDataForm(); @@ -268,11 +290,6 @@ export class GfHistoricalMarketDataEditorComponent } } - public ngOnDestroy() { - this.unsubscribeSubject.next(); - this.unsubscribeSubject.complete(); - } - private initializeHistoricalDataForm() { this.historicalDataForm.setValue({ historicalData: {