Browse Source

Merge 12a8080a5c into fda0c3afc8

pull/6622/merge
Raphaël TISON 16 hours ago
committed by GitHub
parent
commit
a09886d418
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 11
      apps/api/src/app/activities/activities.controller.ts
  2. 9
      apps/api/src/app/export/export.controller.ts
  3. 5
      apps/api/src/app/export/export.service.ts
  4. 9
      apps/api/src/services/api/api.service.ts
  5. 20
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  6. 2
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  7. 21
      libs/common/src/lib/helper.spec.ts
  8. 4
      libs/common/src/lib/helper.ts
  9. 134
      libs/ui/src/lib/activities-table/activities-table.component.html
  10. 24
      libs/ui/src/lib/activities-table/activities-table.component.ts
  11. 14
      libs/ui/src/lib/services/data.service.ts
  12. 2
      prisma/migrations/20260321200654_added_index_for_type_to_order/migration.sql
  13. 1
      prisma/schema.prisma

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

@ -13,6 +13,7 @@ import {
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { splitStringToArray } from '@ghostfolio/common/helper';
import {
ActivitiesResponse,
ActivityResponse
@ -37,7 +38,7 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
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 { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -120,8 +121,13 @@ export class ActivitiesController {
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('take') take?: number
@Query('take') take?: number,
@Query('activityTypes') filterByTypes?: string
): Promise<ActivitiesResponse> {
const types = filterByTypes
? (splitStringToArray(filterByTypes) as ActivityType[])
: undefined;
let endDate: Date;
let startDate: Date;
@ -147,6 +153,7 @@ export class ActivitiesController {
sortColumn,
sortDirection,
startDate,
types,
userCurrency,
includeDrafts: true,
skip: isNaN(skip) ? undefined : skip,

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

@ -2,6 +2,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { splitStringToArray } from '@ghostfolio/common/helper';
import { ExportResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -15,6 +16,7 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Type as ActivityType } from '@prisma/client';
import { ExportService } from './export.service';
@ -36,9 +38,13 @@ export class ExportController {
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
@Query('tags') filterByTags?: string,
@Query('activityTypes') filterByTypes?: string
): Promise<ExportResponse> {
const activityIds = filterByActivityIds?.split(',') ?? [];
const activityTypes = filterByTypes
? (splitStringToArray(filterByTypes) as ActivityType[])
: undefined;
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
@ -50,6 +56,7 @@ export class ExportController {
return this.exportService.export({
activityIds,
filters,
activityTypes: activityTypes,
userId: this.request.user.id,
userSettings: this.request.user.settings.settings
});

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

@ -10,7 +10,7 @@ import {
} from '@ghostfolio/common/interfaces';
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';
@Injectable()
@ -24,12 +24,14 @@ export class ExportService {
public async export({
activityIds,
activityTypes,
filters,
userId,
userSettings
}: {
activityIds?: string[];
filters?: Filter[];
activityTypes?: ActivityType[];
userId: string;
userSettings: UserSettings;
}): Promise<ExportResponse> {
@ -40,6 +42,7 @@ export class ExportService {
let { activities } = await this.activitiesService.getActivities({
filters,
types: activityTypes,
userId,
includeDrafts: true,
sortColumn: 'date',

9
apps/api/src/services/api/api.service.ts

@ -1,3 +1,4 @@
import { splitStringToArray } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@ -23,14 +24,14 @@ export class ApiService {
filterBySymbol?: string;
filterByTags?: string;
}): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const accountIds = splitStringToArray(filterByAccounts);
const assetClasses = splitStringToArray(filterByAssetClasses);
const assetSubClasses = splitStringToArray(filterByAssetSubClasses);
const dataSource = filterByDataSource;
const holdingType = filterByHoldingType;
const searchQuery = filterBySearchQuery?.toLowerCase();
const symbol = filterBySymbol;
const tagIds = filterByTags?.split(',') ?? [];
const tagIds = splitStringToArray(filterByTags);
const filters = [
...accountIds.map((accountId) => {

20
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'
})
export class GfActivitiesPageComponent implements OnInit {
public activityTypesFilter: string[] = [];
public dataSource: MatTableDataSource<Activity>;
public deviceType: string;
public hasImpersonationId: boolean;
@ -145,7 +146,10 @@ export class GfActivitiesPageComponent implements OnInit {
skip: this.pageIndex * this.pageSize,
sortColumn: this.sortColumn,
sortDirection: this.sortDirection,
take: this.pageSize
take: this.pageSize,
activityTypes: this.activityTypesFilter.length
? this.activityTypesFilter
: undefined
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ activities, count }) => {
@ -217,7 +221,12 @@ export class GfActivitiesPageComponent implements OnInit {
let fetchExportParams: any = { activityIds };
if (!activityIds) {
fetchExportParams = { filters: this.userService.getFilters() };
fetchExportParams = {
filters: this.userService.getFilters(),
activityTypes: this.activityTypesFilter.length
? this.activityTypesFilter
: undefined
};
}
this.dataService
@ -318,6 +327,13 @@ export class GfActivitiesPageComponent implements OnInit {
this.fetchActivities();
}
public onTypesFilterChanged(types: string[]) {
this.activityTypesFilter = types;
this.pageIndex = 0;
this.fetchActivities();
}
public onUpdateActivity(aActivity: Activity) {
this.router.navigate([], {
queryParams: { activityId: aActivity.id, editDialog: true }

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

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

21
libs/common/src/lib/helper.spec.ts

@ -1,6 +1,7 @@
import {
extractNumberFromString,
getNumberFormatGroup
getNumberFormatGroup,
splitStringToArray
} from '@ghostfolio/common/helper';
describe('Helper', () => {
@ -116,4 +117,22 @@ describe('Helper', () => {
expect(getNumberFormatGroup()).toEqual(',');
});
});
describe('splitStringToArray', () => {
it('should split a comma-separated string', () => {
expect(splitStringToArray('a,b,c')).toEqual(['a', 'b', 'c']);
});
it('should return a single-element array for a string without commas', () => {
expect(splitStringToArray('a')).toEqual(['a']);
});
it('should return an empty array for undefined', () => {
expect(splitStringToArray(undefined)).toEqual([]);
});
it('should return an empty array for no argument', () => {
expect(splitStringToArray()).toEqual([]);
});
});
});

4
libs/common/src/lib/helper.ts

@ -474,3 +474,7 @@ export function resolveMarketCondition(
return { emoji: undefined };
}
}
export function splitStringToArray(aString?: string): string[] {
return aString?.split(',') ?? [];
}

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

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

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

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

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

@ -214,8 +214,10 @@ export class DataService {
skip,
sortColumn,
sortDirection,
take
take,
activityTypes
}: {
activityTypes?: string[];
filters?: Filter[];
range?: DateRange;
skip?: number;
@ -245,6 +247,10 @@ export class DataService {
params = params.append('take', take);
}
if (activityTypes?.length) {
params = params.append('activityTypes', activityTypes.join(','));
}
return this.http.get<any>('/api/v1/activities', { params }).pipe(
map(({ activities, count }) => {
for (const activity of activities) {
@ -411,9 +417,11 @@ export class DataService {
public fetchExport({
activityIds,
activityTypes,
filters
}: {
activityIds?: string[];
activityTypes?: string[];
filters?: Filter[];
} = {}) {
let params = this.buildFiltersAsQueryParams({ filters });
@ -422,6 +430,10 @@ export class DataService {
params = params.append('activityIds', activityIds.join(','));
}
if (activityTypes?.length) {
params = params.append('activityTypes', activityTypes.join(','));
}
return this.http.get<ExportResponse>('/api/v1/export', {
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([date])
@@index([isDraft])
@@index([type])
@@index([userId])
}

Loading…
Cancel
Save