Feature/allow creating custom tags from holding detail dialog component (#4308)

* Allow creating custom tags from holding detail dialog component

* Extend create tag endpoint

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
This commit is contained in:
Ken Tandrian 2025-02-15 14:48:53 +07:00 committed by GitHub
parent 58dfba8e63
commit ec8fce44a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 99 additions and 20 deletions

View File

@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added a new static portfolio analysis rule: _Regional Market Cluster Risk_ (Asia-Pacific Markets) - Added a new static portfolio analysis rule: _Regional Market Cluster Risk_ (Asia-Pacific Markets)
- Added a new static portfolio analysis rule: _Regional Market Cluster Risk_ (Japan) - Added a new static portfolio analysis rule: _Regional Market Cluster Risk_ (Japan)
- Added support to create custom tags in the holding detail dialog
- Extended the tags selector component by a `readonly` attribute - Extended the tags selector component by a `readonly` attribute
- Extended the tags selector component to support creating custom tags - Extended the tags selector component to support creating custom tags
- Extended the holding detail dialog by the historical market data editor (experimental) - Extended the holding detail dialog by the historical market data editor (experimental)

View File

@ -1,6 +1,10 @@
import { IsString } from 'class-validator'; import { IsOptional, IsString } from 'class-validator';
export class CreateTagDto { export class CreateTagDto {
@IsString() @IsString()
name: string; name: string;
@IsOptional()
@IsString()
userId?: string;
} }

View File

@ -1,6 +1,7 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -8,11 +9,13 @@ import {
Delete, Delete,
Get, Get,
HttpException, HttpException,
Inject,
Param, Param,
Post, Post,
Put, Put,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -23,7 +26,10 @@ import { UpdateTagDto } from './update-tag.dto';
@Controller('tag') @Controller('tag')
export class TagController { export class TagController {
public constructor(private readonly tagService: TagService) {} public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly tagService: TagService
) {}
@Get() @Get()
@HasPermission(permissions.readTags) @HasPermission(permissions.readTags)
@ -33,9 +39,34 @@ export class TagController {
} }
@Post() @Post()
@HasPermission(permissions.createTag) @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createTag(@Body() data: CreateTagDto): Promise<Tag> { public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
const canCreateOwnTag = hasPermission(
this.request.user.permissions,
permissions.createOwnTag
);
const canCreateTag = hasPermission(
this.request.user.permissions,
permissions.createTag
);
if (!canCreateOwnTag && !canCreateTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if (canCreateOwnTag && !canCreateTag) {
if (data.userId !== this.request.user.id) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
return this.tagService.createTag(data); return this.tagService.createTag(data);
} }

View File

@ -1,4 +1,4 @@
import { IsString } from 'class-validator'; import { IsOptional, IsString } from 'class-validator';
export class UpdateTagDto { export class UpdateTagDto {
@IsString() @IsString()
@ -6,4 +6,8 @@ export class UpdateTagDto {
@IsString() @IsString()
name: string; name: string;
@IsOptional()
@IsString()
userId?: string;
} }

View File

@ -347,6 +347,7 @@ export class UserService {
permissions.accessHoldingsChart, permissions.accessHoldingsChart,
permissions.createAccess, permissions.createAccess,
permissions.createMarketDataOfOwnAssetProfile, permissions.createMarketDataOfOwnAssetProfile,
permissions.createOwnTag,
permissions.readAiPrompt, permissions.readAiPrompt,
permissions.readMarketDataOfOwnAssetProfile, permissions.readMarketDataOfOwnAssetProfile,
permissions.updateMarketDataOfOwnAssetProfile permissions.updateMarketDataOfOwnAssetProfile

View File

@ -2,6 +2,7 @@ import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interf
import { GfAccountsTableModule } from '@ghostfolio/client/components/accounts-table/accounts-table.module'; import { GfAccountsTableModule } from '@ghostfolio/client/components/accounts-table/accounts-table.module';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config'; import { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config';
@ -50,7 +51,7 @@ import { Account, MarketData, Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { switchMap, takeUntil } from 'rxjs/operators';
import { HoldingDetailDialogParams } from './interfaces/interfaces'; import { HoldingDetailDialogParams } from './interfaces/interfaces';
@ -98,6 +99,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public dividendYieldPercentWithCurrencyEffect: number; public dividendYieldPercentWithCurrencyEffect: number;
public feeInBaseCurrency: number; public feeInBaseCurrency: number;
public firstBuyDate: string; public firstBuyDate: string;
public hasPermissionToCreateOwnTag: boolean;
public hasPermissionToReadMarketDataOfOwnAssetProfile: boolean; public hasPermissionToReadMarketDataOfOwnAssetProfile: boolean;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public investment: number; public investment: number;
@ -131,6 +133,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>, public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
@ -153,7 +156,31 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.activityForm this.activityForm
.get('tags') .get('tags')
.valueChanges.pipe(takeUntil(this.unsubscribeSubject)) .valueChanges.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tags) => { .subscribe((tags: Tag[]) => {
const newTag = tags.find(({ id }) => {
return id === undefined;
});
if (newTag && this.hasPermissionToCreateOwnTag) {
this.adminService
.postTag({ ...newTag, userId: this.user.id })
.pipe(
switchMap((createdTag) => {
return this.dataService.putHoldingTags({
dataSource: this.data.dataSource,
symbol: this.data.symbol,
tags: [
...tags.filter(({ id }) => {
return id !== undefined;
}),
createdTag
]
});
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe();
} else {
this.dataService this.dataService
.putHoldingTags({ .putHoldingTags({
tags, tags,
@ -162,6 +189,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(); .subscribe();
}
}); });
this.dataService this.dataService
@ -420,6 +448,11 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToCreateOwnTag = hasPermission(
this.user.permissions,
permissions.createOwnTag
);
this.tagsAvailable = this.tagsAvailable =
this.user?.tags?.map((tag) => { this.user?.tags?.map((tag) => {
return { return {

View File

@ -388,6 +388,7 @@
</mat-tab-group> </mat-tab-group>
<gf-tags-selector <gf-tags-selector
[hasPermissionToCreateTag]="hasPermissionToCreateOwnTag"
[readonly]="!data.hasPermissionToUpdateOrder" [readonly]="!data.hasPermissionToUpdateOrder"
[tags]="activityForm.get('tags')?.value" [tags]="activityForm.get('tags')?.value"
[tagsAvailable]="tagsAvailable" [tagsAvailable]="tagsAvailable"

View File

@ -13,6 +13,7 @@ export const permissions = {
createMarketData: 'createMarketData', createMarketData: 'createMarketData',
createMarketDataOfOwnAssetProfile: 'createMarketDataOfOwnAssetProfile', createMarketDataOfOwnAssetProfile: 'createMarketDataOfOwnAssetProfile',
createOrder: 'createOrder', createOrder: 'createOrder',
createOwnTag: 'createOwnTag',
createPlatform: 'createPlatform', createPlatform: 'createPlatform',
createTag: 'createTag', createTag: 'createTag',
createUserAccount: 'createUserAccount', createUserAccount: 'createUserAccount',
@ -67,6 +68,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.createMarketData, permissions.createMarketData,
permissions.createMarketDataOfOwnAssetProfile, permissions.createMarketDataOfOwnAssetProfile,
permissions.createOrder, permissions.createOrder,
permissions.createOwnTag,
permissions.createPlatform, permissions.createPlatform,
permissions.createTag, permissions.createTag,
permissions.deleteAccess, permissions.deleteAccess,
@ -110,6 +112,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.createAccountBalance, permissions.createAccountBalance,
permissions.createMarketDataOfOwnAssetProfile, permissions.createMarketDataOfOwnAssetProfile,
permissions.createOrder, permissions.createOrder,
permissions.createOwnTag,
permissions.deleteAccess, permissions.deleteAccess,
permissions.deleteAccount, permissions.deleteAccount,
permissions.deleteAccountBalance, permissions.deleteAccountBalance,

View File

@ -43,7 +43,7 @@
</mat-option> </mat-option>
} }
@if (hasPermissionToCreateTags && tagInputControl.value) { @if (hasPermissionToCreateTag && tagInputControl.value) {
<mat-option [value]="tagInputControl.value.trim()"> <mat-option [value]="tagInputControl.value.trim()">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="add-circle-outline" /> <ion-icon class="mr-2" name="add-circle-outline" />

View File

@ -1,4 +1,5 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import '@angular/localize/init';
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; import { Meta, moduleMetadata, StoryObj } from '@storybook/angular';
@ -49,7 +50,7 @@ export const Default: Story = {
export const CreateCustomTags: Story = { export const CreateCustomTags: Story = {
args: { args: {
hasPermissionToCreateTags: true, hasPermissionToCreateTag: true,
tags: [ tags: [
{ {
id: 'EMERGENCY_FUND', id: 'EMERGENCY_FUND',

View File

@ -42,7 +42,7 @@ import { BehaviorSubject, Subject, takeUntil } from 'rxjs';
templateUrl: 'tags-selector.component.html' templateUrl: 'tags-selector.component.html'
}) })
export class GfTagsSelectorComponent implements OnInit, OnChanges, OnDestroy { export class GfTagsSelectorComponent implements OnInit, OnChanges, OnDestroy {
@Input() hasPermissionToCreateTags = false; @Input() hasPermissionToCreateTag = false;
@Input() readonly = false; @Input() readonly = false;
@Input() tags: Tag[]; @Input() tags: Tag[];
@Input() tagsAvailable: Tag[]; @Input() tagsAvailable: Tag[];
@ -81,7 +81,7 @@ export class GfTagsSelectorComponent implements OnInit, OnChanges, OnDestroy {
return id === event.option.value; return id === event.option.value;
}); });
if (!tag && this.hasPermissionToCreateTags) { if (!tag && this.hasPermissionToCreateTag) {
tag = { tag = {
id: undefined, id: undefined,
name: event.option.value as string, name: event.option.value as string,