Feature/manage tags of holdings (#3630)

* Manage tags of holdings

* Update changelog
pull/3631/head
Thomas Kaul 5 months ago committed by GitHub
parent 2fa723dc3c
commit 8f6203d296
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Added support to manage tags of holdings in the holding detail dialog
### Changed
- Improved the color assignment in the chart of the holdings tab on the home page (experimental)

@ -46,6 +46,39 @@ export class OrderService {
private readonly symbolProfileService: SymbolProfileService
) {}
public async assignTags({
dataSource,
symbol,
tags,
userId
}: { tags: Tag[]; userId: string } & UniqueAsset) {
const orders = await this.prismaService.order.findMany({
where: {
userId,
SymbolProfile: {
dataSource,
symbol
}
}
});
return Promise.all(
orders.map(({ id }) =>
this.prismaService.order.update({
data: {
tags: {
// The set operation replaces all existing connections with the provided ones
set: tags.map(({ id }) => {
return { id };
})
}
},
where: { id }
})
)
);
}
public async createOrder(
data: Prisma.OrderCreateInput & {
accountId?: string;

@ -1,6 +1,7 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import {
hasNotDefinedValuesInObject,
@ -29,7 +30,8 @@ import {
} from '@ghostfolio/common/interfaces';
import {
hasReadRestrictedAccessPermission,
isRestrictedView
isRestrictedView,
permissions
} from '@ghostfolio/common/permissions';
import type {
DateRange,
@ -38,12 +40,14 @@ import type {
} from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
Headers,
HttpException,
Inject,
Param,
Put,
Query,
UseGuards,
UseInterceptors,
@ -51,12 +55,13 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { AssetClass, AssetSubClass } from '@prisma/client';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
import { PortfolioService } from './portfolio.service';
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@Controller('portfolio')
export class PortfolioController {
@ -566,23 +571,23 @@ export class PortfolioController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPosition(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource,
@Param('symbol') symbol
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<PortfolioHoldingDetail> {
const position = await this.portfolioService.getPosition(
const holding = await this.portfolioService.getPosition(
dataSource,
impersonationId,
symbol
);
if (position) {
return position;
if (!holding) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
return holding;
}
@Get('report')
@ -605,4 +610,36 @@ export class PortfolioController {
return report;
}
@HasPermission(permissions.updateOrder)
@Put('position/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateHoldingTags(
@Body() data: UpdateHoldingTagsDto,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
const holding = await this.portfolioService.getPosition(
dataSource,
impersonationId,
symbol
);
if (!holding) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
await this.portfolioService.updateTags({
dataSource,
impersonationId,
symbol,
tags: data.tags,
userId: this.request.user.id
});
}
}

@ -59,7 +59,8 @@ import {
DataSource,
Order,
Platform,
Prisma
Prisma,
Tag
} from '@prisma/client';
import { Big } from 'big.js';
import {
@ -1304,6 +1305,24 @@ export class PortfolioService {
};
}
public async updateTags({
dataSource,
impersonationId,
symbol,
tags,
userId
}: {
dataSource: DataSource;
impersonationId: string;
symbol: string;
tags: Tag[];
userId: string;
}) {
userId = await this.getUserId(impersonationId, userId);
await this.orderService.assignTags({ dataSource, symbol, tags, userId });
}
private async getCashPositions({
cashDetails,
userCurrency,

@ -0,0 +1,7 @@
import { Tag } from '@prisma/client';
import { IsArray } from 'class-validator';
export class UpdateHoldingTagsDto {
@IsArray()
tags: Tag[];
}

@ -259,6 +259,10 @@ export class AppComponent implements OnDestroy, OnInit {
this.user?.permissions,
permissions.reportDataGlitch
),
hasPermissionToUpdateOrder:
!this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.updateOrder) &&
!user?.settings?.isRestrictedView,
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

@ -19,16 +19,24 @@ import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { CommonModule } from '@angular/common';
import {
CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
Inject,
OnDestroy,
OnInit
OnInit,
ViewChild
} from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import {
MatAutocompleteModule,
MatAutocompleteSelectedEvent
} from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import {
@ -36,14 +44,15 @@ import {
MatDialogModule,
MatDialogRef
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { Account, Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { Observable, of, Subject } from 'rxjs';
import { map, startWith, takeUntil } from 'rxjs/operators';
import { HoldingDetailDialogParams } from './interfaces/interfaces';
@ -60,9 +69,11 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
GfLineChartComponent,
GfPortfolioProportionChartComponent,
GfValueComponent,
MatAutocompleteModule,
MatButtonModule,
MatChipsModule,
MatDialogModule,
MatFormFieldModule,
MatTabsModule,
NgxSkeletonLoaderModule
],
@ -73,6 +84,9 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
templateUrl: 'holding-detail-dialog.html'
})
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
public activityForm: FormGroup;
public accounts: Account[];
public activities: Activity[];
public assetClass: string;
@ -88,6 +102,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public dividendInBaseCurrencyPrecision = 2;
public dividendYieldPercentWithCurrencyEffect: number;
public feeInBaseCurrency: number;
public filteredTagsObservable: Observable<Tag[]> = of([]);
public firstBuyDate: string;
public historicalDataItems: LineChartItem[];
public investment: number;
@ -107,10 +122,12 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public sectors: {
[name: string]: { name: string; value: number };
};
public separatorKeysCodes: number[] = [COMMA, ENTER];
public sortColumn = 'date';
public sortDirection: SortDirection = 'desc';
public SymbolProfile: EnhancedSymbolProfile;
public tags: Tag[];
public tagsAvailable: Tag[];
public totalItems: number;
public transactionCount: number;
public user: User;
@ -123,10 +140,38 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
private dataService: DataService,
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams,
private formBuilder: FormBuilder,
private userService: UserService
) {}
public ngOnInit() {
const { tags } = this.dataService.fetchInfo();
this.activityForm = this.formBuilder.group({
tags: <string[]>[]
});
this.tagsAvailable = tags.map(({ id, name }) => {
return {
id,
name: translate(name)
};
});
this.activityForm
.get('tags')
.valueChanges.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tags) => {
this.dataService
.putHoldingTags({
tags,
dataSource: this.data.dataSource,
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
});
this.dataService
.fetchHoldingDetail({
dataSource: this.data.dataSource,
@ -248,12 +293,27 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
this.sectors = {};
this.SymbolProfile = SymbolProfile;
this.tags = tags.map(({ id, name }) => {
return {
id,
name: translate(name)
};
});
this.activityForm.setValue({ tags: this.tags }, { emitEvent: false });
this.filteredTagsObservable = this.activityForm.controls[
'tags'
].valueChanges.pipe(
startWith(this.activityForm.get('tags').value),
map((aTags: Tag[] | null) => {
return aTags
? this.filterTags(aTags)
: this.tagsAvailable.slice();
})
);
this.transactionCount = transactionCount;
this.totalItems = transactionCount;
this.value = value;
@ -353,6 +413,17 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
});
}
public onAddTag(event: MatAutocompleteSelectedEvent) {
this.activityForm.get('tags').setValue([
...(this.activityForm.get('tags').value ?? []),
this.tagsAvailable.find(({ id }) => {
return id === event.option.value;
})
]);
this.tagInput.nativeElement.value = '';
}
public onClose() {
this.dialogRef.close();
}
@ -377,8 +448,26 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
});
}
public onRemoveTag(aTag: Tag) {
this.activityForm.get('tags').setValue(
this.activityForm.get('tags').value.filter(({ id }) => {
return id !== aTag.id;
})
);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private filterTags(aTags: Tag[]) {
const tagIds = aTags.map(({ id }) => {
return id;
});
return this.tagsAvailable.filter(({ id }) => {
return !tagIds.includes(id);
});
}
}

@ -325,7 +325,7 @@
<mat-tab-group
animationDuration="0"
class="mb-3"
class="mb-5"
[mat-stretch-tabs]="false"
[ngClass]="{ 'd-none': !activities?.length }"
>
@ -375,7 +375,49 @@
</mat-tab>
</mat-tab-group>
@if (tags?.length > 0) {
<div
class="row"
[ngClass]="{
'd-none': !data.hasPermissionToUpdateOrder
}"
>
<div class="col">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Tags</mat-label>
<mat-chip-grid #tagsChipList>
@for (tag of activityForm.get('tags')?.value; track tag.id) {
<mat-chip-row
matChipRemove
[removable]="true"
(removed)="onRemoveTag(tag)"
>
{{ tag.name }}
<ion-icon class="ml-2" matPrefix name="close-outline" />
</mat-chip-row>
}
<input
#tagInput
name="close-outline"
[matAutocomplete]="autocompleteTags"
[matChipInputFor]="tagsChipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
/>
</mat-chip-grid>
<mat-autocomplete
#autocompleteTags="matAutocomplete"
(optionSelected)="onAddTag($event)"
>
@for (tag of filteredTagsObservable | async; track tag.id) {
<mat-option [value]="tag.id">
{{ tag.name }}
</mat-option>
}
</mat-autocomplete>
</mat-form-field>
</div>
</div>
@if (!data.hasPermissionToUpdateOrder && tagsAvailable?.length > 0) {
<div class="row">
<div class="col">
<div class="h5" i18n>Tags</div>

@ -9,6 +9,7 @@ export interface HoldingDetailDialogParams {
deviceType: string;
hasImpersonationId: boolean;
hasPermissionToReportDataGlitch: boolean;
hasPermissionToUpdateOrder: boolean;
locale: string;
symbol: string;
}

@ -53,8 +53,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
public isToday = isToday;
public mode: 'create' | 'update';
public platforms: { id: string; name: string }[];
public separatorKeysCodes: number[] = [ENTER, COMMA];
public tags: Tag[] = [];
public separatorKeysCodes: number[] = [COMMA, ENTER];
public tagsAvailable: Tag[] = [];
public total = 0;
public typesTranslationMap = new Map<Type, string>();
public Validators = Validators;
@ -81,7 +81,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.currencies = currencies;
this.defaultDateFormat = getDateFormatString(this.locale);
this.platforms = platforms;
this.tags = tags.map(({ id, name }) => {
this.tagsAvailable = tags.map(({ id, name }) => {
return {
id,
name: translate(name)
@ -287,7 +287,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
].valueChanges.pipe(
startWith(this.activityForm.get('tags').value),
map((aTags: Tag[] | null) => {
return aTags ? this.filterTags(aTags) : this.tags.slice();
return aTags ? this.filterTags(aTags) : this.tagsAvailable.slice();
})
);
@ -441,10 +441,11 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
public onAddTag(event: MatAutocompleteSelectedEvent) {
this.activityForm.get('tags').setValue([
...(this.activityForm.get('tags').value ?? []),
this.tags.find(({ id }) => {
this.tagsAvailable.find(({ id }) => {
return id === event.option.value;
})
]);
this.tagInput.nativeElement.value = '';
}
@ -518,12 +519,12 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
}
private filterTags(aTags: Tag[]) {
const tagIds = aTags.map((tag) => {
return tag.id;
const tagIds = aTags.map(({ id }) => {
return id;
});
return this.tags.filter((tag) => {
return !tagIds.includes(tag.id);
return this.tagsAvailable.filter(({ id }) => {
return !tagIds.includes(id);
});
}

@ -378,11 +378,11 @@
</mat-select>
</mat-form-field>
</div>
<div class="mb-3" [ngClass]="{ 'd-none': tags?.length < 1 }">
<div class="mb-3" [ngClass]="{ 'd-none': tagsAvailable?.length < 1 }">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Tags</mat-label>
<mat-chip-grid #tagsChipList>
@for (tag of activityForm.get('tags')?.value; track tag) {
@for (tag of activityForm.get('tags')?.value; track tag.id) {
<mat-chip-row
matChipRemove
[removable]="true"
@ -404,7 +404,7 @@
#autocompleteTags="matAutocomplete"
(optionSelected)="onAddTag($event)"
>
@for (tag of filteredTagsObservable | async; track tag) {
@for (tag of filteredTagsObservable | async; track tag.id) {
<mat-option [value]="tag.id">
{{ tag.name }}
</mat-option>

@ -47,7 +47,8 @@ import { SortDirection } from '@angular/material/sort';
import {
AccountBalance,
DataSource,
Order as OrderModel
Order as OrderModel,
Tag
} from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { cloneDeep, groupBy, isNumber } from 'lodash';
@ -649,6 +650,17 @@ export class DataService {
return this.http.put<void>(`/api/v1/admin/settings/${key}`, aData);
}
public putHoldingTags({
dataSource,
symbol,
tags
}: { tags: Tag[] } & UniqueAsset) {
return this.http.put<void>(
`/api/v1/portfolio/position/${dataSource}/${symbol}/tags`,
{ tags }
);
}
public putOrder(aOrder: UpdateOrderDto) {
return this.http.put<UserItem>(`/api/v1/order/${aOrder.id}`, aOrder);
}

Loading…
Cancel
Save