Browse Source

Feature/add type filter to activities table component (#6622)

* Add type filter

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
pull/6663/head
Raphaël TISON 1 week ago
committed by GitHub
parent
commit
c311d835fa
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 7
      apps/api/src/app/activities/activities.controller.ts
  3. 2
      apps/api/src/app/activities/activities.service.ts
  4. 5
      apps/api/src/app/export/export.controller.ts
  5. 5
      apps/api/src/app/export/export.service.ts
  6. 18
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  7. 2
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  8. 134
      libs/ui/src/lib/activities-table/activities-table.component.html
  9. 27
      libs/ui/src/lib/activities-table/activities-table.component.ts
  10. 12
      libs/ui/src/lib/services/data.service.ts
  11. 2
      prisma/migrations/20260321200654_added_index_for_type_to_order/migration.sql
  12. 1
      prisma/schema.prisma

6
CHANGELOG.md

@ -5,6 +5,12 @@ 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 support for filtering by activity type on the activities page (experimental)
## 2.252.0 - 2026-03-02 ## 2.252.0 - 2026-03-02
### Added ### Added

7
apps/api/src/app/activities/activities.controller.ts

@ -37,7 +37,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Order, Prisma } from '@prisma/client'; import { Order, Prisma, Type as ActivityType } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -112,6 +112,7 @@ export class ActivitiesController {
public async getAllActivities( public async getAllActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange?: DateRange, @Query('range') dateRange?: DateRange,
@ -139,6 +140,9 @@ export class ActivitiesController {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const types = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const userCurrency = this.request.user.settings.settings.baseCurrency; const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities, count } = await this.activitiesService.getActivities({ const { activities, count } = await this.activitiesService.getActivities({
@ -147,6 +151,7 @@ export class ActivitiesController {
sortColumn, sortColumn,
sortDirection, sortDirection,
startDate, startDate,
types,
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
skip: isNaN(skip) ? undefined : skip, skip: isNaN(skip) ? undefined : skip,

2
apps/api/src/app/activities/activities.service.ts

@ -629,7 +629,7 @@ export class ActivitiesService {
orderBy = [{ [sortColumn]: sortDirection }]; orderBy = [{ [sortColumn]: sortDirection }];
} }
if (types) { if (types?.length > 0) {
where.type = { in: types }; where.type = { in: types };
} }

5
apps/api/src/app/export/export.controller.ts

@ -15,6 +15,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Type as ActivityType } from '@prisma/client';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
@ -33,12 +34,15 @@ export class ExportController {
public async export( public async export(
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('activityIds') filterByActivityIds?: string, @Query('activityIds') filterByActivityIds?: string,
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string, @Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<ExportResponse> { ): Promise<ExportResponse> {
const activityIds = filterByActivityIds?.split(',') ?? []; const activityIds = filterByActivityIds?.split(',') ?? [];
const activityTypes = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
@ -49,6 +53,7 @@ export class ExportController {
return this.exportService.export({ return this.exportService.export({
activityIds, activityIds,
activityTypes,
filters, filters,
userId: this.request.user.id, userId: this.request.user.id,
userSettings: this.request.user.settings.settings userSettings: this.request.user.settings.settings

5
apps/api/src/app/export/export.service.ts

@ -10,7 +10,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Platform, Prisma } from '@prisma/client'; import { Platform, Prisma, Type as ActivityType } from '@prisma/client';
import { groupBy, uniqBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
@Injectable() @Injectable()
@ -24,11 +24,13 @@ export class ExportService {
public async export({ public async export({
activityIds, activityIds,
activityTypes,
filters, filters,
userId, userId,
userSettings userSettings
}: { }: {
activityIds?: string[]; activityIds?: string[];
activityTypes?: ActivityType[];
filters?: Filter[]; filters?: Filter[];
userId: string; userId: string;
userSettings: UserSettings; userSettings: UserSettings;
@ -44,6 +46,7 @@ export class ExportService {
includeDrafts: true, includeDrafts: true,
sortColumn: 'date', sortColumn: 'date',
sortDirection: 'asc', sortDirection: 'asc',
types: activityTypes,
userCurrency: userSettings?.baseCurrency, userCurrency: userSettings?.baseCurrency,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });

18
apps/client/src/app/pages/portfolio/activities/activities-page.component.ts

@ -54,6 +54,7 @@ import { ImportActivitiesDialogParams } from './import-activities-dialog/interfa
templateUrl: './activities-page.html' templateUrl: './activities-page.html'
}) })
export class GfActivitiesPageComponent implements OnInit { export class GfActivitiesPageComponent implements OnInit {
public activityTypesFilter: string[] = [];
public dataSource: MatTableDataSource<Activity>; public dataSource: MatTableDataSource<Activity>;
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
@ -141,6 +142,9 @@ export class GfActivitiesPageComponent implements OnInit {
this.dataService this.dataService
.fetchActivities({ .fetchActivities({
range, range,
activityTypes: this.activityTypesFilter.length
? this.activityTypesFilter
: undefined,
filters: this.userService.getFilters(), filters: this.userService.getFilters(),
skip: this.pageIndex * this.pageSize, skip: this.pageIndex * this.pageSize,
sortColumn: this.sortColumn, sortColumn: this.sortColumn,
@ -217,7 +221,12 @@ export class GfActivitiesPageComponent implements OnInit {
let fetchExportParams: any = { activityIds }; let fetchExportParams: any = { activityIds };
if (!activityIds) { if (!activityIds) {
fetchExportParams = { filters: this.userService.getFilters() }; fetchExportParams = {
activityTypes: this.activityTypesFilter.length
? this.activityTypesFilter
: undefined,
filters: this.userService.getFilters()
};
} }
this.dataService this.dataService
@ -318,6 +327,13 @@ export class GfActivitiesPageComponent implements OnInit {
this.fetchActivities(); this.fetchActivities();
} }
public onTypesFilterChanged(aTypes: string[]) {
this.activityTypesFilter = aTypes;
this.pageIndex = 0;
this.fetchActivities();
}
public onUpdateActivity(aActivity: Activity) { public onUpdateActivity(aActivity: Activity) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { activityId: aActivity.id, editDialog: true } queryParams: { activityId: aActivity.id, editDialog: true }

2
apps/client/src/app/pages/portfolio/activities/activities-page.html

@ -10,6 +10,7 @@
[hasPermissionToCreateActivity]="hasPermissionToCreateActivity" [hasPermissionToCreateActivity]="hasPermissionToCreateActivity"
[hasPermissionToDeleteActivity]="hasPermissionToDeleteActivity" [hasPermissionToDeleteActivity]="hasPermissionToDeleteActivity"
[hasPermissionToExportActivities]="!hasImpersonationId" [hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToFilterByType]="user?.settings?.isExperimentalFeatures"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[pageIndex]="pageIndex" [pageIndex]="pageIndex"
[pageSize]="pageSize" [pageSize]="pageSize"
@ -32,6 +33,7 @@
(importDividends)="onImportDividends()" (importDividends)="onImportDividends()"
(pageChanged)="onChangePage($event)" (pageChanged)="onChangePage($event)"
(sortChanged)="onSortChanged($event)" (sortChanged)="onSortChanged($event)"
(typesFilterChanged)="onTypesFilterChanged($event)"
/> />
</div> </div>
</div> </div>

134
libs/ui/src/lib/activities-table/activities-table.component.html

@ -1,77 +1,101 @@
@if (hasPermissionToCreateActivity) { <div class="d-flex justify-content-end justify-content-lg-between">
<div class="d-flex justify-content-end"> <div class="d-none d-lg-flex">
<button @if (hasPermissionToFilterByType) {
class="align-items-center d-flex" <mat-form-field appearance="outline" class="without-hint">
mat-stroked-button <mat-label i18n>Type</mat-label>
(click)="onImport()" <mat-select multiple [formControl]="typesFilter">
> @for (
<ion-icon class="mr-2" name="cloud-upload-outline" /> activityType of activityTypesTranslationMap | keyvalue;
<span><ng-container i18n>Import Activities</ng-container>...</span> track activityType.key
</button> ) {
@if (hasPermissionToExportActivities) { <mat-option [value]="activityType.key">
<button {{ activityType.value }}
class="mx-1 no-min-width px-2" </mat-option>
mat-stroked-button }
[matMenuTriggerFor]="activitiesMenu" </mat-select>
(click)="$event.stopPropagation()" </mat-form-field>
>
<ion-icon name="ellipsis-vertical" />
</button>
} }
<mat-menu #activitiesMenu="matMenu" class="no-max-width" xPosition="before"> </div>
@if (hasPermissionToCreateActivity) {
<div class="d-flex">
<button <button
mat-menu-item class="align-items-center d-flex"
[disabled]="dataSource()?.data.length === 0" mat-stroked-button
(click)="onImportDividends()" (click)="onImport()"
> >
<span class="align-items-center d-flex"> <ion-icon class="mr-2" name="cloud-upload-outline" />
<ion-icon class="mr-2" name="color-wand-outline" /> <span><ng-container i18n>Import Activities</ng-container>...</span>
<span><ng-container i18n>Import Dividends</ng-container>...</span>
</span>
</button> </button>
@if (hasPermissionToExportActivities) { @if (hasPermissionToExportActivities) {
<button <button
class="align-items-center d-flex" class="mx-1 no-min-width px-2"
mat-stroked-button
[matMenuTriggerFor]="activitiesMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical" />
</button>
}
<mat-menu
#activitiesMenu="matMenu"
class="no-max-width"
xPosition="before"
>
<button
mat-menu-item mat-menu-item
[disabled]="dataSource()?.data.length === 0" [disabled]="dataSource()?.data.length === 0"
(click)="onExport()" (click)="onImportDividends()"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="cloud-download-outline" /> <ion-icon class="mr-2" name="color-wand-outline" />
<span i18n>Export Activities</span> <span><ng-container i18n>Import Dividends</ng-container>...</span>
</span> </span>
</button> </button>
} @if (hasPermissionToExportActivities) {
@if (hasPermissionToExportActivities) { <button
class="align-items-center d-flex"
mat-menu-item
[disabled]="dataSource()?.data.length === 0"
(click)="onExport()"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="cloud-download-outline" />
<span i18n>Export Activities</span>
</span>
</button>
}
@if (hasPermissionToExportActivities) {
<button
class="align-items-center d-flex"
mat-menu-item
[disabled]="!hasDrafts"
(click)="onExportDrafts()"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="calendar-clear-outline" />
<span i18n>Export Drafts as ICS</span>
</span>
</button>
}
<hr class="m-0" />
<button <button
class="align-items-center d-flex" class="align-items-center d-flex"
mat-menu-item mat-menu-item
[disabled]="!hasDrafts" [disabled]="
(click)="onExportDrafts()" dataSource()?.data.length === 0 || !hasPermissionToDeleteActivity
"
(click)="onDeleteActivities()"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="calendar-clear-outline" /> <ion-icon class="mr-2" name="trash-outline" />
<span i18n>Export Drafts as ICS</span> <span i18n>Delete Activities</span>
</span> </span>
</button> </button>
} </mat-menu>
<hr class="m-0" /> </div>
<button }
class="align-items-center d-flex" </div>
mat-menu-item
[disabled]="
dataSource()?.data.length === 0 || !hasPermissionToDeleteActivity
"
(click)="onDeleteActivities()"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete Activities</span>
</span>
</button>
</mat-menu>
</div>
}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table <table

27
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -10,6 +10,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { GfSymbolPipe } from '@ghostfolio/common/pipes'; import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { SelectionModel } from '@angular/cdk/collections'; import { SelectionModel } from '@angular/cdk/collections';
@ -29,14 +30,17 @@ import {
inject, inject,
input input
} from '@angular/core'; } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { import {
MatPaginator, MatPaginator,
MatPaginatorModule, MatPaginatorModule,
PageEvent PageEvent
} from '@angular/material/paginator'; } from '@angular/material/paginator';
import { MatSelectModule } from '@angular/material/select';
import { import {
MatSort, MatSort,
MatSortModule, MatSortModule,
@ -46,6 +50,7 @@ import {
import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { Type as ActivityType } from '@prisma/client';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
@ -82,12 +87,15 @@ import { GfValueComponent } from '../value/value.component';
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatCheckboxModule, MatCheckboxModule,
MatFormFieldModule,
MatMenuModule, MatMenuModule,
MatPaginatorModule, MatPaginatorModule,
MatSelectModule,
MatSortModule, MatSortModule,
MatTableModule, MatTableModule,
MatTooltipModule, MatTooltipModule,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule,
ReactiveFormsModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-activities-table', selector: 'gf-activities-table',
@ -103,6 +111,7 @@ export class GfActivitiesTableComponent
@Input() hasPermissionToCreateActivity: boolean; @Input() hasPermissionToCreateActivity: boolean;
@Input() hasPermissionToDeleteActivity: boolean; @Input() hasPermissionToDeleteActivity: boolean;
@Input() hasPermissionToExportActivities: boolean; @Input() hasPermissionToExportActivities: boolean;
@Input() hasPermissionToFilterByType: boolean;
@Input() hasPermissionToOpenDetails = true; @Input() hasPermissionToOpenDetails = true;
@Input() locale = getLocale(); @Input() locale = getLocale();
@Input() pageIndex: number; @Input() pageIndex: number;
@ -125,14 +134,17 @@ export class GfActivitiesTableComponent
@Output() pageChanged = new EventEmitter<PageEvent>(); @Output() pageChanged = new EventEmitter<PageEvent>();
@Output() selectedActivities = new EventEmitter<Activity[]>(); @Output() selectedActivities = new EventEmitter<Activity[]>();
@Output() sortChanged = new EventEmitter<Sort>(); @Output() sortChanged = new EventEmitter<Sort>();
@Output() typesFilterChanged = new EventEmitter<string[]>();
@ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
public activityTypesTranslationMap = new Map<ActivityType, string>();
public hasDrafts = false; public hasDrafts = false;
public hasErrors = false; public hasErrors = false;
public isUUID = isUUID; public isUUID = isUUID;
public selectedRows = new SelectionModel<Activity>(true, []); public selectedRows = new SelectionModel<Activity>(true, []);
public typesFilter = new FormControl<string[]>([]);
public readonly dataSource = input.required< public readonly dataSource = input.required<
MatTableDataSource<Activity> | undefined MatTableDataSource<Activity> | undefined
@ -189,6 +201,13 @@ export class GfActivitiesTableComponent
private readonly unsubscribeSubject = new Subject<void>(); private readonly unsubscribeSubject = new Subject<void>();
public constructor() { public constructor() {
for (const type of Object.keys(ActivityType) as ActivityType[]) {
this.activityTypesTranslationMap.set(
ActivityType[type],
translate(ActivityType[type])
);
}
addIcons({ addIcons({
alertCircleOutline, alertCircleOutline,
calendarClearOutline, calendarClearOutline,
@ -214,6 +233,12 @@ export class GfActivitiesTableComponent
this.selectedActivities.emit(selectedRows.source.selected); this.selectedActivities.emit(selectedRows.source.selected);
}); });
} }
this.typesFilter.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((types) => {
this.typesFilterChanged.emit(types ?? []);
});
} }
public ngAfterViewInit() { public ngAfterViewInit() {

12
libs/ui/src/lib/services/data.service.ts

@ -209,6 +209,7 @@ export class DataService {
} }
public fetchActivities({ public fetchActivities({
activityTypes,
filters, filters,
range, range,
skip, skip,
@ -216,6 +217,7 @@ export class DataService {
sortDirection, sortDirection,
take take
}: { }: {
activityTypes?: string[];
filters?: Filter[]; filters?: Filter[];
range?: DateRange; range?: DateRange;
skip?: number; skip?: number;
@ -225,6 +227,10 @@ export class DataService {
}): Observable<ActivitiesResponse> { }): Observable<ActivitiesResponse> {
let params = this.buildFiltersAsQueryParams({ filters }); let params = this.buildFiltersAsQueryParams({ filters });
if (activityTypes?.length) {
params = params.append('activityTypes', activityTypes.join(','));
}
if (range) { if (range) {
params = params.append('range', range); params = params.append('range', range);
} }
@ -411,9 +417,11 @@ export class DataService {
public fetchExport({ public fetchExport({
activityIds, activityIds,
activityTypes,
filters filters
}: { }: {
activityIds?: string[]; activityIds?: string[];
activityTypes?: string[];
filters?: Filter[]; filters?: Filter[];
} = {}) { } = {}) {
let params = this.buildFiltersAsQueryParams({ filters }); let params = this.buildFiltersAsQueryParams({ filters });
@ -422,6 +430,10 @@ export class DataService {
params = params.append('activityIds', activityIds.join(',')); params = params.append('activityIds', activityIds.join(','));
} }
if (activityTypes?.length) {
params = params.append('activityTypes', activityTypes.join(','));
}
return this.http.get<ExportResponse>('/api/v1/export', { return this.http.get<ExportResponse>('/api/v1/export', {
params params
}); });

2
prisma/migrations/20260321200654_added_index_for_type_to_order/migration.sql

@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "Order_type_idx" ON "Order"("type");

1
prisma/schema.prisma

@ -158,6 +158,7 @@ model Order {
@@index([accountId]) @@index([accountId])
@@index([date]) @@index([date])
@@index([isDraft]) @@index([isDraft])
@@index([type])
@@index([userId]) @@index([userId])
} }

Loading…
Cancel
Save