Browse Source

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
pull/6342/head
Kenrick Tandrian 2 weeks ago
committed by GitHub
parent
commit
47a054dd00
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 47
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts
  2. 3
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html
  3. 2
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts
  4. 24
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html
  5. 60
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.spec.ts
  6. 133
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts

47
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<void>();
export class GfHistoricalMarketDataEditorDialogComponent implements OnInit {
public readonly data =
inject<HistoricalMarketDataEditorDialogParams>(MAT_DIALOG_DATA);
protected readonly marketPrice = signal(this.data.marketPrice);
private readonly destroyRef = inject(DestroyRef);
private readonly locale =
this.data.user.settings.locale ?? inject<string>(MAT_DATE_LOCALE);
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA)
public data: HistoricalMarketDataEditorDialogParams,
private dataService: DataService,
private dateAdapter: DateAdapter<any>,
public dialogRef: MatDialogRef<GfHistoricalMarketDataEditorDialogComponent>,
@Inject(MAT_DATE_LOCALE) private locale: string
private dateAdapter: DateAdapter<Date, string>,
public dialogRef: MatDialogRef<GfHistoricalMarketDataEditorDialogComponent>
) {
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();
}
}

3
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)"
/>
<span class="ml-2" matTextSuffix>{{ data.currency }}</span>
</mat-form-field>

2
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;
}

24
libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html

@ -3,28 +3,24 @@
<div class="d-flex">
<div class="date mr-1 text-nowrap">{{ itemByMonth.key }}</div>
<div class="align-items-center d-flex flex-grow-1 px-1">
@for (dayItem of days; track dayItem; let i = $index) {
@for (day of days; track day) {
<div
class="day"
[ngClass]="{
'cursor-pointer valid': isDateOfInterest(
`${itemByMonth.key}-${i + 1 < 10 ? `0${i + 1}` : i + 1}`
`${itemByMonth.key}-${formatDay(day)}`
),
available:
marketDataByMonth[itemByMonth.key][
i + 1 < 10 ? `0${i + 1}` : i + 1
]?.marketPrice,
today: isToday(
`${itemByMonth.key}-${i + 1 < 10 ? `0${i + 1}` : i + 1}`
)
marketDataByMonth[itemByMonth.key][formatDay(day)]?.marketPrice,
today: isToday(`${itemByMonth.key}-${formatDay(day)}`)
}"
[title]="
(`${itemByMonth.key}-${i + 1 < 10 ? `0${i + 1}` : i + 1}`
| date: defaultDateFormat) ?? ''
(`${itemByMonth.key}-${formatDay(day)}`
| date: defaultDateFormat()) ?? ''
"
(click)="
onOpenMarketDataDetail({
day: i + 1 < 10 ? `0${i + 1}` : i + 1,
day: formatDay(day),
yearMonth: itemByMonth.key
})
"
@ -61,10 +57,10 @@
mat-flat-button
type="button"
[disabled]="
!historicalDataForm.controls['historicalData']?.controls['csvString']
!historicalDataForm.controls.historicalData.controls.csvString
.touched ||
historicalDataForm.controls['historicalData']?.controls['csvString']
?.value === ''
historicalDataForm.controls.historicalData.controls.csvString
.value === ''
"
(click)="onImportHistoricalData()"
>

60
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<GfHistoricalMarketDataEditorComponent>;
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');
});
});
});

133
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<boolean>();
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<MarketData, 'date' | 'marketPrice'> & { 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<void>();
public readonly locale = input(getLocale());
public readonly marketData = input.required<MarketData[]>();
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<LineChartItem[]>(() =>
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<MarketData>[] = [];
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<UpdateMarketDataDto>(
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: {

Loading…
Cancel
Save