Feature/add export functionality to position detail dialog (#672)

* Add export functionality to the position detail dialog

* Respect filters in activities export

* Update changelog
This commit is contained in:
Thomas Kaul 2022-02-05 20:26:10 +01:00 committed by GitHub
parent 67d40333f6
commit 48b524de5a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 127 additions and 38 deletions

View File

@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Added the export functionality to the position detail dialog
### Changed
- Improved the export functionality for activities (respect filtering)
## 1.111.0 - 03.02.2022 ## 1.111.0 - 03.02.2022
### Added ### Added

View File

@ -1,6 +1,13 @@
import { Export } from '@ghostfolio/common/interfaces'; import { Export } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; import {
Controller,
Get,
Headers,
Inject,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -15,8 +22,11 @@ export class ExportController {
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async export(): Promise<Export> { public async export(
return await this.exportService.export({ @Query('activityIds') activityIds?: string[]
): Promise<Export> {
return this.exportService.export({
activityIds,
userId: this.request.user.id userId: this.request.user.id
}); });
} }

View File

@ -7,8 +7,14 @@ import { Injectable } from '@nestjs/common';
export class ExportService { export class ExportService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async export({ userId }: { userId: string }): Promise<Export> { public async export({
const orders = await this.prismaService.order.findMany({ activityIds,
userId
}: {
activityIds?: string[];
userId: string;
}): Promise<Export> {
let orders = await this.prismaService.order.findMany({
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
select: { select: {
accountId: true, accountId: true,
@ -16,6 +22,7 @@ export class ExportService {
dataSource: true, dataSource: true,
date: true, date: true,
fee: true, fee: true,
id: true,
quantity: true, quantity: true,
SymbolProfile: true, SymbolProfile: true,
type: true, type: true,
@ -24,6 +31,12 @@ export class ExportService {
where: { userId } where: { userId }
}); });
if (activityIds) {
orders = orders.filter((order) => {
return activityIds.includes(order.id);
});
}
return { return {
meta: { date: new Date().toISOString(), version: environment.version }, meta: { date: new Date().toISOString(), version: environment.version },
orders: orders.map( orders: orders.map(

View File

@ -3,6 +3,7 @@ import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component'; import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { import {
RANGE, RANGE,
SettingsStorageService SettingsStorageService
@ -26,6 +27,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
public dateRange: DateRange; public dateRange: DateRange;
public dateRangeOptions = defaultDateRangeOptions; public dateRangeOptions = defaultDateRangeOptions;
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean; public hasPermissionToCreateOrder: boolean;
public positions: Position[]; public positions: Position[];
public user: User; public user: User;
@ -40,6 +42,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private settingsStorageService: SettingsStorageService, private settingsStorageService: SettingsStorageService,
@ -82,6 +85,13 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
});
this.dateRange = this.dateRange =
<DateRange>this.settingsStorageService.getSetting(RANGE) || 'max'; <DateRange>this.settingsStorageService.getSetting(RANGE) || 'max';
@ -119,6 +129,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
symbol, symbol,
baseCurrency: this.user?.settings?.baseCurrency, baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType, deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
locale: this.user?.settings?.locale locale: this.user?.settings?.locale
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

View File

@ -4,6 +4,7 @@ export interface PositionDetailDialogParams {
baseCurrency: string; baseCurrency: string;
dataSource: DataSource; dataSource: DataSource;
deviceType: string; deviceType: string;
hasImpersonationId: boolean;
locale: string; locale: string;
symbol: string; symbol: string;
} }

View File

@ -8,7 +8,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 { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface'; import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { AssetSubClass } from '@prisma/client'; import { AssetSubClass } from '@prisma/client';
@ -185,6 +185,26 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.dialogRef.close(); this.dialogRef.close();
} }
public onExport() {
this.dataService
.fetchExport(
this.orders.map((order) => {
return order.id;
})
)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile(
data,
`ghostfolio-export-${this.symbol}-${format(
parseISO(data.meta.date),
'yyyyMMddHHmm'
)}.json`,
'text/plain'
);
});
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

View File

@ -131,12 +131,14 @@
[baseCurrency]="data.baseCurrency" [baseCurrency]="data.baseCurrency"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToFilter]="false" [hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false" [hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[locale]="data.locale" [locale]="data.locale"
[showActions]="false" [showActions]="false"
[showSymbolColumn]="false" [showSymbolColumn]="false"
(export)="onExport()"
></gf-activities-table> ></gf-activities-table>
</div> </div>

View File

@ -316,6 +316,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
symbol, symbol,
baseCurrency: this.user?.settings?.baseCurrency, baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType, deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
locale: this.user?.settings?.locale locale: this.user?.settings?.locale
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

View File

@ -10,6 +10,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service'; import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces'; 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';
@ -90,11 +91,6 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
public ngOnInit() { public ngOnInit() {
const { globalPermissions } = this.dataService.fetchInfo(); const { globalPermissions } = this.dataService.fetchInfo();
this.hasPermissionToImportOrders = hasPermission(
globalPermissions,
permissions.enableImport
);
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService this.impersonationStorageService
@ -102,6 +98,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => { .subscribe((aId) => {
this.hasImpersonationId = !!aId; this.hasImpersonationId = !!aId;
this.hasPermissionToImportOrders =
hasPermission(globalPermissions, permissions.enableImport) &&
!this.hasImpersonationId;
}); });
this.userService.stateChanged this.userService.stateChanged
@ -147,12 +147,12 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
}); });
} }
public onExport() { public onExport(activityIds?: string[]) {
this.dataService this.dataService
.fetchExport() .fetchExport(activityIds)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => { .subscribe((data) => {
this.downloadAsFile( downloadAsFile(
data, data,
`ghostfolio-export-${format( `ghostfolio-export-${format(
parseISO(data.meta.date), parseISO(data.meta.date),
@ -303,20 +303,6 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private downloadAsFile(
aContent: unknown,
aFileName: string,
aContentType: string
) {
const a = document.createElement('a');
const file = new Blob([JSON.stringify(aContent, undefined, ' ')], {
type: aContentType
});
a.href = URL.createObjectURL(file);
a.download = aFileName;
a.click();
}
private handleImportError({ error, orders }: { error: any; orders: any[] }) { private handleImportError({ error, orders }: { error: any; orders: any[] }) {
this.snackBar.dismiss(); this.snackBar.dismiss();
@ -406,6 +392,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
symbol, symbol,
baseCurrency: this.user?.settings?.baseCurrency, baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType, deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
locale: this.user?.settings?.locale locale: this.user?.settings?.locale
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

View File

@ -7,13 +7,14 @@
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder" [hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
[hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToImportActivities]="hasPermissionToImportOrders" [hasPermissionToImportActivities]="hasPermissionToImportOrders"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteOrder && !user.settings.isRestrictedView" [showActions]="!hasImpersonationId && hasPermissionToDeleteOrder && !user.settings.isRestrictedView"
(activityDeleted)="onDeleteTransaction($event)" (activityDeleted)="onDeleteTransaction($event)"
(activityToClone)="onCloneTransaction($event)" (activityToClone)="onCloneTransaction($event)"
(activityToUpdate)="onUpdateTransaction($event)" (activityToUpdate)="onUpdateTransaction($event)"
(export)="onExport()" (export)="onExport($event)"
(import)="onImport()" (import)="onImport()"
></gf-activities-table> ></gf-activities-table>
</div> </div>

View File

@ -94,8 +94,16 @@ export class DataService {
}); });
} }
public fetchExport() { public fetchExport(activityIds?: string[]) {
return this.http.get<Export>('/api/export'); let params = new HttpParams();
if (activityIds) {
params = params.append('activityIds', activityIds.join(','));
}
return this.http.get<Export>('/api/export', {
params
});
} }
public fetchInfo(): InfoItem { public fetchInfo(): InfoItem {

View File

@ -12,6 +12,20 @@ export function decodeDataSource(encodedDataSource: string) {
return Buffer.from(encodedDataSource, 'hex').toString(); return Buffer.from(encodedDataSource, 'hex').toString();
} }
export function downloadAsFile(
aContent: unknown,
aFileName: string,
aContentType: string
) {
const a = document.createElement('a');
const file = new Blob([JSON.stringify(aContent, undefined, ' ')], {
type: aContentType
});
a.href = URL.createObjectURL(file);
a.download = aFileName;
a.click();
}
export function encodeDataSource(aDataSource: DataSource) { export function encodeDataSource(aDataSource: DataSource) {
return Buffer.from(aDataSource, 'utf-8').toString('hex'); return Buffer.from(aDataSource, 'utf-8').toString('hex');
} }

View File

@ -268,6 +268,9 @@
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell> <th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
<button <button
*ngIf="
hasPermissionToExportActivities || hasPermissionToImportActivities
"
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
mat-button mat-button
[matMenuTriggerFor]="activitiesMenu" [matMenuTriggerFor]="activitiesMenu"
@ -286,6 +289,7 @@
<span i18n>Import</span> <span i18n>Import</span>
</button> </button>
<button <button
*ngIf="hasPermissionToExportActivities"
class="align-items-center d-flex" class="align-items-center d-flex"
mat-menu-item mat-menu-item
(click)="onExport()" (click)="onExport()"
@ -297,6 +301,7 @@
</th> </th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell> <td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button <button
*ngIf="this.showActions"
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
mat-button mat-button
[matMenuTriggerFor]="activityMenu" [matMenuTriggerFor]="activityMenu"

View File

@ -43,6 +43,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToCreateActivity: boolean; @Input() hasPermissionToCreateActivity: boolean;
@Input() hasPermissionToExportActivities: boolean;
@Input() hasPermissionToFilter = true; @Input() hasPermissionToFilter = true;
@Input() hasPermissionToImportActivities: boolean; @Input() hasPermissionToImportActivities: boolean;
@Input() hasPermissionToOpenDetails = true; @Input() hasPermissionToOpenDetails = true;
@ -53,7 +54,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
@Output() activityDeleted = new EventEmitter<string>(); @Output() activityDeleted = new EventEmitter<string>();
@Output() activityToClone = new EventEmitter<OrderWithAccount>(); @Output() activityToClone = new EventEmitter<OrderWithAccount>();
@Output() activityToUpdate = new EventEmitter<OrderWithAccount>(); @Output() activityToUpdate = new EventEmitter<OrderWithAccount>();
@Output() export = new EventEmitter<void>(); @Output() export = new EventEmitter<string[]>();
@Output() import = new EventEmitter<void>(); @Output() import = new EventEmitter<void>();
@ViewChild('autocomplete') matAutocomplete: MatAutocomplete; @ViewChild('autocomplete') matAutocomplete: MatAutocomplete;
@ -137,13 +138,10 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
'unitPrice', 'unitPrice',
'fee', 'fee',
'value', 'value',
'account' 'account',
'actions'
]; ];
if (this.showActions) {
this.displayedColumns.push('actions');
}
if (!this.showSymbolColumn) { if (!this.showSymbolColumn) {
this.displayedColumns = this.displayedColumns.filter((column) => { this.displayedColumns = this.displayedColumns.filter((column) => {
return column !== 'symbol'; return column !== 'symbol';
@ -184,7 +182,15 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
} }
public onExport() { public onExport() {
this.export.emit(); if (this.searchKeywords.length > 0) {
this.export.emit(
this.dataSource.filteredData.map((activity) => {
return activity.id;
})
);
} else {
this.export.emit();
}
} }
public onImport() { public onImport() {