From e7df24f899b6af48faf37e7b3b75e821b386a97f Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Tue, 17 Feb 2026 23:41:59 +0700 Subject: [PATCH 1/3] Task/enable UI test (#6345) * Create script for ui test and implement parallel test --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 89d6e9f23..123ce3c54 100644 --- a/package.json +++ b/package.json @@ -43,10 +43,11 @@ "start:production": "npm run database:migrate && npm run database:seed && node main", "start:server": "nx run api:copy-assets && nx run api:serve --watch", "start:storybook": "nx run ui:storybook", - "test": "npm run test:api && npm run test:common", + "test": "npx dotenv-cli -e .env.example -- npx nx run-many --target=test --all --parallel=4", "test:api": "npx dotenv-cli -e .env.example -- nx test api", "test:common": "npx dotenv-cli -e .env.example -- nx test common", "test:single": "nx run api:test --test-file object.helper.spec.ts", + "test:ui": "npx dotenv-cli -e .env.example -- nx test ui", "ts-node": "ts-node", "update": "nx migrate latest", "watch:server": "nx run api:copy-assets && nx run api:build --watch", From 78ccdd8d2da1b1dfc31eaa8f6b615c6f69f6df29 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:43:50 +0100 Subject: [PATCH 2/3] Task/upgrade marked to version 17.0.2 (#6323) * Upgrade marked to version 17.0.2 * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7609fa0e4..bb9b2e676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Upgraded `marked` from version `17.0.1` to `17.0.2` - Upgraded `ngx-markdown` from version `21.0.1` to `21.1.0` ## 2.239.0 - 2026-02-15 diff --git a/package-lock.json b/package-lock.json index 13d3a99ca..44d479e7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,7 +70,7 @@ "ionicons": "8.0.13", "jsonpath": "1.1.1", "lodash": "4.17.23", - "marked": "17.0.1", + "marked": "17.0.2", "ms": "3.0.0-canary.1", "ng-extract-i18n-merge": "3.2.1", "ngx-device-detector": "11.0.0", @@ -25625,9 +25625,9 @@ } }, "node_modules/marked": { - "version": "17.0.1", - "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz", - "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==", + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.2.tgz", + "integrity": "sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA==", "license": "MIT", "bin": { "marked": "bin/marked.js" diff --git a/package.json b/package.json index 123ce3c54..aaf9df90e 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "ionicons": "8.0.13", "jsonpath": "1.1.1", "lodash": "4.17.23", - "marked": "17.0.1", + "marked": "17.0.2", "ms": "3.0.0-canary.1", "ng-extract-i18n-merge": "3.2.1", "ngx-device-detector": "11.0.0", 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 3/3] 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: {