Feature/add tag management in admin control panel (#2389)

* Add tag management

* Update locales

* Update changelog
This commit is contained in:
Thomas Kaul 2023-09-26 18:56:09 +02:00 committed by GitHub
parent 6230aa87e2
commit 5b7409d08e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 4223 additions and 491 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added the management of tags in the admin control panel
- Added a blog post: _Hacktoberfest 2023_
### Changed

View File

@ -39,6 +39,7 @@ import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SitemapModule } from './sitemap/sitemap.module';
import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module';
import { TagModule } from './tag/tag.module';
import { UserModule } from './user/user.module';
@Module({
@ -101,6 +102,7 @@ import { UserModule } from './user/user.module';
SitemapModule,
SubscriptionModule,
SymbolModule,
TagModule,
TwitterBotModule,
UserModule
],

View File

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

View File

@ -0,0 +1,104 @@
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Post,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateTagDto } from './create-tag.dto';
import { TagService } from './tag.service';
import { UpdateTagDto } from './update-tag.dto';
@Controller('tag')
export class TagController {
public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly tagService: TagService
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
public async getTags() {
return this.tagService.getTagsWithActivityCount();
}
@Post()
@UseGuards(AuthGuard('jwt'))
public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
if (!hasPermission(this.request.user.permissions, permissions.createTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.createTag(data);
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
if (!hasPermission(this.request.user.permissions, permissions.updateTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalTag = await this.tagService.getTag({
id
});
if (!originalTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.updateTag({
data: {
...data
},
where: {
id
}
});
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteTag(@Param('id') id: string) {
if (!hasPermission(this.request.user.permissions, permissions.deleteTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalTag = await this.tagService.getTag({
id
});
if (!originalTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.deleteTag({ id });
}
}

View File

@ -0,0 +1,13 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { TagController } from './tag.controller';
import { TagService } from './tag.service';
@Module({
controllers: [TagController],
exports: [TagService],
imports: [PrismaModule],
providers: [TagService]
})
export class TagModule {}

View File

@ -0,0 +1,79 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { Prisma, Tag } from '@prisma/client';
@Injectable()
export class TagService {
public constructor(private readonly prismaService: PrismaService) {}
public async createTag(data: Prisma.TagCreateInput) {
return this.prismaService.tag.create({
data
});
}
public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> {
return this.prismaService.tag.delete({ where });
}
public async getTag(
tagWhereUniqueInput: Prisma.TagWhereUniqueInput
): Promise<Tag> {
return this.prismaService.tag.findUnique({
where: tagWhereUniqueInput
});
}
public async getTags({
cursor,
orderBy,
skip,
take,
where
}: {
cursor?: Prisma.TagWhereUniqueInput;
orderBy?: Prisma.TagOrderByWithRelationInput;
skip?: number;
take?: number;
where?: Prisma.TagWhereInput;
} = {}) {
return this.prismaService.tag.findMany({
cursor,
orderBy,
skip,
take,
where
});
}
public async getTagsWithActivityCount() {
const tagsWithOrderCount = await this.prismaService.tag.findMany({
include: {
_count: {
select: { orders: true }
}
}
});
return tagsWithOrderCount.map(({ _count, id, name }) => {
return {
id,
name,
activityCount: _count.orders
};
});
}
public async updateTag({
data,
where
}: {
data: Prisma.TagUpdateInput;
where: Prisma.TagWhereUniqueInput;
}): Promise<Tag> {
return this.prismaService.tag.update({
data,
where
});
}
}

View File

@ -0,0 +1,9 @@
import { IsString } from 'class-validator';
export class UpdateTagDto {
@IsString()
id: string;
@IsString()
name: string;
}

View File

@ -72,19 +72,6 @@
</div>
</div>
</div>
<div
*ngIf="info?.tags?.length > 0"
class="align-items-start d-flex my-3"
>
<div class="w-50" i18n>Tags</div>
<div class="w-50">
<table>
<tr *ngFor="let tag of info.tags">
<td class="pl-1">{{ tag.name }}</td>
</tr>
</table>
</div>
</div>
<div class="d-flex my-3">
<div class="w-50" i18n>User Signup</div>
<div class="w-50">

View File

@ -2,14 +2,13 @@
<div class="mb-5 row">
<div class="col">
<h2 class="text-center" i18n>Platforms</h2>
<gf-admin-platform></gf-admin-platform>
<gf-admin-platform />
</div>
</div>
<!--
<div class="row">
<div class="col">
<h2 class="text-center" i18n>Tags</h2>
<gf-admin-tag />
</div>
</div>
-->
</div>

View File

@ -2,12 +2,18 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module';
import { GfAdminTagModule } from '@ghostfolio/client/components/admin-tag/admin-tag.module';
import { AdminSettingsComponent } from './admin-settings.component';
@NgModule({
declarations: [AdminSettingsComponent],
imports: [CommonModule, GfAdminPlatformModule, RouterModule],
imports: [
CommonModule,
GfAdminPlatformModule,
GfAdminTagModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminSettingsModule {}

View File

@ -0,0 +1,85 @@
<div class="container">
<div class="row">
<div class="col">
<div class="d-flex justify-content-end">
<a
color="primary"
i18n
mat-flat-button
[queryParams]="{ createTagDialog: true }"
[routerLink]="[]"
>
Add Tag
</a>
</div>
<table
class="gf-table w-100"
mat-table
matSort
matSortActive="name"
matSortDirection="asc"
[dataSource]="dataSource"
>
<ng-container matColumnDef="name">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="name"
>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.name }}
</td>
</ng-container>
<ng-container matColumnDef="activities">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="activityCount"
>
<ng-container i18n>Activities</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.activityCount }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th
*matHeaderCellDef
class="px-1 text-center"
i18n
mat-header-cell
></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="tagMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu #tagMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateTag(element)">
<ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</button>
<button mat-menu-item (click)="onDeleteTag(element.id)">
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</button>
</mat-menu>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
</div>
</div>
</div>

View File

@ -0,0 +1,5 @@
@import 'apps/client/src/styles/ghostfolio-style';
:host {
display: block;
}

View File

@ -0,0 +1,199 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Tag } from '@prisma/client';
import { get } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
import { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog/create-or-update-tag-dialog.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-admin-tag',
styleUrls: ['./admin-tag.component.scss'],
templateUrl: './admin-tag.component.html'
})
export class AdminTagComponent implements OnInit, OnDestroy {
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<Tag> = new MatTableDataSource();
public deviceType: string;
public displayedColumns = ['name', 'activities', 'actions'];
public tags: Tag[];
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router,
private userService: UserService
) {
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['createTagDialog']) {
this.openCreateTagDialog();
} else if (params['editTagDialog']) {
if (this.tags) {
const tag = this.tags.find(({ id }) => {
return id === params['tagId'];
});
this.openUpdateTagDialog(tag);
} else {
this.router.navigate(['.'], { relativeTo: this.route });
}
}
});
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.fetchTags();
}
public onDeleteTag(aId: string) {
const confirmation = confirm(
$localize`Do you really want to delete this tag?`
);
if (confirmation) {
this.deleteTag(aId);
}
}
public onUpdateTag({ id }: Tag) {
this.router.navigate([], {
queryParams: { editTagDialog: true, tagId: id }
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private deleteTag(aId: string) {
this.adminService
.deleteTag(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchTags();
}
});
}
private fetchTags() {
this.adminService
.fetchTags()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tags) => {
this.tags = tags;
this.dataSource = new MatTableDataSource(this.tags);
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get;
this.changeDetectorRef.markForCheck();
});
}
private openCreateTagDialog() {
const dialogRef = this.dialog.open(CreateOrUpdateTagDialog, {
data: {
tag: {
name: null
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
const tag: CreateTagDto = data?.tag;
if (tag) {
this.adminService
.postTag(tag)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchTags();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private openUpdateTagDialog({ id, name }) {
const dialogRef = this.dialog.open(CreateOrUpdateTagDialog, {
data: {
tag: {
id,
name
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
const tag: UpdateTagDto = data?.tag;
if (tag) {
this.adminService
.putTag(tag)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchTags();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
}

View File

@ -0,0 +1,26 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { AdminTagComponent } from './admin-tag.component';
import { GfCreateOrUpdateTagDialogModule } from './create-or-update-tag-dialog/create-or-update-tag-dialog.module';
@NgModule({
declarations: [AdminTagComponent],
exports: [AdminTagComponent],
imports: [
CommonModule,
GfCreateOrUpdateTagDialogModule,
MatButtonModule,
MatMenuModule,
MatSortModule,
MatTableModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminTagModule {}

View File

@ -0,0 +1,30 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Subject } from 'rxjs';
import { CreateOrUpdateTagDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' },
selector: 'gf-create-or-update-tag-dialog',
styleUrls: ['./create-or-update-tag-dialog.scss'],
templateUrl: 'create-or-update-tag-dialog.html'
})
export class CreateOrUpdateTagDialog {
private unsubscribeSubject = new Subject<void>();
public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTagDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdateTagDialog>
) {}
public onCancel() {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,23 @@
<form #addTagForm="ngForm" class="d-flex flex-column h-100">
<h1 *ngIf="data.tag.id" i18n mat-dialog-title>Update tag</h1>
<h1 *ngIf="!data.tag.id" i18n mat-dialog-title>Add tag</h1>
<div class="flex-grow-1 py-3" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name</mat-label>
<input matInput name="name" required [(ngModel)]="data.tag.name" />
</mat-form-field>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button
color="primary"
mat-flat-button
[disabled]="!addTagForm.form.valid"
[mat-dialog-close]="data"
>
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>

View File

@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog.component';
@NgModule({
declarations: [CreateOrUpdateTagDialog],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule
]
})
export class GfCreateOrUpdateTagDialogModule {}

View File

@ -0,0 +1,7 @@
:host {
display: block;
.mat-mdc-dialog-content {
max-height: unset;
}
}

View File

@ -0,0 +1,5 @@
import { Tag } from '@prisma/client';
export interface CreateOrUpdateTagDialogParams {
tag: Tag;
}

View File

@ -4,6 +4,8 @@ import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-pr
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
@ -15,7 +17,7 @@ import {
Filter,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { DataSource, MarketData, Platform, Prisma } from '@prisma/client';
import { DataSource, MarketData, Platform, Prisma, Tag } from '@prisma/client';
import { JobStatus } from 'bull';
import { format, parseISO } from 'date-fns';
import { Observable, map } from 'rxjs';
@ -64,6 +66,10 @@ export class AdminService {
);
}
public deleteTag(aId: string) {
return this.http.delete<void>(`/api/v1/tag/${aId}`);
}
public fetchAdminData() {
return this.http.get<AdminData>('/api/v1/admin');
}
@ -139,6 +145,10 @@ export class AdminService {
return this.http.get<Platform[]>('/api/v1/platform');
}
public fetchTags() {
return this.http.get<Tag[]>('/api/v1/tag');
}
public gather7Days() {
return this.http.post<void>('/api/v1/admin/gather', {});
}
@ -208,6 +218,10 @@ export class AdminService {
return this.http.post<Platform>(`/api/v1/platform`, aPlatform);
}
public postTag(aTag: CreateTagDto) {
return this.http.post<Tag>(`/api/v1/tag`, aTag);
}
public putMarketData({
dataSource,
date,
@ -233,4 +247,8 @@ export class AdminService {
aPlatform
);
}
public putTag(aTag: UpdateTagDto) {
return this.http.put<Tag>(`/api/v1/tag/${aTag.id}`, aTag);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -7,12 +7,14 @@ export const permissions = {
createAccount: 'createAccount',
createOrder: 'createOrder',
createPlatform: 'createPlatform',
createTag: 'createTag',
createUserAccount: 'createUserAccount',
deleteAccess: 'deleteAccess',
deleteAccount: 'deleteAcccount',
deleteAuthDevice: 'deleteAuthDevice',
deleteOrder: 'deleteOrder',
deletePlatform: 'deletePlatform',
deleteTag: 'deleteTag',
deleteUser: 'deleteUser',
enableFearAndGreedIndex: 'enableFearAndGreedIndex',
enableImport: 'enableImport',
@ -29,6 +31,7 @@ export const permissions = {
updateAuthDevice: 'updateAuthDevice',
updateOrder: 'updateOrder',
updatePlatform: 'updatePlatform',
updateTag: 'updateTag',
updateUserSettings: 'updateUserSettings',
updateViewMode: 'updateViewMode'
};
@ -42,16 +45,19 @@ export function getPermissions(aRole: Role): string[] {
permissions.createAccount,
permissions.createOrder,
permissions.createPlatform,
permissions.createTag,
permissions.deleteAccess,
permissions.deleteAccount,
permissions.deleteAuthDevice,
permissions.deleteOrder,
permissions.deletePlatform,
permissions.deleteTag,
permissions.deleteUser,
permissions.updateAccount,
permissions.updateAuthDevice,
permissions.updateOrder,
permissions.updatePlatform,
permissions.updateTag,
permissions.updateUserSettings,
permissions.updateViewMode
];