diff --git a/CHANGELOG.md b/CHANGELOG.md index d2615bc00..663fdd1fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the check for duplicates in the preview step of the import dividends dialog (regardless of the account) - Extended the activities import to reuse an existing account of the user by name and currency - Extended the activities import to resolve an ISIN to the symbol of the data provider +- Migrated the create, detail and edit account dialogs to dedicated routes - Improved the language localization for German (`de`) ### Fixed 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 a0350ee6b..9aa0f6964 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 @@ -176,6 +176,8 @@ export class GfAccountDetailDialogComponent implements OnInit { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.initialize(); + + this.refreshUser(); }); } @@ -195,6 +197,8 @@ export class GfAccountDetailDialogComponent implements OnInit { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.initialize(); + + this.refreshUser(); }); } @@ -413,4 +417,11 @@ export class GfAccountDetailDialogComponent implements OnInit { this.fetchChart(); this.fetchPortfolioHoldings(); } + + private refreshUser() { + this.userService + .get(true) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(); + } } diff --git a/apps/client/src/app/pages/accounts/account-dialog-host/account-dialog-host.component.ts b/apps/client/src/app/pages/accounts/account-dialog-host/account-dialog-host.component.ts new file mode 100644 index 000000000..3734889a3 --- /dev/null +++ b/apps/client/src/app/pages/accounts/account-dialog-host/account-dialog-host.component.ts @@ -0,0 +1,296 @@ +import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component'; +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 { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos'; +import { AccountResponse, 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'; + +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + OnDestroy, + OnInit, + inject +} from '@angular/core'; +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, Subject } from 'rxjs'; +import { + distinctUntilChanged, + map, + switchMap, + takeUntil, + tap +} from 'rxjs/operators'; + +import { GfCreateOrUpdateAccountDialogComponent } from '../create-or-update-account-dialog/create-or-update-account-dialog.component'; +import { CreateOrUpdateAccountDialogParams } from '../create-or-update-account-dialog/interfaces/interfaces'; +import { AccountDialogMode } from './types/account-dialog-mode.type'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'gf-account-dialog-host', + template: '' +}) +export class GfAccountDialogHostComponent implements OnDestroy, OnInit { + private dialogRef: MatDialogRef< + GfAccountDetailDialogComponent | GfCreateOrUpdateAccountDialogComponent + >; + + private readonly deviceType = computed(() => { + return this.deviceDetectorService.deviceInfo().deviceType; + }); + + 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 AccountDialogMode; + + // The router reuses this component when only the account id changes, so + // the parameters are observed instead of read from the snapshot once + this.route.paramMap + .pipe( + map((paramMap) => { + return paramMap.get('accountId'); + }), + distinctUntilChanged(), + tap(() => { + this.closeDialog(); + }), + switchMap((accountId) => { + const account$: Observable = + mode === 'update' && accountId + ? this.dataService.fetchAccount(accountId) + : of(undefined); + + return this.userService.get().pipe( + switchMap((user) => { + return account$.pipe( + map((account) => { + return { account, accountId, user }; + }) + ); + }) + ); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + error: () => { + this.navigateBack(); + }, + next: ({ account, accountId, user }) => { + if (mode === 'detail') { + this.openAccountDetailDialog({ accountId, user }); + + return; + } + + if (mode === 'update') { + if ( + !account || + !hasPermission(user?.permissions, permissions.updateAccount) || + this.isReadOnlyMode(user) + ) { + this.navigateBack(); + + return; + } + + const { balance, comment, currency, id, name, platformId, tags } = + account; + + this.openCreateOrUpdateAccountDialog({ + user, + account: { + balance, + comment, + currency, + id, + name, + platformId, + tags + }, + isUpdate: true + }); + + return; + } + + if ( + !hasPermission(user?.permissions, permissions.createAccount) || + this.isReadOnlyMode(user) + ) { + this.navigateBack(); + + return; + } + + this.openCreateOrUpdateAccountDialog({ + user, + account: { + balance: 0, + comment: null, + currency: user?.settings?.baseCurrency ?? null, + id: null, + name: null, + platformId: null, + tags: [] + }, + 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(); + + 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() { + void this.router.navigate(internalRoutes.accounts.routerLink); + } + + private openAccountDetailDialog({ + accountId, + user + }: { + accountId: string | null; + user: User; + }) { + if (!accountId) { + this.navigateBack(); + + return; + } + + const impersonationId = this.impersonationStorageService.getId(); + + const dialogRef = this.dialog.open< + GfAccountDetailDialogComponent, + AccountDetailDialogParams, + AccountDetailDialogResult + >(GfAccountDetailDialogComponent, { + autoFocus: false, + data: { + accountId, + impersonationId, + deviceType: this.deviceType(), + hasPermissionToCreateActivity: + !impersonationId && + hasPermission(user?.permissions, permissions.createActivity) && + !user?.settings?.isRestrictedView + }, + height: this.deviceType() === 'mobile' ? '98vh' : '80vh', + width: this.deviceType() === 'mobile' ? '100vw' : '50rem' + }); + + this.dialogRef = dialogRef; + + dialogRef + .afterClosed() + .pipe(takeUntil(this.dialogClosed), takeUntilDestroyed(this.destroyRef)) + .subscribe((result) => { + if (result?.isNavigating) { + return; + } + + this.navigateBack(); + }); + } + + private openCreateOrUpdateAccountDialog({ + account, + isUpdate, + user + }: { + account: CreateOrUpdateAccountDialogParams['account']; + isUpdate: boolean; + user: User; + }) { + const dialogRef = this.dialog.open< + GfCreateOrUpdateAccountDialogComponent, + CreateOrUpdateAccountDialogParams, + CreateAccountDto | UpdateAccountDto | null + >(GfCreateOrUpdateAccountDialogComponent, { + data: { + account, + user + }, + height: this.deviceType() === 'mobile' ? '98vh' : '80vh', + width: this.deviceType() === 'mobile' ? '100vw' : '50rem' + }); + + this.dialogRef = dialogRef; + + dialogRef + .afterClosed() + .pipe(takeUntil(this.dialogClosed), takeUntilDestroyed(this.destroyRef)) + .subscribe((result) => { + if (!result) { + this.navigateBack(); + + return; + } + + const request$: Observable = isUpdate + ? this.dataService.putAccount(result as UpdateAccountDto) + : this.dataService.postAccount(result as CreateAccountDto); + + 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 + // accounts page reload its data + this.userService.get(true).subscribe(); + + this.navigateBack(); + } + }); + }); + } +} diff --git a/apps/client/src/app/pages/accounts/account-dialog-host/types/account-dialog-mode.type.ts b/apps/client/src/app/pages/accounts/account-dialog-host/types/account-dialog-mode.type.ts new file mode 100644 index 000000000..9c2d44a2a --- /dev/null +++ b/apps/client/src/app/pages/accounts/account-dialog-host/types/account-dialog-mode.type.ts @@ -0,0 +1 @@ +export type AccountDialogMode = 'create' | 'detail' | 'update'; 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 9b10fb222..0f21ef55e 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.component.ts +++ b/apps/client/src/app/pages/accounts/accounts-page.component.ts @@ -1,17 +1,9 @@ -import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component'; -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 { - CreateAccountDto, - TransferBalanceDto, - UpdateAccountDto -} from '@ghostfolio/common/dtos'; +import { TransferBalanceDto } from '@ghostfolio/common/dtos'; import { User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { AccountWithValue } from '@ghostfolio/common/types'; import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table'; import { GfFabComponent } from '@ghostfolio/ui/fab'; @@ -30,13 +22,10 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { Tag } from '@prisma/client'; import { DeviceDetectorService } from 'ngx-device-detector'; import { EMPTY } from 'rxjs'; import { catchError } from 'rxjs/operators'; -import { GfCreateOrUpdateAccountDialogComponent } from './create-or-update-account-dialog/create-or-update-account-dialog.component'; -import { CreateOrUpdateAccountDialogParams } from './create-or-update-account-dialog/interfaces/interfaces'; import { TransferBalanceDialogParams } from './transfer-balance/interfaces/interfaces'; import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-balance-dialog.component'; @@ -54,10 +43,13 @@ export class GfAccountsPageComponent implements OnInit { protected hasPermissionToCreateAccount: boolean; protected hasPermissionToUpdateAccount: boolean; protected impersonationId: string | null; + protected readonly internalRoutes = internalRoutes; protected totalBalanceInBaseCurrency = 0; protected totalValueInBaseCurrency = 0; protected user: User; + private isInitialFetch = true; + private readonly deviceType = computed( () => this.deviceDetectorService.deviceInfo().deviceType ); @@ -79,26 +71,7 @@ export class GfAccountsPageComponent implements OnInit { this.route.queryParams .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((params) => { - if (params['accountId'] && params['accountDetailDialog']) { - this.openAccountDetailDialog(params['accountId']); - } else if ( - params['createDialog'] && - this.hasPermissionToCreateAccount - ) { - this.openCreateAccountDialog(); - } else if (params['editDialog']) { - if (this.accounts) { - const account = this.accounts.find(({ id }) => { - return id === params['accountId']; - }); - - if (account) { - this.openUpdateAccountDialog(account); - } - } else { - this.router.navigate(['.'], { relativeTo: this.route }); - } - } else if (params['transferBalanceDialog']) { + if (params['transferBalanceDialog']) { this.openTransferBalanceDialog(); } }); @@ -130,12 +103,12 @@ export class GfAccountsPageComponent implements OnInit { this.user.permissions, permissions.updateAccount ); + + this.fetchAccounts(); } this.changeDetectorRef.markForCheck(); }); - - this.fetchAccounts(); } protected onDeleteAccount(aId: string) { @@ -149,8 +122,6 @@ export class GfAccountsPageComponent implements OnInit { .get(true) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(); - - this.fetchAccounts(); }); } @@ -160,12 +131,6 @@ export class GfAccountsPageComponent implements OnInit { }); } - protected onUpdateAccount(aAccount: AccountWithValue) { - this.router.navigate([], { - queryParams: { accountId: aAccount.id, editDialog: true } - }); - } - private fetchAccounts() { this.dataService .fetchAccounts() @@ -182,149 +147,21 @@ export class GfAccountsPageComponent implements OnInit { this.totalBalanceInBaseCurrency = totalBalanceInBaseCurrency; this.totalValueInBaseCurrency = totalValueInBaseCurrency; - if (this.accounts?.length <= 0) { - this.router.navigate([], { queryParams: { createDialog: true } }); + if ( + this.accounts?.length <= 0 && + this.hasPermissionToCreateAccount && + this.isInitialFetch + ) { + void this.router.navigate( + internalRoutes.accounts.subRoutes.create.routerLink + ); } - this.changeDetectorRef.markForCheck(); - } - ); - } - - private openUpdateAccountDialog({ - balance, - comment, - currency, - id, - name, - platformId, - tags - }: AccountWithValue & { tags?: Tag[] }) { - const dialogRef = this.dialog.open< - GfCreateOrUpdateAccountDialogComponent, - CreateOrUpdateAccountDialogParams - >(GfCreateOrUpdateAccountDialogComponent, { - data: { - account: { - balance, - comment, - currency, - id, - name, - platformId, - tags - }, - user: this.user - }, - height: this.deviceType() === 'mobile' ? '98vh' : '80vh', - width: this.deviceType() === 'mobile' ? '100vw' : '50rem' - }); - - dialogRef - .afterClosed() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((account: UpdateAccountDto | null) => { - if (account) { - this.reset(); - - this.dataService - .putAccount(account) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.userService - .get(true) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(); - - this.fetchAccounts(); - }); + this.isInitialFetch = false; this.changeDetectorRef.markForCheck(); } - - this.router.navigate(['.'], { relativeTo: this.route }); - }); - } - - private openAccountDetailDialog(aAccountId: string) { - const dialogRef = this.dialog.open< - GfAccountDetailDialogComponent, - AccountDetailDialogParams, - AccountDetailDialogResult - >(GfAccountDetailDialogComponent, { - autoFocus: false, - data: { - accountId: aAccountId, - deviceType: this.deviceType(), - hasPermissionToCreateActivity: - !this.hasImpersonationId && - hasPermission(this.user?.permissions, permissions.createActivity) && - !this.user?.settings?.isRestrictedView, - impersonationId: this.impersonationId - }, - height: this.deviceType() === 'mobile' ? '98vh' : '80vh', - width: this.deviceType() === 'mobile' ? '100vw' : '50rem' - }); - - dialogRef - .afterClosed() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((result) => { - if (result?.isNavigating) { - return; - } - - this.fetchAccounts(); - - this.router.navigate(['.'], { relativeTo: this.route }); - }); - } - - private openCreateAccountDialog() { - const dialogRef = this.dialog.open< - GfCreateOrUpdateAccountDialogComponent, - CreateOrUpdateAccountDialogParams - >(GfCreateOrUpdateAccountDialogComponent, { - data: { - account: { - balance: 0, - comment: null, - currency: this.user?.settings?.baseCurrency ?? null, - id: null, - name: null, - platformId: null, - tags: [] - }, - user: this.user - } satisfies CreateOrUpdateAccountDialogParams, - height: this.deviceType() === 'mobile' ? '98vh' : '80vh', - width: this.deviceType() === 'mobile' ? '100vw' : '50rem' - }); - - dialogRef - .afterClosed() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((account: CreateAccountDto | null) => { - if (account) { - this.reset(); - - this.dataService - .postAccount(account) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.userService - .get(true) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(); - - this.fetchAccounts(); - }); - - this.changeDetectorRef.markForCheck(); - } - - this.router.navigate(['.'], { relativeTo: this.route }); - }); + ); } private openTransferBalanceDialog() { diff --git a/apps/client/src/app/pages/accounts/accounts-page.html b/apps/client/src/app/pages/accounts/accounts-page.html index 1bdedbbb9..8935f70f5 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.html +++ b/apps/client/src/app/pages/accounts/accounts-page.html @@ -15,7 +15,6 @@ [totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency" [totalValueInBaseCurrency]="totalValueInBaseCurrency" (accountDeleted)="onDeleteAccount($event)" - (accountToUpdate)="onUpdateAccount($event)" (transferBalance)="onTransferBalance()" /> @@ -26,6 +25,10 @@ hasPermissionToCreateAccount && !user.settings.isRestrictedView ) { - + } + + diff --git a/apps/client/src/app/pages/accounts/accounts-page.routes.ts b/apps/client/src/app/pages/accounts/accounts-page.routes.ts index e4edc39c1..fd58d660c 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.routes.ts +++ b/apps/client/src/app/pages/accounts/accounts-page.routes.ts @@ -3,11 +3,39 @@ import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { Routes } from '@angular/router'; +import { GfAccountDialogHostComponent } from './account-dialog-host/account-dialog-host.component'; import { GfAccountsPageComponent } from './accounts-page.component'; +const { create, detail, update } = internalRoutes.accounts.subRoutes; + export const routes: Routes = [ { canActivate: [AuthGuard], + children: [ + { + component: GfAccountDialogHostComponent, + data: { mode: 'create' }, + path: create.path, + title: create.title + }, + { + children: [ + { + component: GfAccountDialogHostComponent, + data: { mode: 'detail' }, + path: '', + title: detail.title + }, + { + component: GfAccountDialogHostComponent, + data: { mode: 'update' }, + path: update.path, + title: update.title + } + ], + path: ':accountId' + } + ], component: GfAccountsPageComponent, path: '', title: internalRoutes.accounts.title diff --git a/libs/common/src/lib/routes/routes.ts b/libs/common/src/lib/routes/routes.ts index 4d40cc5fb..d1cc1d430 100644 --- a/libs/common/src/lib/routes/routes.ts +++ b/libs/common/src/lib/routes/routes.ts @@ -66,6 +66,27 @@ export const internalRoutes = { accounts: { path: 'accounts', routerLink: ['/accounts'], + subRoutes: { + create: { + path: 'create', + routerLink: ['/accounts', 'create'], + title: $localize`Add Account` + }, + detail: { + path: undefined, // Default sub route + routerLink: (aAccountId: string) => { + return ['/accounts', aAccountId]; + }, + title: $localize`Account` + }, + update: { + path: 'update', + routerLink: (aAccountId: string) => { + return ['/accounts', aAccountId, 'update']; + }, + title: $localize`Update Account` + } + }, title: $localize`Accounts` }, api: { diff --git a/libs/ui/src/lib/accounts-table/accounts-table.component.html b/libs/ui/src/lib/accounts-table/accounts-table.component.html index 1d1cbb296..b6e0d846c 100644 --- a/libs/ui/src/lib/accounts-table/accounts-table.component.html +++ b/libs/ui/src/lib/accounts-table/accounts-table.component.html @@ -307,18 +307,26 @@ - - +