diff --git a/CHANGELOG.md b/CHANGELOG.md index ff0111ba7..439767581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Introduced a lazy-loaded activities table on the portfolio activities page (experimental) + ### Changed - Set the actions columns of various tables to stick at the end diff --git a/apps/api/src/app/order/interfaces/activities.interface.ts b/apps/api/src/app/order/interfaces/activities.interface.ts index bc2c35a50..7c612d464 100644 --- a/apps/api/src/app/order/interfaces/activities.interface.ts +++ b/apps/api/src/app/order/interfaces/activities.interface.ts @@ -2,6 +2,7 @@ import { OrderWithAccount } from '@ghostfolio/common/types'; export interface Activities { activities: Activity[]; + count: number; } export interface Activity extends OrderWithAccount { diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts index 8c8e3e27a..7e37f22b7 100644 --- a/apps/api/src/app/order/order.controller.ts +++ b/apps/api/src/app/order/order.controller.ts @@ -24,7 +24,7 @@ import { } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import { Order as OrderModel } from '@prisma/client'; +import { Order as OrderModel, Prisma } from '@prisma/client'; import { parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @@ -90,6 +90,8 @@ export class OrderController { @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, @Query('skip') skip?: number, + @Query('sortColumn') sortColumn?: string, + @Query('sortDirection') sortDirection?: Prisma.SortOrder, @Query('tags') filterByTags?: string, @Query('take') take?: number ): Promise { @@ -103,8 +105,10 @@ export class OrderController { await this.impersonationService.validateImpersonationId(impersonationId); const userCurrency = this.request.user.Settings.settings.baseCurrency; - const activities = await this.orderService.getOrders({ + const { activities, count } = await this.orderService.getOrders({ filters, + sortColumn, + sortDirection, userCurrency, includeDrafts: true, skip: isNaN(skip) ? undefined : skip, @@ -113,7 +117,7 @@ export class OrderController { withExcludedAccounts: true }); - return { activities }; + return { activities, count }; } @Post() diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index 10515018c..574bfdcd2 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -25,7 +25,7 @@ import { endOfToday, isAfter } from 'date-fns'; import { groupBy } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; -import { Activity } from './interfaces/activities.interface'; +import { Activities, Activity } from './interfaces/activities.interface'; @Injectable() export class OrderService { @@ -51,7 +51,7 @@ export class OrderService { take?: number; cursor?: Prisma.OrderWhereUniqueInput; where?: Prisma.OrderWhereInput; - orderBy?: Prisma.OrderOrderByWithRelationInput; + orderBy?: Prisma.Enumerable; }): Promise { const { include, skip, take, cursor, where, orderBy } = params; @@ -231,6 +231,8 @@ export class OrderService { filters, includeDrafts = false, skip, + sortColumn, + sortDirection, take = Number.MAX_SAFE_INTEGER, types, userCurrency, @@ -240,12 +242,17 @@ export class OrderService { filters?: Filter[]; includeDrafts?: boolean; skip?: number; + sortColumn?: string; + sortDirection?: Prisma.SortOrder; take?: number; types?: TypeOfOrder[]; userCurrency: string; userId: string; withExcludedAccounts?: boolean; - }): Promise { + }): Promise { + let orderBy: Prisma.Enumerable = [ + { date: 'asc' } + ]; const where: Prisma.OrderWhereInput = { userId }; const { @@ -307,6 +314,10 @@ export class OrderService { }; } + if (sortColumn) { + orderBy = [{ [sortColumn]: sortDirection }]; + } + if (types) { where.OR = types.map((type) => { return { @@ -317,8 +328,9 @@ export class OrderService { }); } - return ( - await this.orders({ + const [orders, count] = await Promise.all([ + this.orders({ + orderBy, skip, take, where, @@ -332,10 +344,12 @@ export class OrderService { // eslint-disable-next-line @typescript-eslint/naming-convention SymbolProfile: true, tags: true - }, - orderBy: { date: 'asc' } - }) - ) + } + }), + this.prismaService.order.count({ where }) + ]); + + const activities = orders .filter((order) => { return ( withExcludedAccounts || @@ -361,6 +375,8 @@ export class OrderService { ) }; }); + + return { activities, count }; } public async updateOrder({ diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 85e914287..050559c85 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -225,7 +225,7 @@ export class PortfolioService { }): Promise { const userId = await this.getUserId(impersonationId, this.request.user.id); - const activities = await this.orderService.getOrders({ + const { activities } = await this.orderService.getOrders({ filters, userId, types: ['DIVIDEND'], @@ -679,13 +679,13 @@ export class PortfolioService { const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); - const orders = ( - await this.orderService.getOrders({ - userCurrency, - userId, - withExcludedAccounts: true - }) - ).filter(({ SymbolProfile }) => { + const { activities } = await this.orderService.getOrders({ + userCurrency, + userId, + withExcludedAccounts: true + }); + + const orders = activities.filter(({ SymbolProfile }) => { return ( SymbolProfile.dataSource === aDataSource && SymbolProfile.symbol === aSymbol @@ -1639,18 +1639,18 @@ export class PortfolioService { userId }); - const activities = await this.orderService.getOrders({ + const { activities } = await this.orderService.getOrders({ userCurrency, userId }); - const excludedActivities = ( - await this.orderService.getOrders({ - userCurrency, - userId, - withExcludedAccounts: true - }) - ).filter(({ Account: account }) => { + let { activities: excludedActivities } = await this.orderService.getOrders({ + userCurrency, + userId, + withExcludedAccounts: true + }); + + excludedActivities = excludedActivities.filter(({ Account: account }) => { return account?.isExcluded ?? false; }); @@ -1830,7 +1830,7 @@ export class PortfolioService { const userCurrency = this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY; - const orders = await this.orderService.getOrders({ + const { activities, count } = await this.orderService.getOrders({ filters, includeDrafts, userCurrency, @@ -1839,11 +1839,11 @@ export class PortfolioService { types: ['BUY', 'SELL'] }); - if (orders.length <= 0) { + if (count <= 0) { return { transactionPoints: [], orders: [], portfolioOrders: [] }; } - const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ + const portfolioOrders: PortfolioOrder[] = activities.map((order) => ({ currency: order.SymbolProfile.currency, dataSource: order.SymbolProfile.dataSource, date: format(order.date, DATE_FORMAT), @@ -1877,8 +1877,8 @@ export class PortfolioService { portfolioCalculator.computeTransactionPoints(); return { - orders, portfolioOrders, + orders: activities, transactionPoints: portfolioCalculator.getTransactionPoints() }; } @@ -1913,13 +1913,14 @@ export class PortfolioService { userId: string; withExcludedAccounts?: boolean; }) { - const ordersOfTypeItemOrLiability = await this.orderService.getOrders({ - filters, - userCurrency, - userId, - withExcludedAccounts, - types: ['ITEM', 'LIABILITY'] - }); + const { activities: ordersOfTypeItemOrLiability } = + await this.orderService.getOrders({ + filters, + userCurrency, + userId, + withExcludedAccounts, + types: ['ITEM', 'LIABILITY'] + }); const accounts: PortfolioDetails['accounts'] = {}; const platforms: PortfolioDetails['platforms'] = {}; diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts index 259611c0d..a1f596e37 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts @@ -1,5 +1,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; +import { PageEvent } from '@angular/material/paginator'; +import { MatTableDataSource } from '@angular/material/table'; import { ActivatedRoute, Router } from '@angular/router'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; @@ -10,10 +12,11 @@ import { DataService } from '@ghostfolio/client/services/data.service'; import { IcsService } from '@ghostfolio/client/services/ics/ics.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; 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'; +import { DataSource, Order as OrderModel, Prisma } from '@prisma/client'; import { format, parseISO } from 'date-fns'; import { DeviceDetectorService } from 'ngx-device-detector'; import { Subject, Subscription } from 'rxjs'; @@ -30,12 +33,18 @@ import { ImportActivitiesDialogParams } from './import-activities-dialog/interfa }) export class ActivitiesPageComponent implements OnDestroy, OnInit { public activities: Activity[]; + public dataSource: MatTableDataSource; public defaultAccountId: string; public deviceType: string; public hasImpersonationId: boolean; public hasPermissionToCreateActivity: boolean; public hasPermissionToDeleteActivity: boolean; + public pageIndex = 0; + public pageSize = DEFAULT_PAGE_SIZE; public routeQueryParams: Subscription; + public sortColumn = 'date'; + public sortDirection: Prisma.SortOrder = 'desc'; + public totalItems: number; public user: User; private unsubscribeSubject = new Subject(); @@ -103,21 +112,48 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit { } public fetchActivities() { - this.dataService - .fetchActivities({}) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ activities }) => { - this.activities = activities; + if (this.user?.settings?.isExperimentalFeatures === true) { + this.dataService + .fetchActivities({ + skip: this.pageIndex * this.pageSize, + sortColumn: this.sortColumn, + sortDirection: this.sortDirection, + take: this.pageSize + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ activities, count }) => { + this.dataSource = new MatTableDataSource(activities); + this.totalItems = count; - if ( - this.hasPermissionToCreateActivity && - this.activities?.length <= 0 - ) { - this.router.navigate([], { queryParams: { createDialog: true } }); - } + if (this.hasPermissionToCreateActivity && this.totalItems <= 0) { + this.router.navigate([], { queryParams: { createDialog: true } }); + } - this.changeDetectorRef.markForCheck(); - }); + this.changeDetectorRef.markForCheck(); + }); + } else { + this.dataService + .fetchActivities({}) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ activities }) => { + this.activities = activities; + + if ( + this.hasPermissionToCreateActivity && + this.activities?.length <= 0 + ) { + this.router.navigate([], { queryParams: { createDialog: true } }); + } + + this.changeDetectorRef.markForCheck(); + }); + } + } + + public onChangePage(page: PageEvent) { + this.pageIndex = page.pageIndex; + + this.fetchActivities(); } public onCloneActivity(aActivity: Activity) { diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.html b/apps/client/src/app/pages/portfolio/activities/activities-page.html index 8c2cf9bd5..bab5eb066 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.html +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.html @@ -2,7 +2,30 @@

Activities

+ { - return this.http - .get('/api/v1/order', { - params: this.buildFiltersAsQueryParams({ filters }) + let params = this.buildFiltersAsQueryParams({ filters }); + + if (skip) { + params = params.append('skip', skip); + } + + if (sortColumn) { + params = params.append('sortColumn', sortColumn); + } + + if (sortDirection) { + params = params.append('sortDirection', sortDirection); + } + + if (take) { + params = params.append('take', take); + } + + return this.http.get('/api/v1/order', { params }).pipe( + map(({ activities, count }) => { + for (const activity of activities) { + activity.createdAt = parseISO(activity.createdAt); + activity.date = parseISO(activity.date); + } + return { activities, count }; }) - .pipe( - map(({ activities }) => { - for (const activity of activities) { - activity.createdAt = parseISO(activity.createdAt); - activity.date = parseISO(activity.date); - } - return { activities }; - }) - ); + ); } public fetchDividends({ diff --git a/libs/ui/src/lib/activities-table-lazy/activities-table-lazy.component.html b/libs/ui/src/lib/activities-table-lazy/activities-table-lazy.component.html new file mode 100644 index 000000000..116883b18 --- /dev/null +++ b/libs/ui/src/lib/activities-table-lazy/activities-table-lazy.component.html @@ -0,0 +1,499 @@ +
+ + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ +
+
+ +
{{ element.dataSource }}
+
+ Name + +
+
+ {{ element.SymbolProfile?.name }} + Draft +
+
+
+ {{ + element.SymbolProfile?.symbol | gfSymbol + }} +
+
+ Type + + + + Date + +
+ {{ element.date | date: defaultDateFormat }} +
+
+ Quantity + +
+ +
+
+ Unit Price + +
+ +
+
+ Fee + +
+ +
+
+ Value + +
+ +
+
+ Currency + + {{ element.SymbolProfile?.currency }} + + Value + +
+ +
+
+ Account + +
+ + {{ element.Account?.name }} +
+
+ + + + + + + + + + + + + + + + + +
+
+ + + + + +
+ +
diff --git a/libs/ui/src/lib/activities-table-lazy/activities-table-lazy.component.scss b/libs/ui/src/lib/activities-table-lazy/activities-table-lazy.component.scss new file mode 100644 index 000000000..003303f95 --- /dev/null +++ b/libs/ui/src/lib/activities-table-lazy/activities-table-lazy.component.scss @@ -0,0 +1,9 @@ +@import 'apps/client/src/styles/ghostfolio-style'; + +:host { + display: block; + + .activities { + overflow-x: auto; + } +} diff --git a/libs/ui/src/lib/activities-table-lazy/activities-table-lazy.component.ts b/libs/ui/src/lib/activities-table-lazy/activities-table-lazy.component.ts new file mode 100644 index 000000000..4300dd421 --- /dev/null +++ b/libs/ui/src/lib/activities-table-lazy/activities-table-lazy.component.ts @@ -0,0 +1,237 @@ +import { SelectionModel } from '@angular/cdk/collections'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + ViewChild +} from '@angular/core'; +import { MatPaginator, PageEvent } from '@angular/material/paginator'; +import { MatTableDataSource } from '@angular/material/table'; +import { Router } from '@angular/router'; +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; +import { getDateFormatString } from '@ghostfolio/common/helper'; +import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { OrderWithAccount } from '@ghostfolio/common/types'; +import { isUUID } from 'class-validator'; +import { endOfToday, isAfter } from 'date-fns'; +import { Subject, Subscription, takeUntil } from 'rxjs'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'gf-activities-table-lazy', + styleUrls: ['./activities-table-lazy.component.scss'], + templateUrl: './activities-table-lazy.component.html' +}) +export class ActivitiesTableLazyComponent + implements OnChanges, OnDestroy, OnInit +{ + @Input() baseCurrency: string; + @Input() dataSource: MatTableDataSource; + @Input() deviceType: string; + @Input() hasPermissionToCreateActivity: boolean; + @Input() hasPermissionToExportActivities: boolean; + @Input() hasPermissionToOpenDetails = true; + @Input() locale: string; + @Input() pageIndex: number; + @Input() pageSize = DEFAULT_PAGE_SIZE; + @Input() showActions = true; + @Input() showCheckbox = false; + @Input() showFooter = true; + @Input() showNameColumn = true; + @Input() totalItems = Number.MAX_SAFE_INTEGER; + + @Output() activityDeleted = new EventEmitter(); + @Output() activityToClone = new EventEmitter(); + @Output() activityToUpdate = new EventEmitter(); + @Output() deleteAllActivities = new EventEmitter(); + @Output() export = new EventEmitter(); + @Output() exportDrafts = new EventEmitter(); + @Output() import = new EventEmitter(); + @Output() importDividends = new EventEmitter(); + @Output() pageChanged = new EventEmitter(); + @Output() selectedActivities = new EventEmitter(); + + @ViewChild(MatPaginator) paginator: MatPaginator; + + public defaultDateFormat: string; + public displayedColumns = []; + public endOfToday = endOfToday(); + public hasDrafts = false; + public hasErrors = false; + public isAfter = isAfter; + public isLoading = true; + public isUUID = isUUID; + public routeQueryParams: Subscription; + public searchKeywords: string[] = []; + public selectedRows = new SelectionModel(true, []); + + private unsubscribeSubject = new Subject(); + + public constructor(private router: Router) {} + + public ngOnInit() { + if (this.showCheckbox) { + this.toggleAllRows(); + this.selectedRows.changed + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((selectedRows) => { + this.selectedActivities.emit(selectedRows.source.selected); + }); + } + } + + public areAllRowsSelected() { + const numSelectedRows = this.selectedRows.selected.length; + const numTotalRows = this.dataSource.data.length; + return numSelectedRows === numTotalRows; + } + + public ngOnChanges() { + this.defaultDateFormat = getDateFormatString(this.locale); + + this.displayedColumns = [ + 'select', + 'importStatus', + 'icon', + 'nameWithSymbol', + 'type', + 'date', + 'quantity', + 'unitPrice', + 'fee', + 'value', + 'currency', + 'valueInBaseCurrency', + 'account', + 'comment', + 'actions' + ]; + + if (!this.showCheckbox) { + this.displayedColumns = this.displayedColumns.filter((column) => { + return column !== 'importStatus' && column !== 'select'; + }); + } + + if (!this.showNameColumn) { + this.displayedColumns = this.displayedColumns.filter((column) => { + return column !== 'nameWithSymbol'; + }); + } + + if (this.dataSource) { + this.isLoading = false; + } + } + + public onChangePage(page: PageEvent) { + this.pageChanged.emit(page); + } + + public onClickActivity(activity: Activity) { + if (this.showCheckbox) { + if (!activity.error) { + this.selectedRows.toggle(activity); + } + } else if ( + this.hasPermissionToOpenDetails && + !activity.isDraft && + activity.type !== 'FEE' && + activity.type !== 'INTEREST' && + activity.type !== 'ITEM' && + activity.type !== 'LIABILITY' + ) { + this.onOpenPositionDialog({ + dataSource: activity.SymbolProfile.dataSource, + symbol: activity.SymbolProfile.symbol + }); + } + } + + public onCloneActivity(aActivity: OrderWithAccount) { + this.activityToClone.emit(aActivity); + } + + public onDeleteActivity(aId: string) { + const confirmation = confirm( + $localize`Do you really want to delete this activity?` + ); + + if (confirmation) { + this.activityDeleted.emit(aId); + } + } + + public onExport() { + if (this.searchKeywords.length > 0) { + this.export.emit( + this.dataSource.filteredData.map((activity) => { + return activity.id; + }) + ); + } else { + this.export.emit(); + } + } + + public onExportDraft(aActivityId: string) { + this.exportDrafts.emit([aActivityId]); + } + + public onExportDrafts() { + this.exportDrafts.emit( + this.dataSource.filteredData + .filter((activity) => { + return activity.isDraft; + }) + .map((activity) => { + return activity.id; + }) + ); + } + + public onDeleteAllActivities() { + this.deleteAllActivities.emit(); + } + + public onImport() { + this.import.emit(); + } + + public onImportDividends() { + this.importDividends.emit(); + } + + public onOpenComment(aComment: string) { + alert(aComment); + } + + public onOpenPositionDialog({ dataSource, symbol }: UniqueAsset): void { + this.router.navigate([], { + queryParams: { dataSource, symbol, positionDetailDialog: true } + }); + } + + public onUpdateActivity(aActivity: OrderWithAccount) { + 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() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } +} diff --git a/libs/ui/src/lib/activities-table-lazy/activities-table-lazy.module.ts b/libs/ui/src/lib/activities-table-lazy/activities-table-lazy.module.ts new file mode 100644 index 000000000..3d7e79a4b --- /dev/null +++ b/libs/ui/src/lib/activities-table-lazy/activities-table-lazy.module.ts @@ -0,0 +1,41 @@ +import { CommonModule } from '@angular/common'; +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { MatSortModule } from '@angular/material/sort'; +import { MatTableModule } from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { RouterModule } from '@angular/router'; +import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module'; +import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; +import { GfActivityTypeModule } from '@ghostfolio/ui/activity-type'; +import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info'; +import { GfValueModule } from '@ghostfolio/ui/value'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; + +import { ActivitiesTableLazyComponent } from './activities-table-lazy.component'; + +@NgModule({ + declarations: [ActivitiesTableLazyComponent], + exports: [ActivitiesTableLazyComponent], + imports: [ + CommonModule, + GfActivityTypeModule, + GfNoTransactionsInfoModule, + GfSymbolIconModule, + GfSymbolModule, + GfValueModule, + MatButtonModule, + MatCheckboxModule, + MatMenuModule, + MatPaginatorModule, + MatTableModule, + MatTooltipModule, + NgxSkeletonLoaderModule, + RouterModule + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] +}) +export class GfActivitiesTableLazyModule {}