diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index 90ff2f1bc..ff67daf5e 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -40,6 +40,7 @@ import { filter } from 'rxjs/operators'; import { GfFooterComponent } from './components/footer/footer.component'; import { GfHeaderComponent } from './components/header/header.component'; import { GfHoldingDetailDialogComponent } from './components/holding-detail-dialog/holding-detail-dialog.component'; +import { HoldingDetailDialogResult } from './components/holding-detail-dialog/interfaces/interfaces'; import { GfAppQueryParams } from './interfaces/interfaces'; import { ImpersonationStorageService } from './services/impersonation-storage.service'; import { UserService } from './services/user/user.service'; @@ -319,7 +320,11 @@ export class GfAppComponent implements OnInit { dialogRef .afterClosed() .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { + .subscribe((result: HoldingDetailDialogResult) => { + if (result?.isNavigating) { + return; + } + void this.router.navigate([], { queryParams: { dataSource: null, diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index ca5b1a6e3..62b87cfc0 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -54,7 +54,10 @@ import { isNumber } from 'lodash'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { filter, forkJoin } from 'rxjs'; -import { AccountDetailDialogParams } from './interfaces/interfaces'; +import { + AccountDetailDialogParams, + AccountDetailDialogResult +} from './interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -112,7 +115,9 @@ export class GfAccountDetailDialogComponent implements OnInit { private readonly dataService = inject(DataService); private readonly destroyRef = inject(DestroyRef); private readonly dialogRef = - inject>(MatDialogRef); + inject< + MatDialogRef + >(MatDialogRef); private readonly router = inject(Router); private readonly userService = inject(UserService); @@ -125,7 +130,7 @@ export class GfAccountDetailDialogComponent implements OnInit { takeUntilDestroyed(this.destroyRef) ) .subscribe(() => { - this.dialogRef.close(); + this.dialogRef.close({ isNavigating: true }); }); this.userService.stateChanged diff --git a/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts index 01c84e956..2f80dac36 100644 --- a/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts @@ -4,3 +4,7 @@ export interface AccountDetailDialogParams { hasImpersonationId: boolean; hasPermissionToCreateActivity: boolean; } + +export interface AccountDetailDialogResult { + isNavigating?: boolean; +} diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts index 43ba6edc0..944b1cb1d 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts @@ -82,7 +82,10 @@ import { isNumber, round, uniqBy } from 'lodash'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { filter, switchMap } from 'rxjs/operators'; -import { HoldingDetailDialogParams } from './interfaces/interfaces'; +import { + HoldingDetailDialogParams, + HoldingDetailDialogResult +} from './interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -186,9 +189,10 @@ export class GfHoldingDetailDialogComponent implements OnInit { protected value: number; protected readonly data = inject(MAT_DIALOG_DATA); - protected readonly dialogRef = inject( - MatDialogRef - ); + protected readonly dialogRef = + inject< + MatDialogRef + >(MatDialogRef); private tags: Tag[]; @@ -208,7 +212,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { takeUntilDestroyed(this.destroyRef) ) .subscribe(() => { - this.dialogRef.close(); + this.dialogRef.close({ isNavigating: true }); }); addIcons({ diff --git a/apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts index 527b13636..40c94ca60 100644 --- a/apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts @@ -15,3 +15,7 @@ export interface HoldingDetailDialogParams { locale: string; symbol: string; } + +export interface HoldingDetailDialogResult { + isNavigating?: boolean; +} diff --git a/apps/client/src/app/pages/accounts/accounts-page.component.ts b/apps/client/src/app/pages/accounts/accounts-page.component.ts index 7d5e2fff7..1cf0e44a7 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.component.ts +++ b/apps/client/src/app/pages/accounts/accounts-page.component.ts @@ -1,5 +1,8 @@ import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component'; -import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces'; +import { + AccountDetailDialogParams, + AccountDetailDialogResult +} from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { @@ -243,7 +246,8 @@ export class GfAccountsPageComponent implements OnInit { private openAccountDetailDialog(aAccountId: string) { const dialogRef = this.dialog.open< GfAccountDetailDialogComponent, - AccountDetailDialogParams + AccountDetailDialogParams, + AccountDetailDialogResult >(GfAccountDetailDialogComponent, { autoFocus: false, data: { @@ -262,7 +266,11 @@ export class GfAccountsPageComponent implements OnInit { dialogRef .afterClosed() .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { + .subscribe((result) => { + if (result?.isNavigating) { + return; + } + this.fetchAccounts(); this.router.navigate(['.'], { relativeTo: this.route }); 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 19e51eb41..e0e3fc950 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 @@ -8,27 +8,29 @@ import { ChangeDetectionStrategy, Component, DestroyRef, + OnDestroy, OnInit, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatDialog } from '@angular/material/dialog'; +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 { finalize, map, switchMap } from 'rxjs/operators'; +import { map, switchMap } 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'; - -type ActivityDialogMode = 'clone' | 'create' | 'update'; +import { ActivityDialogMode } from './types/activity-dialog-mode.type'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'gf-activity-dialog-host', template: '' }) -export class GfActivityDialogHostComponent implements OnInit { +export class GfActivityDialogHostComponent implements OnDestroy, OnInit { + private dialogRef: MatDialogRef; + private readonly dataService = inject(DataService); private readonly destroyRef = inject(DestroyRef); private readonly deviceDetectorService = inject(DeviceDetectorService); @@ -49,7 +51,11 @@ export class GfActivityDialogHostComponent implements OnInit { .get() .pipe( switchMap((user) => { - return activity$.pipe(map((activity) => ({ activity, user }))); + return activity$.pipe( + map((activity) => { + return { activity, user }; + }) + ); }), takeUntilDestroyed(this.destroyRef) ) @@ -58,51 +64,80 @@ export class GfActivityDialogHostComponent implements OnInit { this.navigateBack(); }, next: ({ activity, user }) => { - this.openDialog(mode, user, activity); + if (mode === 'update') { + if (!activity) { + this.navigateBack(); + + return; + } + + this.openDialog({ activity, user, isUpdate: true }); + + return; + } + + if (mode === 'clone' && !activity) { + 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 + }); } }); } + public ngOnDestroy() { + // The dialog lives in an overlay outside of this component, so it needs to + // be closed explicitly when leaving the route (for example via the browser + // navigation) + this.dialogRef?.close(); + } + private navigateBack() { void this.router.navigate( internalRoutes.portfolio.subRoutes.activities.routerLink ); } - private openDialog( - aMode: ActivityDialogMode, - aUser: User, - aActivity?: Activity - ) { + private openDialog({ + activity, + isUpdate, + user + }: { + activity: CreateOrUpdateActivityDialogParams['activity']; + isUpdate: boolean; + user: User; + }) { const deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - const isUpdate = aMode === 'update'; - const dialogRef = this.dialog.open< + this.dialogRef = this.dialog.open< GfCreateOrUpdateActivityDialogComponent, CreateOrUpdateActivityDialogParams >(GfCreateOrUpdateActivityDialogComponent, { data: { - accounts: aUser?.accounts, - activity: - isUpdate && aActivity - ? aActivity - : { - ...aActivity, - accountId: aActivity?.accountId, - assetProfile: aActivity?.assetProfile ?? null, - date: new Date(), - fee: 0, - id: null, - type: aActivity?.type ?? 'BUY', - unitPrice: null - }, - user: aUser + activity, + user, + accounts: user?.accounts }, height: deviceType === 'mobile' ? '98vh' : '80vh', width: deviceType === 'mobile' ? '100vw' : '50rem' }); - dialogRef + this.dialogRef .afterClosed() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((result: CreateOrderDto | UpdateOrderDto | null) => { @@ -116,17 +151,19 @@ export class GfActivityDialogHostComponent implements OnInit { ? this.dataService.putActivity(result as UpdateOrderDto) : this.dataService.postActivity(result as CreateOrderDto); - request$ - .pipe( - finalize(() => { - this.navigateBack(); - }) - ) - .subscribe({ - next: () => { - this.userService.get(true).subscribe(); - } - }); + request$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + error: () => { + this.navigateBack(); + }, + next: () => { + // Deliberately not bound to the destroy reference: navigating back + // destroys this component and the refreshed user is what makes the + // activities page reload its data + this.userService.get(true).subscribe(); + + this.navigateBack(); + } + }); }); } } diff --git a/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/types/activity-dialog-mode.type.ts b/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/types/activity-dialog-mode.type.ts new file mode 100644 index 000000000..03d6305a5 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/types/activity-dialog-mode.type.ts @@ -0,0 +1 @@ +export type ActivityDialogMode = 'clone' | 'create' | 'update'; diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index f1dfed942..52f051e77 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -1,5 +1,8 @@ import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component'; -import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces'; +import { + AccountDetailDialogParams, + AccountDetailDialogResult +} from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config'; @@ -604,7 +607,8 @@ export class GfAllocationsPageComponent implements OnInit { private openAccountDetailDialog(aAccountId: string) { const dialogRef = this.dialog.open< GfAccountDetailDialogComponent, - AccountDetailDialogParams + AccountDetailDialogParams, + AccountDetailDialogResult >(GfAccountDetailDialogComponent, { autoFocus: false, data: { @@ -623,7 +627,11 @@ export class GfAllocationsPageComponent implements OnInit { dialogRef .afterClosed() .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { + .subscribe((result) => { + if (result?.isNavigating) { + return; + } + void this.router.navigate(['.'], { relativeTo: this.route }); }); } diff --git a/libs/common/src/lib/routes/interfaces/internal-route.interface.ts b/libs/common/src/lib/routes/interfaces/internal-route.interface.ts index 9ff6b2ea4..14538bb8e 100644 --- a/libs/common/src/lib/routes/interfaces/internal-route.interface.ts +++ b/libs/common/src/lib/routes/interfaces/internal-route.interface.ts @@ -3,7 +3,7 @@ import { User } from '@ghostfolio/common/interfaces'; export interface InternalRoute { excludeFromAssistant?: boolean | ((aUser: User) => boolean); path?: string; - routerLink?: string[]; + routerLink: string[] | ((...aParams: string[]) => string[]); subRoutes?: Record; title?: string; } diff --git a/libs/common/src/lib/routes/routes.ts b/libs/common/src/lib/routes/routes.ts index ea6f216ea..86cb2480b 100644 --- a/libs/common/src/lib/routes/routes.ts +++ b/libs/common/src/lib/routes/routes.ts @@ -128,6 +128,9 @@ export const internalRoutes = { subRoutes: { clone: { path: 'clone', + routerLink: (aActivityId: string) => { + return ['/portfolio', 'activities', aActivityId, 'clone']; + }, title: $localize`Clone Activity` }, create: { @@ -137,6 +140,9 @@ export const internalRoutes = { }, update: { path: 'update', + routerLink: (aActivityId: string) => { + return ['/portfolio', 'activities', aActivityId, 'update']; + }, title: $localize`Update Activity` } }, diff --git a/libs/ui/src/lib/activities-table/activities-table.component.html b/libs/ui/src/lib/activities-table/activities-table.component.html index 7104867f1..2266e758b 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.html +++ b/libs/ui/src/lib/activities-table/activities-table.component.html @@ -478,12 +478,7 @@ } @@ -492,12 +487,7 @@ diff --git a/libs/ui/src/lib/activities-table/activities-table.component.ts b/libs/ui/src/lib/activities-table/activities-table.component.ts index 6b08f3cc4..399d5d7e1 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.ts +++ b/libs/ui/src/lib/activities-table/activities-table.component.ts @@ -151,8 +151,24 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit { public readonly showCheckbox = input(false); public readonly showNameColumn = input(true); - protected readonly activityDialogRoutes = - internalRoutes.portfolio.subRoutes.activities.subRoutes; + protected readonly activityDialogRouterLinks = computed(() => { + const { clone, update } = + internalRoutes.portfolio.subRoutes.activities.subRoutes; + + const routerLinks = new Map< + string, + { clone: string[]; update: string[] } + >(); + + for (const { id } of this.dataSource()?.data ?? []) { + routerLinks.set(id, { + clone: clone.routerLink(id), + update: update.routerLink(id) + }); + } + + return routerLinks; + }); protected readonly displayedColumns = computed(() => { let columns = [ @@ -268,14 +284,6 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit { ); } - public getActivityDialogRouterLink(aActivity: Activity, aPath: string) { - return [ - ...internalRoutes.portfolio.subRoutes.activities.routerLink, - aActivity.id, - aPath - ]; - } - public isExcludedFromAnalysis(activity: Activity) { return ( (activity.account && isAccountExcluded(activity.account)) ?? diff --git a/libs/ui/src/lib/assistant/assistant.component.ts b/libs/ui/src/lib/assistant/assistant.component.ts index 90b2856e7..df4c1d95b 100644 --- a/libs/ui/src/lib/assistant/assistant.component.ts +++ b/libs/ui/src/lib/assistant/assistant.component.ts @@ -708,7 +708,9 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { return acc; }, []) .filter((route): route is InternalRoute & { routerLink: string[] } => { - return route.routerLink !== undefined; + // Routes with a parameterized router link cannot be linked to without + // their parameters and are therefore not offered as quick links + return Array.isArray(route.routerLink); }); const fuse = new Fuse(allRoutes, {