From ac082b362cfd53454db16302aa06a8b57a130e5c Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:36:55 +0200 Subject: [PATCH] Task/align activity dialog host with account dialog host (#7472) Align with account dialog host --- .../activities/activities-page.component.ts | 4 +- .../activity-dialog-host.component.ts | 152 ++++++++++++------ 2 files changed, 108 insertions(+), 48 deletions(-) diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts index 0ff4168ac..c67629bb6 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts @@ -331,7 +331,9 @@ export class GfActivitiesPageComponent implements OnInit { this.hasPermissionToCreateActivity = !this.hasImpersonationId && - hasPermission(this.user.permissions, permissions.createActivity); + hasPermission(this.user.permissions, permissions.createActivity) && + !this.user.settings?.isRestrictedView; + this.hasPermissionToDeleteActivity = !this.hasImpersonationId && hasPermission(this.user.permissions, permissions.deleteActivity); diff --git a/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts b/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts index e0e3fc950..6b70acbad 100644 --- a/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts @@ -1,6 +1,8 @@ +import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { Activity, User } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { DataService } from '@ghostfolio/ui/services'; @@ -16,8 +18,15 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { ActivatedRoute, Router } from '@angular/router'; import { DeviceDetectorService } from 'ngx-device-detector'; -import { Observable, of } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; +import { EMPTY, Observable, of, Subject } from 'rxjs'; +import { + catchError, + distinctUntilChanged, + map, + switchMap, + takeUntil, + tap +} from 'rxjs/operators'; import { GfCreateOrUpdateActivityDialogComponent } from '../create-or-update-activity-dialog/create-or-update-activity-dialog.component'; import { CreateOrUpdateActivityDialogParams } from '../create-or-update-activity-dialog/interfaces/interfaces'; @@ -31,72 +40,102 @@ import { ActivityDialogMode } from './types/activity-dialog-mode.type'; export class GfActivityDialogHostComponent implements OnDestroy, OnInit { private dialogRef: MatDialogRef; + private readonly dialogClosed = new Subject(); + private readonly dataService = inject(DataService); private readonly destroyRef = inject(DestroyRef); private readonly deviceDetectorService = inject(DeviceDetectorService); private readonly dialog = inject(MatDialog); + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly userService = inject(UserService); public ngOnInit() { const mode = this.route.snapshot.data.mode as ActivityDialogMode; - const activityId = this.route.snapshot.paramMap.get('activityId'); - - const activity$: Observable = activityId - ? this.dataService.fetchActivity(activityId) - : of(undefined); - this.userService - .get() + // The router reuses this component when only the activity id changes, so + // the parameters are observed instead of read from the snapshot once + this.route.paramMap .pipe( - switchMap((user) => { - return activity$.pipe( - map((activity) => { - return { activity, user }; + map((paramMap) => { + return paramMap.get('activityId'); + }), + distinctUntilChanged(), + tap(() => { + this.closeDialog(); + }), + switchMap((activityId) => { + const activity$: Observable = activityId + ? this.dataService.fetchActivity(activityId) + : of(undefined); + + return this.userService.get().pipe( + switchMap((user) => { + return activity$.pipe( + map((activity) => { + return { activity, user }; + }) + ); + }), + catchError(() => { + this.navigateBack(); + + return EMPTY; }) ); }), takeUntilDestroyed(this.destroyRef) ) - .subscribe({ - error: () => { - this.navigateBack(); - }, - next: ({ activity, user }) => { - if (mode === 'update') { - if (!activity) { - this.navigateBack(); - - return; - } - - this.openDialog({ activity, user, isUpdate: true }); + .subscribe(({ activity, user }) => { + if (mode === 'update') { + if ( + !activity || + !hasPermission(user?.permissions, permissions.updateActivity) || + this.isReadOnlyMode(user) + ) { + this.navigateBack(); return; } - if (mode === 'clone' && !activity) { - this.navigateBack(); + this.openDialog({ activity, user, isUpdate: true }); - return; - } + return; + } + + if (mode === 'clone' && !activity) { + this.navigateBack(); - this.openDialog({ - user, - activity: { - ...activity, - accountId: activity?.accountId, - assetProfile: activity?.assetProfile ?? null, - date: new Date(), - fee: 0, - id: null, - type: activity?.type ?? 'BUY', - unitPrice: null - }, - isUpdate: false - }); + return; } + + // Cloning creates a new activity as well + if ( + !hasPermission(user?.permissions, permissions.createActivity) || + this.isReadOnlyMode(user) + ) { + this.navigateBack(); + + return; + } + + this.openDialog({ + user, + activity: { + ...activity, + accountId: activity?.accountId, + assetProfile: activity?.assetProfile ?? null, + date: new Date(), + fee: 0, + id: null, + type: activity?.type ?? 'BUY', + unitPrice: null + }, + isUpdate: false + }); }); } @@ -105,6 +144,23 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit { // be closed explicitly when leaving the route (for example via the browser // navigation) this.dialogRef?.close(); + + this.dialogClosed.complete(); + } + + private closeDialog() { + // Tear down the subscription of the dialog which is about to be replaced, + // so that its result is not mistaken for the user closing it + this.dialogClosed.next(); + + this.dialogRef?.close(); + } + + private isReadOnlyMode(user: User) { + return ( + !!this.impersonationStorageService.getId() || + !!user?.settings?.isRestrictedView + ); } private navigateBack() { @@ -124,7 +180,7 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit { }) { const deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - this.dialogRef = this.dialog.open< + const dialogRef = this.dialog.open< GfCreateOrUpdateActivityDialogComponent, CreateOrUpdateActivityDialogParams >(GfCreateOrUpdateActivityDialogComponent, { @@ -137,9 +193,11 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit { width: deviceType === 'mobile' ? '100vw' : '50rem' }); - this.dialogRef + this.dialogRef = dialogRef; + + dialogRef .afterClosed() - .pipe(takeUntilDestroyed(this.destroyRef)) + .pipe(takeUntil(this.dialogClosed), takeUntilDestroyed(this.destroyRef)) .subscribe((result: CreateOrderDto | UpdateOrderDto | null) => { if (!result) { this.navigateBack();