Feature/improve usability of import (#1425)
* Improve usability by adding expected file format * Update changelog
This commit is contained in:
parent
6d12c27f9c
commit
b8574d24b2
@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Improved the usability of the activities import
|
||||||
- Improved the usage of the premium indicator component
|
- Improved the usage of the premium indicator component
|
||||||
- Removed the intro image in dark mode
|
- Removed the intro image in dark mode
|
||||||
- Refactored the `TransactionsPageComponent` to `ActivitiesPageComponent`
|
- Refactored the `TransactionsPageComponent` to `ActivitiesPageComponent`
|
||||||
|
@ -17,13 +17,13 @@ import { User } from '@ghostfolio/common/interfaces';
|
|||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { DataSource, Order as OrderModel } from '@prisma/client';
|
import { DataSource, Order as OrderModel } from '@prisma/client';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import { isArray } from 'lodash';
|
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject, Subscription } from 'rxjs';
|
import { Subject, Subscription } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog/create-or-update-activity-dialog.component';
|
import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog/create-or-update-activity-dialog.component';
|
||||||
import { ImportActivitiesDialog } from './import-activities-dialog/import-activities-dialog.component';
|
import { ImportActivitiesDialog } from './import-activities-dialog/import-activities-dialog.component';
|
||||||
|
import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
host: { class: 'page' },
|
host: { class: 'page' },
|
||||||
@ -51,10 +51,8 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
|||||||
private dialog: MatDialog,
|
private dialog: MatDialog,
|
||||||
private icsService: IcsService,
|
private icsService: IcsService,
|
||||||
private impersonationStorageService: ImpersonationStorageService,
|
private impersonationStorageService: ImpersonationStorageService,
|
||||||
private importActivitiesService: ImportActivitiesService,
|
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private snackBar: MatSnackBar,
|
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
this.routeQueryParams = route.queryParams
|
this.routeQueryParams = route.queryParams
|
||||||
@ -186,88 +184,20 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onImport() {
|
public onImport() {
|
||||||
const input = document.createElement('input');
|
const dialogRef = this.dialog.open(ImportActivitiesDialog, {
|
||||||
input.accept = 'application/JSON, .csv';
|
data: <ImportActivitiesDialogParams>{
|
||||||
input.type = 'file';
|
deviceType: this.deviceType,
|
||||||
|
user: this.user
|
||||||
|
},
|
||||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
|
});
|
||||||
|
|
||||||
input.onchange = (event) => {
|
dialogRef
|
||||||
this.snackBar.open('⏳ ' + $localize`Importing data...`);
|
.afterClosed()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
// Getting the file reference
|
.subscribe(() => {
|
||||||
const file = (event.target as HTMLInputElement).files[0];
|
this.fetchActivities();
|
||||||
|
});
|
||||||
// Setting up the reader
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.readAsText(file, 'UTF-8');
|
|
||||||
|
|
||||||
reader.onload = async (readerEvent) => {
|
|
||||||
const fileContent = readerEvent.target.result as string;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (file.name.endsWith('.json')) {
|
|
||||||
const content = JSON.parse(fileContent);
|
|
||||||
|
|
||||||
if (!isArray(content.activities)) {
|
|
||||||
if (isArray(content.orders)) {
|
|
||||||
this.handleImportError({
|
|
||||||
activities: [],
|
|
||||||
error: {
|
|
||||||
error: {
|
|
||||||
message: [`orders needs to be renamed to activities`]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
throw new Error();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.importActivitiesService.importJson({
|
|
||||||
content: content.activities
|
|
||||||
});
|
|
||||||
|
|
||||||
this.handleImportSuccess();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
this.handleImportError({ error, activities: content.activities });
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
} else if (file.name.endsWith('.csv')) {
|
|
||||||
try {
|
|
||||||
await this.importActivitiesService.importCsv({
|
|
||||||
fileContent,
|
|
||||||
userAccounts: this.user.accounts
|
|
||||||
});
|
|
||||||
|
|
||||||
this.handleImportSuccess();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
this.handleImportError({
|
|
||||||
activities: error?.activities ?? [],
|
|
||||||
error: {
|
|
||||||
error: { message: error?.error?.message ?? [error?.message] }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
this.handleImportError({
|
|
||||||
activities: [],
|
|
||||||
error: { error: { message: ['Unexpected format'] } }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
input.click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public onUpdateActivity(aActivity: OrderModel) {
|
public onUpdateActivity(aActivity: OrderModel) {
|
||||||
@ -315,37 +245,6 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
|||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleImportError({
|
|
||||||
activities,
|
|
||||||
error
|
|
||||||
}: {
|
|
||||||
activities: any[];
|
|
||||||
error: any;
|
|
||||||
}) {
|
|
||||||
this.snackBar.dismiss();
|
|
||||||
|
|
||||||
this.dialog.open(ImportActivitiesDialog, {
|
|
||||||
data: {
|
|
||||||
activities,
|
|
||||||
deviceType: this.deviceType,
|
|
||||||
messages: error?.error?.message
|
|
||||||
},
|
|
||||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleImportSuccess() {
|
|
||||||
this.fetchActivities();
|
|
||||||
|
|
||||||
this.snackBar.open(
|
|
||||||
'✅ ' + $localize`Import has been completed`,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
duration: 3000
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private openCreateActivityDialog(aActivity?: Activity): void {
|
private openCreateActivityDialog(aActivity?: Activity): void {
|
||||||
this.userService
|
this.userService
|
||||||
.get()
|
.get()
|
||||||
|
@ -1,10 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
Inject,
|
Inject,
|
||||||
OnDestroy
|
OnDestroy
|
||||||
} 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 { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
|
||||||
|
import { isArray } from 'lodash';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
import { ImportActivitiesDialogParams } from './interfaces/interfaces';
|
import { ImportActivitiesDialogParams } from './interfaces/interfaces';
|
||||||
@ -17,34 +21,156 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
|
|||||||
})
|
})
|
||||||
export class ImportActivitiesDialog implements OnDestroy {
|
export class ImportActivitiesDialog implements OnDestroy {
|
||||||
public details: any[] = [];
|
public details: any[] = [];
|
||||||
|
public errorMessages: string[] = [];
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
@Inject(MAT_DIALOG_DATA) public data: ImportActivitiesDialogParams,
|
@Inject(MAT_DIALOG_DATA) public data: ImportActivitiesDialogParams,
|
||||||
public dialogRef: MatDialogRef<ImportActivitiesDialog>
|
public dialogRef: MatDialogRef<ImportActivitiesDialog>,
|
||||||
|
private importActivitiesService: ImportActivitiesService,
|
||||||
|
private snackBar: MatSnackBar
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {}
|
||||||
for (const message of this.data.messages) {
|
|
||||||
if (message.includes('activities.')) {
|
|
||||||
let [index] = message.split(' ');
|
|
||||||
index = index.replace('activities.', '');
|
|
||||||
[index] = index.split('.');
|
|
||||||
|
|
||||||
this.details.push(this.data.activities[index]);
|
|
||||||
} else {
|
|
||||||
this.details.push('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public onCancel(): void {
|
public onCancel(): void {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onImport() {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.accept = 'application/JSON, .csv';
|
||||||
|
input.type = 'file';
|
||||||
|
|
||||||
|
input.onchange = (event) => {
|
||||||
|
this.snackBar.open('⏳ ' + $localize`Importing data...`);
|
||||||
|
|
||||||
|
// Getting the file reference
|
||||||
|
const file = (event.target as HTMLInputElement).files[0];
|
||||||
|
|
||||||
|
// Setting up the reader
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsText(file, 'UTF-8');
|
||||||
|
|
||||||
|
reader.onload = async (readerEvent) => {
|
||||||
|
const fileContent = readerEvent.target.result as string;
|
||||||
|
|
||||||
|
console.log(fileContent);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (file.name.endsWith('.json')) {
|
||||||
|
const content = JSON.parse(fileContent);
|
||||||
|
|
||||||
|
if (!isArray(content.activities)) {
|
||||||
|
if (isArray(content.orders)) {
|
||||||
|
this.handleImportError({
|
||||||
|
activities: [],
|
||||||
|
error: {
|
||||||
|
error: {
|
||||||
|
message: [`orders needs to be renamed to activities`]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.importActivitiesService.importJson({
|
||||||
|
content: content.activities
|
||||||
|
});
|
||||||
|
|
||||||
|
this.handleImportSuccess();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
this.handleImportError({ error, activities: content.activities });
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
} else if (file.name.endsWith('.csv')) {
|
||||||
|
try {
|
||||||
|
await this.importActivitiesService.importCsv({
|
||||||
|
fileContent,
|
||||||
|
userAccounts: this.data.user.accounts
|
||||||
|
});
|
||||||
|
|
||||||
|
this.handleImportSuccess();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
this.handleImportError({
|
||||||
|
activities: error?.activities ?? [],
|
||||||
|
error: {
|
||||||
|
error: { message: error?.error?.message ?? [error?.message] }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
this.handleImportError({
|
||||||
|
activities: [],
|
||||||
|
error: { error: { message: ['Unexpected format'] } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onReset() {
|
||||||
|
this.details = [];
|
||||||
|
this.errorMessages = [];
|
||||||
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleImportError({
|
||||||
|
activities,
|
||||||
|
error
|
||||||
|
}: {
|
||||||
|
activities: any[];
|
||||||
|
error: any;
|
||||||
|
}) {
|
||||||
|
this.snackBar.dismiss();
|
||||||
|
|
||||||
|
this.errorMessages = error?.error?.message;
|
||||||
|
|
||||||
|
for (const message of this.errorMessages) {
|
||||||
|
if (message.includes('activities.')) {
|
||||||
|
let [index] = message.split(' ');
|
||||||
|
index = index.replace('activities.', '');
|
||||||
|
[index] = index.split('.');
|
||||||
|
|
||||||
|
this.details.push(activities[index]);
|
||||||
|
} else {
|
||||||
|
this.details.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleImportSuccess() {
|
||||||
|
this.snackBar.open(
|
||||||
|
'✅ ' + $localize`Import has been completed`,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
duration: 3000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,32 +1,67 @@
|
|||||||
<gf-dialog-header
|
<gf-dialog-header
|
||||||
mat-dialog-title
|
mat-dialog-title
|
||||||
title="Import Activities Error"
|
|
||||||
[deviceType]="data.deviceType"
|
[deviceType]="data.deviceType"
|
||||||
|
[title]="errorMessages.length === 0 ? 'Import Activities' : 'Import Activities Error'"
|
||||||
(closeButtonClicked)="onCancel()"
|
(closeButtonClicked)="onCancel()"
|
||||||
></gf-dialog-header>
|
></gf-dialog-header>
|
||||||
|
|
||||||
<div class="flex-grow-1" mat-dialog-content>
|
<div class="flex-grow-1" mat-dialog-content>
|
||||||
<mat-accordion displayMode="flat">
|
<ng-container *ngIf="errorMessages.length === 0">
|
||||||
<mat-expansion-panel
|
<div class="d-flex justify-content-center flex-column">
|
||||||
*ngFor="let message of data.messages; let i = index"
|
<button
|
||||||
[disabled]="!details[i]"
|
class="py-3"
|
||||||
>
|
color="primary"
|
||||||
<mat-expansion-panel-header class="pl-1">
|
mat-stroked-button
|
||||||
<mat-panel-title>
|
(click)="onImport()"
|
||||||
<div class="d-flex">
|
>
|
||||||
<div class="align-items-center d-flex mr-2">
|
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
|
||||||
<ion-icon name="warning-outline"></ion-icon>
|
<span i18n>Upload File</span>
|
||||||
|
</button>
|
||||||
|
<p class="mb-0 mt-4 text-center">
|
||||||
|
<span class="mr-1" i18n>The following file formats are expected:</span>
|
||||||
|
<a
|
||||||
|
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.csv"
|
||||||
|
target="_blank"
|
||||||
|
>CSV</a
|
||||||
|
>
|
||||||
|
<span class="mx-1" i18n>or</span>
|
||||||
|
<a
|
||||||
|
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.json"
|
||||||
|
target="_blank"
|
||||||
|
>JSON</a
|
||||||
|
>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
<div>{{ message }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>{{ message }}</div>
|
</mat-panel-title>
|
||||||
</div>
|
</mat-expansion-panel-header>
|
||||||
</mat-panel-title>
|
<pre
|
||||||
</mat-expansion-panel-header>
|
*ngIf="details[i]"
|
||||||
<pre
|
class="m-0"
|
||||||
*ngIf="details[i]"
|
><code>{{ details[i] | json }}</code></pre>
|
||||||
class="m-0"
|
</mat-expansion-panel>
|
||||||
><code>{{ details[i] | json }}</code></pre>
|
</mat-accordion>
|
||||||
</mat-expansion-panel>
|
<div class="mt-2">
|
||||||
</mat-accordion>
|
<button mat-button (click)="onReset()">
|
||||||
|
<ion-icon class="mr-2" name="arrow-back-outline"></ion-icon>
|
||||||
|
<span i18n>Back</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<gf-dialog-footer
|
<gf-dialog-footer
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgba(var(--palette-primary-500), 1);
|
||||||
|
}
|
||||||
|
|
||||||
.mat-expansion-panel {
|
.mat-expansion-panel {
|
||||||
background: none;
|
background: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
|
import { User } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
export interface ImportActivitiesDialogParams {
|
export interface ImportActivitiesDialogParams {
|
||||||
activities: any[];
|
|
||||||
deviceType: string;
|
deviceType: string;
|
||||||
messages: string[];
|
user: User;
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user