mirror of https://github.com/ghostfolio/ghostfolio
Thomas Kaul
1 year ago
committed by
GitHub
29 changed files with 4199 additions and 467 deletions
@ -0,0 +1,6 @@ |
|||
import { IsString } from 'class-validator'; |
|||
|
|||
export class CreateTagDto { |
|||
@IsString() |
|||
name: string; |
|||
} |
@ -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 }); |
|||
} |
|||
} |
@ -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 {} |
@ -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 |
|||
}); |
|||
} |
|||
} |
@ -0,0 +1,9 @@ |
|||
import { IsString } from 'class-validator'; |
|||
|
|||
export class UpdateTagDto { |
|||
@IsString() |
|||
id: string; |
|||
|
|||
@IsString() |
|||
name: string; |
|||
} |
@ -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> |
@ -0,0 +1,5 @@ |
|||
@import 'apps/client/src/styles/ghostfolio-style'; |
|||
|
|||
:host { |
|||
display: block; |
|||
} |
@ -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 }); |
|||
}); |
|||
} |
|||
} |
@ -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 {} |
@ -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(); |
|||
} |
|||
} |
@ -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> |
@ -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 {} |
@ -0,0 +1,7 @@ |
|||
:host { |
|||
display: block; |
|||
|
|||
.mat-mdc-dialog-content { |
|||
max-height: unset; |
|||
} |
|||
} |
@ -0,0 +1,5 @@ |
|||
import { Tag } from '@prisma/client'; |
|||
|
|||
export interface CreateOrUpdateTagDialogParams { |
|||
tag: Tag; |
|||
} |
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
Loading…
Reference in new issue