mirror of https://github.com/ghostfolio/ghostfolio
19 changed files with 650 additions and 5 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; |
|||
} |
Loading…
Reference in new issue