From 48b524de5aecaa091819e2b725a03e3208a357fe Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 5 Feb 2022 20:26:10 +0100 Subject: [PATCH] Feature/add export functionality to position detail dialog (#672) * Add export functionality to the position detail dialog * Respect filters in activities export * Update changelog --- CHANGELOG.md | 10 ++++++ apps/api/src/app/export/export.controller.ts | 16 ++++++++-- apps/api/src/app/export/export.service.ts | 17 ++++++++-- .../home-holdings/home-holdings.component.ts | 11 +++++++ .../interfaces/interfaces.ts | 1 + .../position-detail-dialog.component.ts | 22 ++++++++++++- .../position-detail-dialog.html | 2 ++ .../allocations/allocations-page.component.ts | 1 + .../transactions-page.component.ts | 31 ++++++------------- .../transactions/transactions-page.html | 3 +- apps/client/src/app/services/data.service.ts | 12 +++++-- libs/common/src/lib/helper.ts | 14 +++++++++ .../activities-table.component.html | 5 +++ .../activities-table.component.ts | 20 +++++++----- 14 files changed, 127 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e27515571..679bb42fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Added diff --git a/apps/api/src/app/export/export.controller.ts b/apps/api/src/app/export/export.controller.ts index ca318ce81..3617ebe24 100644 --- a/apps/api/src/app/export/export.controller.ts +++ b/apps/api/src/app/export/export.controller.ts @@ -1,6 +1,13 @@ import { Export } from '@ghostfolio/common/interfaces'; 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 { AuthGuard } from '@nestjs/passport'; @@ -15,8 +22,11 @@ export class ExportController { @Get() @UseGuards(AuthGuard('jwt')) - public async export(): Promise { - return await this.exportService.export({ + public async export( + @Query('activityIds') activityIds?: string[] + ): Promise { + return this.exportService.export({ + activityIds, userId: this.request.user.id }); } diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index 30b1ed082..301f13cea 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -7,8 +7,14 @@ import { Injectable } from '@nestjs/common'; export class ExportService { public constructor(private readonly prismaService: PrismaService) {} - public async export({ userId }: { userId: string }): Promise { - const orders = await this.prismaService.order.findMany({ + public async export({ + activityIds, + userId + }: { + activityIds?: string[]; + userId: string; + }): Promise { + let orders = await this.prismaService.order.findMany({ orderBy: { date: 'desc' }, select: { accountId: true, @@ -16,6 +22,7 @@ export class ExportService { dataSource: true, date: true, fee: true, + id: true, quantity: true, SymbolProfile: true, type: true, @@ -24,6 +31,12 @@ export class ExportService { where: { userId } }); + if (activityIds) { + orders = orders.filter((order) => { + return activityIds.includes(order.id); + }); + } + return { meta: { date: new Date().toISOString(), version: environment.version }, orders: orders.map( diff --git a/apps/client/src/app/components/home-holdings/home-holdings.component.ts b/apps/client/src/app/components/home-holdings/home-holdings.component.ts index c7e964372..50c452ea8 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.component.ts +++ b/apps/client/src/app/components/home-holdings/home-holdings.component.ts @@ -3,6 +3,7 @@ import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router } from '@angular/router'; import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component'; import { DataService } from '@ghostfolio/client/services/data.service'; +import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { RANGE, SettingsStorageService @@ -26,6 +27,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { public dateRange: DateRange; public dateRangeOptions = defaultDateRangeOptions; public deviceType: string; + public hasImpersonationId: boolean; public hasPermissionToCreateOrder: boolean; public positions: Position[]; public user: User; @@ -40,6 +42,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { private dataService: DataService, private deviceService: DeviceDetectorService, private dialog: MatDialog, + private impersonationStorageService: ImpersonationStorageService, private route: ActivatedRoute, private router: Router, private settingsStorageService: SettingsStorageService, @@ -82,6 +85,13 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { public ngOnInit() { this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.impersonationStorageService + .onChangeHasImpersonation() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((aId) => { + this.hasImpersonationId = !!aId; + }); + this.dateRange = this.settingsStorageService.getSetting(RANGE) || 'max'; @@ -119,6 +129,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { symbol, baseCurrency: this.user?.settings?.baseCurrency, deviceType: this.deviceType, + hasImpersonationId: this.hasImpersonationId, locale: this.user?.settings?.locale }, height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', diff --git a/apps/client/src/app/components/position/position-detail-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/position/position-detail-dialog/interfaces/interfaces.ts index daac4065a..791c2b46a 100644 --- a/apps/client/src/app/components/position/position-detail-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/position/position-detail-dialog/interfaces/interfaces.ts @@ -4,6 +4,7 @@ export interface PositionDetailDialogParams { baseCurrency: string; dataSource: DataSource; deviceType: string; + hasImpersonationId: boolean; locale: string; symbol: string; } diff --git a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts index 1802619a0..02563afba 100644 --- a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts +++ b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts @@ -8,7 +8,7 @@ import { } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; 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 { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface'; import { AssetSubClass } from '@prisma/client'; @@ -185,6 +185,26 @@ export class PositionDetailDialog implements OnDestroy, OnInit { 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() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); diff --git a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html index 00d949263..db8f78bc3 100644 --- a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html +++ b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html @@ -131,12 +131,14 @@ [baseCurrency]="data.baseCurrency" [deviceType]="data.deviceType" [hasPermissionToCreateActivity]="false" + [hasPermissionToExportActivities]="!hasImpersonationId" [hasPermissionToFilter]="false" [hasPermissionToImportActivities]="false" [hasPermissionToOpenDetails]="false" [locale]="data.locale" [showActions]="false" [showSymbolColumn]="false" + (export)="onExport()" > diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index f89297403..82a7fdcc4 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -316,6 +316,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { symbol, baseCurrency: this.user?.settings?.baseCurrency, deviceType: this.deviceType, + hasImpersonationId: this.hasImpersonationId, locale: this.user?.settings?.locale }, height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', diff --git a/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts b/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts index 8172ed080..abd99042d 100644 --- a/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts +++ b/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts @@ -10,6 +10,7 @@ import { DataService } from '@ghostfolio/client/services/data.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { downloadAsFile } from '@ghostfolio/common/helper'; import { User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { DataSource, Order as OrderModel } from '@prisma/client'; @@ -90,11 +91,6 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { public ngOnInit() { const { globalPermissions } = this.dataService.fetchInfo(); - this.hasPermissionToImportOrders = hasPermission( - globalPermissions, - permissions.enableImport - ); - this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.impersonationStorageService @@ -102,6 +98,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((aId) => { this.hasImpersonationId = !!aId; + + this.hasPermissionToImportOrders = + hasPermission(globalPermissions, permissions.enableImport) && + !this.hasImpersonationId; }); this.userService.stateChanged @@ -147,12 +147,12 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { }); } - public onExport() { + public onExport(activityIds?: string[]) { this.dataService - .fetchExport() + .fetchExport(activityIds) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((data) => { - this.downloadAsFile( + downloadAsFile( data, `ghostfolio-export-${format( parseISO(data.meta.date), @@ -303,20 +303,6 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { 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[] }) { this.snackBar.dismiss(); @@ -406,6 +392,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { symbol, baseCurrency: this.user?.settings?.baseCurrency, deviceType: this.deviceType, + hasImpersonationId: this.hasImpersonationId, locale: this.user?.settings?.locale }, height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', diff --git a/apps/client/src/app/pages/portfolio/transactions/transactions-page.html b/apps/client/src/app/pages/portfolio/transactions/transactions-page.html index db365c2d9..0df0171b9 100644 --- a/apps/client/src/app/pages/portfolio/transactions/transactions-page.html +++ b/apps/client/src/app/pages/portfolio/transactions/transactions-page.html @@ -7,13 +7,14 @@ [baseCurrency]="user?.settings?.baseCurrency" [deviceType]="deviceType" [hasPermissionToCreateActivity]="hasPermissionToCreateOrder" + [hasPermissionToExportActivities]="!hasImpersonationId" [hasPermissionToImportActivities]="hasPermissionToImportOrders" [locale]="user?.settings?.locale" [showActions]="!hasImpersonationId && hasPermissionToDeleteOrder && !user.settings.isRestrictedView" (activityDeleted)="onDeleteTransaction($event)" (activityToClone)="onCloneTransaction($event)" (activityToUpdate)="onUpdateTransaction($event)" - (export)="onExport()" + (export)="onExport($event)" (import)="onImport()" > diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index bf03b3f5f..fac56a1f2 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -94,8 +94,16 @@ export class DataService { }); } - public fetchExport() { - return this.http.get('/api/export'); + public fetchExport(activityIds?: string[]) { + let params = new HttpParams(); + + if (activityIds) { + params = params.append('activityIds', activityIds.join(',')); + } + + return this.http.get('/api/export', { + params + }); } public fetchInfo(): InfoItem { diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 4fac26654..dbfc787f3 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -12,6 +12,20 @@ export function decodeDataSource(encodedDataSource: string) { 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) { return Buffer.from(aDataSource, 'utf-8').toString('hex'); } diff --git a/libs/ui/src/lib/activities-table/activities-table.component.html b/libs/ui/src/lib/activities-table/activities-table.component.html index 5af6013bb..31475941f 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.html +++ b/libs/ui/src/lib/activities-table/activities-table.component.html @@ -268,6 +268,9 @@