Feature/improve activities import (#1531)

This commit is contained in:
Yash Solanki
2022-12-26 20:56:51 +05:30
committed by GitHub
parent c22733db56
commit a08610b603
7 changed files with 245 additions and 78 deletions

View File

@@ -7,6 +7,7 @@ import {
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
import { isArray } from 'lodash';
import { Subject } from 'rxjs';
@@ -20,8 +21,11 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
templateUrl: 'import-activities-dialog.html'
})
export class ImportActivitiesDialog implements OnDestroy {
public activities: Activity[] = [];
public details: any[] = [];
public errorMessages: string[] = [];
public isFileSelected = false;
public selectedActivities: Activity[] = [];
private unsubscribeSubject = new Subject<void>();
@@ -39,13 +43,47 @@ export class ImportActivitiesDialog implements OnDestroy {
this.dialogRef.close();
}
public onImport() {
public async onImportActivities() {
try {
this.snackBar.open('⏳ ' + $localize`Importing data...`);
await this.importActivitiesService.importSelectedActivities(
this.selectedActivities
);
this.snackBar.open(
'✅ ' + $localize`Import has been completed`,
undefined,
{
duration: 3000
}
);
} catch (error) {
this.snackBar.open(
$localize`Oops! Something went wrong.` +
' ' +
$localize`Please try again later.`,
$localize`Okay`,
{ duration: 3000 }
);
} finally {
this.dialogRef.close();
}
}
public onReset() {
this.details = [];
this.errorMessages = [];
this.isFileSelected = false;
}
public onSelectFile() {
const input = document.createElement('input');
input.accept = 'application/JSON, .csv';
input.type = 'file';
input.onchange = (event) => {
this.snackBar.open('⏳ ' + $localize`Importing data...`);
this.snackBar.open('⏳ ' + $localize`Validating data...`);
// Getting the file reference
const file = (event.target as HTMLInputElement).files[0];
@@ -80,11 +118,10 @@ export class ImportActivitiesDialog implements OnDestroy {
}
try {
await this.importActivitiesService.importJson({
content: content.activities
this.activities = await this.importActivitiesService.importJson({
content: content.activities,
dryRun: true
});
this.handleImportSuccess();
} catch (error) {
console.error(error);
this.handleImportError({ error, activities: content.activities });
@@ -93,12 +130,11 @@ export class ImportActivitiesDialog implements OnDestroy {
return;
} else if (file.name.endsWith('.csv')) {
try {
await this.importActivitiesService.importCsv({
this.activities = await this.importActivitiesService.importCsv({
dryRun: true,
fileContent,
userAccounts: this.data.user.accounts
});
this.handleImportSuccess();
} catch (error) {
console.error(error);
this.handleImportError({
@@ -119,6 +155,10 @@ export class ImportActivitiesDialog implements OnDestroy {
activities: [],
error: { error: { message: ['Unexpected format'] } }
});
} finally {
this.isFileSelected = true;
this.snackBar.dismiss();
this.changeDetectorRef.markForCheck();
}
};
};
@@ -126,9 +166,8 @@ export class ImportActivitiesDialog implements OnDestroy {
input.click();
}
public onReset() {
this.details = [];
this.errorMessages = [];
public updateSelection(data: Activity[]) {
this.selectedActivities = data;
}
public ngOnDestroy() {
@@ -143,8 +182,6 @@ export class ImportActivitiesDialog implements OnDestroy {
activities: any[];
error: any;
}) {
this.snackBar.dismiss();
this.errorMessages = error?.error?.message;
for (const message of this.errorMessages) {
@@ -161,16 +198,4 @@ export class ImportActivitiesDialog implements OnDestroy {
this.changeDetectorRef.markForCheck();
}
private handleImportSuccess() {
this.snackBar.open(
'✅ ' + $localize`Import has been completed`,
undefined,
{
duration: 3000
}
);
this.dialogRef.close();
}
}

View File

@@ -6,13 +6,13 @@
></gf-dialog-header>
<div class="flex-grow-1" mat-dialog-content>
<ng-container *ngIf="errorMessages.length === 0">
<ng-container *ngIf="!isFileSelected">
<div class="d-flex justify-content-center flex-column">
<button
class="py-3"
color="primary"
mat-stroked-button
(click)="onImport()"
(click)="onSelectFile()"
>
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
<span i18n>Choose File</span>
@@ -33,37 +33,68 @@
</p>
</div>
</ng-container>
<ng-container *ngIf="errorMessages.length > 0">
<mat-accordion displayMode="flat">
<mat-expansion-panel
*ngFor="let message of errorMessages; let i = index"
[disabled]="!details[i]"
>
<mat-expansion-panel-header class="pl-1">
<mat-panel-title>
<div class="d-flex">
<div class="align-items-center d-flex mr-2">
<ion-icon name="warning-outline"></ion-icon>
<ng-container *ngIf="isFileSelected">
<ng-container *ngIf="errorMessages.length === 0; else errorMessage">
<gf-activities-table
[activities]="activities"
[baseCurrency]="data?.user?.settings?.baseCurrency"
[deviceType]="data?.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="false"
[hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data?.user?.settings?.locale"
[showActions]="false"
[showCheckbox]="true"
[showSymbolColumn]="false"
(selectedActivities)="updateSelection($event)"
></gf-activities-table>
</ng-container>
<ng-template #errorMessage>
<mat-accordion displayMode="flat">
<mat-expansion-panel
*ngFor="let message of errorMessages; let i = index"
[disabled]="!details[i]"
>
<mat-expansion-panel-header class="pl-1">
<mat-panel-title>
<div class="d-flex">
<div class="align-items-center d-flex mr-2">
<ion-icon name="warning-outline"></ion-icon>
</div>
<div>{{ message }}</div>
</div>
<div>{{ message }}</div>
</div>
</mat-panel-title>
</mat-expansion-panel-header>
<pre
*ngIf="details[i]"
class="m-0"
><code>{{ details[i] | json }}</code></pre>
</mat-expansion-panel>
</mat-accordion>
<div class="mt-2">
<button mat-button (click)="onReset()">
<ion-icon class="mr-2" name="arrow-back-outline"></ion-icon>
<span i18n>Back</span>
</button>
</div>
</mat-panel-title>
</mat-expansion-panel-header>
<pre
*ngIf="details[i]"
class="m-0"
><code>{{ details[i] | json }}</code></pre>
</mat-expansion-panel>
</mat-accordion>
<div class="mt-2">
<button mat-button (click)="onReset()">
<ion-icon class="mr-2" name="arrow-back-outline"></ion-icon>
<span i18n>Back</span>
</button>
</div>
</ng-template>
</ng-container>
</div>
<div *ngIf="isFileSelected" class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button
color="primary"
mat-flat-button
[disabled]="!selectedActivities?.length"
(click)="onImportActivities()"
>
<ng-container i18n>Import</ng-container>
</button>
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"

View File

@@ -3,6 +3,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatExpansionModule } from '@angular/material/expansion';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
@@ -12,6 +13,7 @@ import { ImportActivitiesDialog } from './import-activities-dialog.component';
declarations: [ImportActivitiesDialog],
imports: [
CommonModule,
GfActivitiesTableModule,
GfDialogFooterModule,
GfDialogHeaderModule,
MatButtonModule,

View File

@@ -1,6 +1,7 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { Account, DataSource, Type } from '@prisma/client';
import { isMatch, parse, parseISO } from 'date-fns';
import { isFinite } from 'lodash';
@@ -25,12 +26,14 @@ export class ImportActivitiesService {
public constructor(private http: HttpClient) {}
public async importCsv({
dryRun = false,
fileContent,
userAccounts
}: {
dryRun?: boolean;
fileContent: string;
userAccounts: Account[];
}) {
}): Promise<Activity[]> {
const content = csvToJson(fileContent, {
dynamicTyping: true,
header: true,
@@ -52,14 +55,23 @@ export class ImportActivitiesService {
});
}
await this.importJson({ content: activities });
return await this.importJson({ content: activities, dryRun });
}
public importJson({ content }: { content: CreateOrderDto[] }): Promise<void> {
public importJson({
content,
dryRun = false
}: {
content: CreateOrderDto[];
dryRun?: boolean;
}): Promise<Activity[]> {
return new Promise((resolve, reject) => {
this.postImport({
activities: content
})
this.postImport(
{
activities: content
},
dryRun
)
.pipe(
catchError((error) => {
reject(error);
@@ -67,13 +79,35 @@ export class ImportActivitiesService {
})
)
.subscribe({
next: () => {
resolve();
next: (data) => {
resolve(data.activities);
}
});
});
}
public importSelectedActivities(
selectedActivities: Activity[]
): Promise<Activity[]> {
const importData: CreateOrderDto[] = [];
for (const activity of selectedActivities) {
importData.push(this.convertToCreateOrderDto(activity));
}
return this.importJson({ content: importData });
}
private convertToCreateOrderDto(aActivity: Activity): CreateOrderDto {
return {
currency: aActivity.SymbolProfile.currency,
date: aActivity.date.toString(),
fee: aActivity.fee,
quantity: aActivity.quantity,
symbol: aActivity.SymbolProfile.symbol,
type: aActivity.type,
unitPrice: aActivity.unitPrice
};
}
private lowercaseKeys(aObject: any) {
return Object.keys(aObject).reduce((acc, key) => {
acc[key.toLowerCase()] = aObject[key];
@@ -301,7 +335,13 @@ export class ImportActivitiesService {
};
}
private postImport(aImportData: { activities: CreateOrderDto[] }) {
return this.http.post<void>('/api/v1/import', aImportData);
private postImport(
aImportData: { activities: CreateOrderDto[] },
dryRun = false
) {
return this.http.post<{ activities: Activity[] }>(
`/api/v1/import?dryRun=${dryRun}`,
aImportData
);
}
}