Feature/extract historical market data editor to reusable component (#4080)

* Extract historical market data editor to reusable component

* Update changelog
pull/4091/head^2
Amandee Ellawala 3 weeks ago committed by GitHub
parent c85a1be3cf
commit 11d5f36c31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added pagination to the users table of the admin control panel
### Changed
- Extracted the historical market data editor to a reusable component
## 2.125.0 - 2024-11-30
### Changed

@ -1,15 +0,0 @@
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AdminMarketDataDetailComponent } from './admin-market-data-detail.component';
import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/market-data-detail-dialog.module';
@NgModule({
declarations: [AdminMarketDataDetailComponent],
exports: [AdminMarketDataDetailComponent],
imports: [CommonModule, GfLineChartComponent, GfMarketDataDetailDialogModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminMarketDataDetailModule {}

@ -1,26 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MarketDataDetailDialog } from './market-data-detail-dialog.component';
@NgModule({
declarations: [MarketDataDetailDialog],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatDatepickerModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfMarketDataDetailDialogModule {}

@ -3,5 +3,9 @@
.mat-mdc-dialog-content {
max-height: unset;
gf-line-chart {
aspect-ratio: 16/9;
}
}
}

@ -1,15 +1,17 @@
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
AdminMarketDataDetails,
AssetProfileIdentifier
AssetProfileIdentifier,
LineChartItem,
User
} from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n';
@ -23,7 +25,6 @@ import {
} from '@angular/core';
import { FormBuilder, FormControl, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import {
AssetClass,
AssetSubClass,
@ -31,7 +32,6 @@ import {
SymbolProfile
} from '@prisma/client';
import { format } from 'date-fns';
import { parse as csvToJson } from 'papaparse';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
@ -75,11 +75,13 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
};
public currencies: string[] = [];
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
public historicalDataItems: LineChartItem[];
public isBenchmark = false;
public marketDataDetails: MarketData[] = [];
public marketDataItems: MarketData[] = [];
public sectors: {
[name: string]: { name: string; value: number };
};
public user: User;
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
new Date(),
@ -96,7 +98,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
public dialogRef: MatDialogRef<AssetProfileDialog>,
private formBuilder: FormBuilder,
private notificationService: NotificationService,
private snackBar: MatSnackBar
private userService: UserService
) {}
public ngOnInit() {
@ -109,6 +111,16 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}
public initialize() {
this.historicalDataItems = undefined;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
}
});
this.adminService
.fetchAdminMarketDataBySymbol({
dataSource: this.data.dataSource,
@ -121,10 +133,19 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.assetProfileClass = translate(this.assetProfile?.assetClass);
this.assetProfileSubClass = translate(this.assetProfile?.assetSubClass);
this.countries = {};
this.isBenchmark = this.benchmarks.some(({ id }) => {
return id === this.assetProfile.id;
});
this.marketDataDetails = marketData;
this.historicalDataItems = marketData.map(({ date, marketPrice }) => {
return {
date: format(date, DATE_FORMAT),
value: marketPrice
};
});
this.marketDataItems = marketData;
this.sectors = {};
if (this.assetProfile?.countries?.length > 0) {
@ -200,47 +221,6 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
.subscribe();
}
public onImportHistoricalData() {
try {
const marketData = csvToJson(
this.assetProfileForm.controls['historicalData'].controls['csvString']
.value,
{
dynamicTyping: true,
header: true,
skipEmptyLines: true
}
).data as UpdateMarketDataDto[];
this.adminService
.postMarketData({
dataSource: this.data.dataSource,
marketData: {
marketData
},
symbol: this.data.symbol
})
.pipe(
catchError(({ error, message }) => {
this.snackBar.open(`${error}: ${message[0]}`, undefined, {
duration: 3000
});
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {
this.initialize();
});
} catch {
this.snackBar.open(
$localize`Oops! Could not parse historical data.`,
undefined,
{ duration: 3000 }
);
}
}
public onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) {
this.initialize();

@ -68,50 +68,28 @@
</div>
<div class="flex-grow-1" mat-dialog-content>
<gf-admin-market-data-detail
<gf-line-chart
class="mb-4"
[colorScheme]="user?.settings?.colorScheme"
[historicalDataItems]="historicalDataItems"
[isAnimated]="true"
[locale]="data.locale"
[showXAxis]="true"
[showYAxis]="true"
[symbol]="data.symbol"
/>
<gf-historical-market-data-editor
class="mb-3"
[currency]="assetProfile?.currency"
[dataSource]="data.dataSource"
[dateOfFirstActivity]="assetProfile?.dateOfFirstActivity"
[locale]="data.locale"
[marketData]="marketDataDetails"
[marketData]="marketDataItems"
[symbol]="data.symbol"
[user]="user"
(marketDataChanged)="onMarketDataChanged($event)"
/>
<div class="mt-3" formGroupName="historicalData">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label>
<ng-container i18n>Historical Data</ng-container> (CSV)
</mat-label>
<textarea
cdkAutosizeMaxRows="5"
cdkTextareaAutosize
formControlName="csvString"
matInput
type="text"
(keyup.enter)="$event.stopPropagation()"
></textarea>
</mat-form-field>
</div>
<div class="d-flex justify-content-end mt-2">
<button
color="accent"
mat-flat-button
type="button"
[disabled]="
!assetProfileForm.controls['historicalData']?.controls['csvString']
.touched ||
assetProfileForm.controls['historicalData']?.controls['csvString']
?.value === ''
"
(click)="onImportHistoricalData()"
>
<ng-container i18n>Import</ng-container>
</button>
</div>
<div class="row">
<div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="assetProfile?.symbol"

@ -1,7 +1,8 @@
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { GfValueComponent } from '@ghostfolio/ui/value';
@ -24,9 +25,10 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
imports: [
CommonModule,
FormsModule,
GfAdminMarketDataDetailModule,
GfAssetProfileIconComponent,
GfCurrencySelectorComponent,
GfHistoricalMarketDataEditorComponent,
GfLineChartComponent,
GfPortfolioProportionChartComponent,
GfValueComponent,
MatButtonModule,

@ -1,34 +1,58 @@
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
Inject,
OnDestroy
} from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatDatepickerModule } from '@angular/material/datepicker';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { Subject, takeUntil } from 'rxjs';
import { MarketDataDetailDialogParams } from './interfaces/interfaces';
import { HistoricalMarketDataEditorDialogParams } from './interfaces/interfaces';
@Component({
host: { class: 'h-100' },
selector: 'gf-market-data-detail-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./market-data-detail-dialog.scss'],
templateUrl: 'market-data-detail-dialog.html'
host: { class: 'h-100' },
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatDatepickerModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule
],
selector: 'gf-historical-market-data-editor-dialog',
schemas: [CUSTOM_ELEMENTS_SCHEMA],
standalone: true,
styleUrls: ['./historical-market-data-editor-dialog.scss'],
templateUrl: 'historical-market-data-editor-dialog.html'
})
export class MarketDataDetailDialog implements OnDestroy {
export class GfHistoricalMarketDataEditorDialogComponent implements OnDestroy {
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams,
@Inject(MAT_DIALOG_DATA)
public data: HistoricalMarketDataEditorDialogParams,
private dateAdapter: DateAdapter<any>,
public dialogRef: MatDialogRef<MarketDataDetailDialog>,
public dialogRef: MatDialogRef<GfHistoricalMarketDataEditorDialogComponent>,
@Inject(MAT_DATE_LOCALE) private locale: string
) {}

@ -2,7 +2,7 @@ import { User } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client';
export interface MarketDataDetailDialogParams {
export interface HistoricalMarketDataEditorDialogParams {
currency: string;
dataSource: DataSource;
dateString: string;

@ -1,14 +1,4 @@
<div>
<gf-line-chart
class="mb-4"
[colorScheme]="user?.settings?.colorScheme"
[historicalDataItems]="historicalDataItems"
[isAnimated]="true"
[locale]="locale"
[showXAxis]="true"
[showYAxis]="true"
[symbol]="symbol"
/>
@for (itemByMonth of marketDataByMonth | keyvalue; track itemByMonth) {
<div class="d-flex">
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
@ -43,4 +33,42 @@
</div>
</div>
}
<form
class="d-flex flex-column h-100"
[formGroup]="historicalDataForm"
(ngSubmit)="onImportHistoricalData()"
>
<div class="mt-3" formGroupName="historicalData">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label>
<ng-container i18n>Historical Data</ng-container> (CSV)
</mat-label>
<textarea
cdkAutosizeMaxRows="5"
cdkTextareaAutosize
formControlName="csvString"
matInput
type="text"
(keyup.enter)="$event.stopPropagation()"
></textarea>
</mat-form-field>
</div>
<div class="d-flex justify-content-end mt-2">
<button
color="accent"
mat-flat-button
type="button"
[disabled]="
!historicalDataForm.controls['historicalData']?.controls['csvString']
.touched ||
historicalDataForm.controls['historicalData']?.controls['csvString']
?.value === ''
"
(click)="onImportHistoricalData()"
>
<ng-container i18n>Import</ng-container>
</button>
</div>
</form>
</div>

@ -2,10 +2,6 @@
display: block;
font-size: 0.9rem;
gf-line-chart {
aspect-ratio: 16/9;
}
.date {
font-feature-settings: 'tnum';
font-variant-numeric: tabular-nums;

@ -1,4 +1,5 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import {
DATE_FORMAT,
getDateFormatString,
@ -6,15 +7,22 @@ import {
} from '@ghostfolio/common/helper';
import { LineChartItem, User } from '@ghostfolio/common/interfaces';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output
} from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar';
import { DataSource, MarketData } from '@prisma/client';
import {
addDays,
@ -29,55 +37,70 @@ import {
parseISO
} from 'date-fns';
import { first, last } from 'lodash';
import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
import { parse as csvToJson } from 'papaparse';
import { EMPTY, Subject, takeUntil } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { MarketDataDetailDialogParams } from './market-data-detail-dialog/interfaces/interfaces';
import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-detail-dialog.component';
import { GfHistoricalMarketDataEditorDialogComponent } from './historical-market-data-editor-dialog/historical-market-data-editor-dialog.component';
import { HistoricalMarketDataEditorDialogParams } from './historical-market-data-editor-dialog/interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-admin-market-data-detail',
styleUrls: ['./admin-market-data-detail.component.scss'],
templateUrl: './admin-market-data-detail.component.html'
imports: [CommonModule, MatButtonModule, MatInputModule, ReactiveFormsModule],
selector: 'gf-historical-market-data-editor',
standalone: true,
styleUrls: ['./historical-market-data-editor.component.scss'],
templateUrl: './historical-market-data-editor.component.html'
})
export class AdminMarketDataDetailComponent implements OnChanges {
export class GfHistoricalMarketDataEditorComponent
implements OnChanges, OnDestroy, OnInit
{
@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 };
};
} = {};
public user: User;
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
new Date(),
DATE_FORMAT
)};123.45`;
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private userService: UserService
private formBuilder: FormBuilder,
private snackBar: MatSnackBar
) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
}
});
public ngOnInit() {
this.initializeHistoricalDataForm();
}
public ngOnChanges() {
@ -177,29 +200,84 @@ export class AdminMarketDataDetailComponent implements OnChanges {
}) {
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
data: {
marketPrice,
currency: this.currency,
dataSource: this.dataSource,
dateString: `${yearMonth}-${day}`,
symbol: this.symbol,
user: this.user
} as MarketDataDetailDialogParams,
height: this.deviceType === 'mobile' ? '98vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
const dialogRef = this.dialog.open(
GfHistoricalMarketDataEditorDialogComponent,
{
data: {
marketPrice,
currency: this.currency,
dataSource: this.dataSource,
dateString: `${yearMonth}-${day}`,
symbol: this.symbol,
user: this.user
} as HistoricalMarketDataEditorDialogParams,
height: this.deviceType === 'mobile' ? '98vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}
);
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ withRefresh } = { withRefresh: false }) => {
this.marketDataChanged.next(withRefresh);
this.marketDataChanged.emit(withRefresh);
});
}
public onImportHistoricalData() {
try {
const marketData = csvToJson(
this.historicalDataForm.controls['historicalData'].controls['csvString']
.value,
{
dynamicTyping: true,
header: true,
skipEmptyLines: true
}
).data as UpdateMarketDataDto[];
this.adminService
.postMarketData({
dataSource: this.dataSource,
marketData: {
marketData
},
symbol: this.symbol
})
.pipe(
catchError(({ error, message }) => {
this.snackBar.open(`${error}: ${message[0]}`, undefined, {
duration: ms('3 seconds')
});
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {
this.initializeHistoricalDataForm();
this.marketDataChanged.emit(true);
});
} catch {
this.snackBar.open(
$localize`Oops! Could not parse historical data.`,
undefined,
{ duration: ms('3 seconds') }
);
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private initializeHistoricalDataForm() {
this.historicalDataForm.setValue({
historicalData: {
csvString:
GfHistoricalMarketDataEditorComponent.HISTORICAL_DATA_TEMPLATE
}
});
}
}

@ -0,0 +1 @@
export * from './historical-market-data-editor.component';
Loading…
Cancel
Save