Feature/improve activities import (#1531)

pull/1541/head
Yash Solanki 2 years ago committed by GitHub
parent c22733db56
commit a08610b603
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

@ -6,13 +6,13 @@
></gf-dialog-header> ></gf-dialog-header>
<div class="flex-grow-1" mat-dialog-content> <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"> <div class="d-flex justify-content-center flex-column">
<button <button
class="py-3" class="py-3"
color="primary" color="primary"
mat-stroked-button mat-stroked-button
(click)="onImport()" (click)="onSelectFile()"
> >
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon> <ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
<span i18n>Choose File</span> <span i18n>Choose File</span>
@ -33,7 +33,25 @@
</p> </p>
</div> </div>
</ng-container> </ng-container>
<ng-container *ngIf="errorMessages.length > 0"> <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-accordion displayMode="flat">
<mat-expansion-panel <mat-expansion-panel
*ngFor="let message of errorMessages; let i = index" *ngFor="let message of errorMessages; let i = index"
@ -61,9 +79,22 @@
<span i18n>Back</span> <span i18n>Back</span>
</button> </button>
</div> </div>
</ng-template>
</ng-container> </ng-container>
</div> </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 <gf-dialog-footer
mat-dialog-actions mat-dialog-actions
[deviceType]="data.deviceType" [deviceType]="data.deviceType"

@ -3,6 +3,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatExpansionModule } from '@angular/material/expansion'; 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 { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.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], declarations: [ImportActivitiesDialog],
imports: [ imports: [
CommonModule, CommonModule,
GfActivitiesTableModule,
GfDialogFooterModule, GfDialogFooterModule,
GfDialogHeaderModule, GfDialogHeaderModule,
MatButtonModule, MatButtonModule,

@ -1,6 +1,7 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; 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 { Account, DataSource, Type } from '@prisma/client';
import { isMatch, parse, parseISO } from 'date-fns'; import { isMatch, parse, parseISO } from 'date-fns';
import { isFinite } from 'lodash'; import { isFinite } from 'lodash';
@ -25,12 +26,14 @@ export class ImportActivitiesService {
public constructor(private http: HttpClient) {} public constructor(private http: HttpClient) {}
public async importCsv({ public async importCsv({
dryRun = false,
fileContent, fileContent,
userAccounts userAccounts
}: { }: {
dryRun?: boolean;
fileContent: string; fileContent: string;
userAccounts: Account[]; userAccounts: Account[];
}) { }): Promise<Activity[]> {
const content = csvToJson(fileContent, { const content = csvToJson(fileContent, {
dynamicTyping: true, dynamicTyping: true,
header: 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) => { return new Promise((resolve, reject) => {
this.postImport({ this.postImport(
{
activities: content activities: content
}) },
dryRun
)
.pipe( .pipe(
catchError((error) => { catchError((error) => {
reject(error); reject(error);
@ -67,13 +79,35 @@ export class ImportActivitiesService {
}) })
) )
.subscribe({ .subscribe({
next: () => { next: (data) => {
resolve(); 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) { private lowercaseKeys(aObject: any) {
return Object.keys(aObject).reduce((acc, key) => { return Object.keys(aObject).reduce((acc, key) => {
acc[key.toLowerCase()] = aObject[key]; acc[key.toLowerCase()] = aObject[key];
@ -301,7 +335,13 @@ export class ImportActivitiesService {
}; };
} }
private postImport(aImportData: { activities: CreateOrderDto[] }) { private postImport(
return this.http.post<void>('/api/v1/import', aImportData); aImportData: { activities: CreateOrderDto[] },
dryRun = false
) {
return this.http.post<{ activities: Activity[] }>(
`/api/v1/import?dryRun=${dryRun}`,
aImportData
);
} }
} }

@ -15,6 +15,26 @@
matSortDirection="desc" matSortDirection="desc"
[dataSource]="dataSource" [dataSource]="dataSource"
> >
<ng-container matColumnDef="select">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<mat-checkbox
class="mt-2"
[checked]="selectedRows.hasValue() && areAllRowsSelected()"
[indeterminate]="selectedRows.hasValue() && !areAllRowsSelected()"
(change)="$event ? toggleAllRows() : null"
></mat-checkbox>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<mat-checkbox
class="mt-2"
[checked]="selectedRows.isSelected(element)"
(change)="$event ? selectedRows.toggle(element) : null"
(click)="$event.stopPropagation()"
></mat-checkbox>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<ng-container matColumnDef="count"> <ng-container matColumnDef="count">
<th <th
*matHeaderCellDef *matHeaderCellDef
@ -45,7 +65,7 @@
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex"> <div class="d-flex">
{{ element.date | date: defaultDateFormat }} {{ element.date | date : defaultDateFormat }}
</div> </div>
</td> </td>
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td> <td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td>
@ -432,15 +452,7 @@
'cursor-pointer': 'cursor-pointer':
hasPermissionToOpenDetails && !row.isDraft && row.type !== 'ITEM' hasPermissionToOpenDetails && !row.isDraft && row.type !== 'ITEM'
}" }"
(click)=" (click)="onClickActivity(row)"
hasPermissionToOpenDetails &&
!row.isDraft &&
row.type !== 'ITEM' &&
onOpenPositionDialog({
dataSource: row.SymbolProfile.dataSource,
symbol: row.SymbolProfile.symbol
})
"
></tr> ></tr>
<tr <tr
*matFooterRowDef="displayedColumns" *matFooterRowDef="displayedColumns"

@ -8,6 +8,7 @@ import {
Output, Output,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { SelectionModel } from '@angular/cdk/collections';
import { MatPaginator, PageEvent } from '@angular/material/paginator'; import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort'; import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
@ -41,6 +42,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
@Input() locale: string; @Input() locale: string;
@Input() pageSize = DEFAULT_PAGE_SIZE; @Input() pageSize = DEFAULT_PAGE_SIZE;
@Input() showActions: boolean; @Input() showActions: boolean;
@Input() showCheckbox = false;
@Input() showNameColumn = true; @Input() showNameColumn = true;
@Output() activityDeleted = new EventEmitter<string>(); @Output() activityDeleted = new EventEmitter<string>();
@ -49,6 +51,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
@Output() export = new EventEmitter<string[]>(); @Output() export = new EventEmitter<string[]>();
@Output() exportDrafts = new EventEmitter<string[]>(); @Output() exportDrafts = new EventEmitter<string[]>();
@Output() import = new EventEmitter<void>(); @Output() import = new EventEmitter<void>();
@Output() selectedActivities = new EventEmitter<Activity[]>();
@ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
@ -67,6 +70,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
public placeholder = ''; public placeholder = '';
public routeQueryParams: Subscription; public routeQueryParams: Subscription;
public searchKeywords: string[] = []; public searchKeywords: string[] = [];
public selectedRows = new SelectionModel<Activity>(true, []);
public totalFees: number; public totalFees: number;
public totalValue: number; public totalValue: number;
@ -81,8 +85,15 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
}); });
} }
public areAllRowsSelected() {
const numSelectedRows = this.selectedRows.selected.length;
const numTotalRows = this.dataSource.data.length;
return numSelectedRows === numTotalRows;
}
public ngOnChanges() { public ngOnChanges() {
this.displayedColumns = [ this.displayedColumns = [
'select',
'count', 'count',
'date', 'date',
'type', 'type',
@ -98,6 +109,16 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
'actions' 'actions'
]; ];
if (this.showCheckbox) {
this.displayedColumns = this.displayedColumns.filter((column) => {
return column !== 'count';
});
} else {
this.displayedColumns = this.displayedColumns.filter((column) => {
return column !== 'select';
});
}
if (!this.showNameColumn) { if (!this.showNameColumn) {
this.displayedColumns = this.displayedColumns.filter((column) => { this.displayedColumns = this.displayedColumns.filter((column) => {
return column !== 'nameWithSymbol'; return column !== 'nameWithSymbol';
@ -133,6 +154,17 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
} }
} }
ngOnInit() {
if (this.showCheckbox) {
this.toggleAllRows();
this.selectedRows.changed
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((selectedRows) => {
this.selectedActivities.emit(selectedRows.source.selected);
});
}
}
public onChangePage(page: PageEvent) { public onChangePage(page: PageEvent) {
this.pageIndex = page.pageIndex; this.pageIndex = page.pageIndex;
@ -140,6 +172,21 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
this.totalValue = this.getTotalValue(); this.totalValue = this.getTotalValue();
} }
public onClickActivity(activity: Activity) {
if (this.showCheckbox) {
this.selectedRows.toggle(activity);
} else if (
this.hasPermissionToOpenDetails &&
!activity.isDraft &&
activity.type !== 'ITEM'
) {
this.onOpenPositionDialog({
dataSource: activity.SymbolProfile.dataSource,
symbol: activity.SymbolProfile.symbol
});
}
}
public onCloneActivity(aActivity: OrderWithAccount) { public onCloneActivity(aActivity: OrderWithAccount) {
this.activityToClone.emit(aActivity); this.activityToClone.emit(aActivity);
} }
@ -200,6 +247,14 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
this.activityToUpdate.emit(aActivity); this.activityToUpdate.emit(aActivity);
} }
public toggleAllRows() {
this.areAllRowsSelected()
? this.selectedRows.clear()
: this.dataSource.data.forEach((row) => this.selectedRows.select(row));
this.selectedActivities.emit(this.selectedRows.selected);
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
@ -237,9 +292,9 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
type: 'TAG' type: 'TAG'
}; };
fieldValueMap[format(activity.date, 'yyyy')] = { fieldValueMap[format(new Date(activity.date), 'yyyy')] = {
id: format(activity.date, 'yyyy'), id: format(new Date(activity.date), 'yyyy'),
label: format(activity.date, 'yyyy'), label: format(new Date(activity.date), 'yyyy'),
type: 'TAG' type: 'TAG'
}; };

@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule } from '@angular/material/paginator'; import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort'; import { MatSortModule } from '@angular/material/sort';
@ -26,6 +27,7 @@ import { ActivitiesTableComponent } from './activities-table.component';
GfSymbolModule, GfSymbolModule,
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,
MatCheckboxModule,
MatMenuModule, MatMenuModule,
MatPaginatorModule, MatPaginatorModule,
MatSortModule, MatSortModule,

Loading…
Cancel
Save