diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts index 87389f957..1d685e30d 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts @@ -1,9 +1,5 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { - DEFAULT_COLOR_SCHEME, - DEFAULT_LOCALE, - DEFAULT_PAGE_SIZE -} from '@ghostfolio/common/config'; +import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { canDeleteAssetProfile } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, @@ -13,6 +9,7 @@ import { User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter'; import { GfFabComponent } from '@ghostfolio/ui/fab'; import { translate } from '@ghostfolio/ui/i18n'; @@ -27,7 +24,6 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - computed, DestroyRef, inject, OnInit, @@ -36,7 +32,6 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatDialog } from '@angular/material/dialog'; import { MatMenuModule } from '@angular/material/menu'; import { MatPaginator, @@ -51,7 +46,7 @@ import { SortDirection } from '@angular/material/sort'; import { MatTableDataSource, MatTableModule } from '@angular/material/table'; -import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { Router, RouterModule } from '@angular/router'; import { IonIcon } from '@ionic/angular/standalone'; import { AssetSubClass, DataSource, SymbolProfile } from '@prisma/client'; import { isUUID } from 'class-validator'; @@ -66,16 +61,11 @@ import { trashOutline } from 'ionicons/icons'; import ms from 'ms'; -import { DeviceDetectorService } from 'ngx-device-detector'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { Subject } from 'rxjs'; import { distinctUntilChanged } from 'rxjs/operators'; import { AdminMarketDataService } from './admin-market-data.service'; -import { GfAssetProfileDialogComponent } from './asset-profile-dialog/asset-profile-dialog.component'; -import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces'; -import { GfCreateAssetProfileDialogComponent } from './create-asset-profile-dialog/create-asset-profile-dialog.component'; -import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -153,6 +143,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { protected dataSource = new MatTableDataSource(); protected readonly displayedColumns: string[] = []; protected readonly filters$ = new Subject(); + protected readonly internalRoutes = internalRoutes; protected isLoading = true; protected readonly isUUID = isUUID; protected pageSize = DEFAULT_PAGE_SIZE; @@ -164,9 +155,6 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { private activeFilters: Filter[] = []; private benchmarks: Partial[]; - private readonly deviceType = computed( - () => this.deviceDetectorService.deviceInfo().deviceType - ); private readonly hasPermissionForSubscription: boolean; private readonly info: InfoItem; private readonly paginator = viewChild.required(MatPaginator); @@ -176,9 +164,6 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly dataService = inject(DataService); private readonly destroyRef = inject(DestroyRef); - private readonly deviceDetectorService = inject(DeviceDetectorService); - private readonly dialog = inject(MatDialog); - private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly snackBar = inject(MatSnackBar); private readonly userService = inject(UserService); @@ -213,21 +198,10 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { this.displayedColumns.push('comment'); this.displayedColumns.push('actions'); - this.route.queryParams + this.adminMarketDataService.refresh$ .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((params) => { - if ( - params['assetProfileDialog'] && - params['dataSource'] && - params['symbol'] - ) { - this.openAssetProfileDialog({ - dataSource: params['dataSource'], - symbol: params['symbol'] - }); - } else if (params['createAssetProfileDialog']) { - this.openCreateAssetProfileDialog(); - } + .subscribe(() => { + this.reloadData(); }); this.userService.stateChanged @@ -341,13 +315,12 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { dataSource, symbol }: AssetProfileIdentifier) { - this.router.navigate([], { - queryParams: { + void this.router.navigate( + internalRoutes.adminControl.subRoutes.marketData.subRoutes.update.routerLink( dataSource, - symbol, - assetProfileDialog: true - } - }); + symbol + ) + ); } private loadData( @@ -418,96 +391,6 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { ); } - private openAssetProfileDialog({ - dataSource, - symbol - }: AssetProfileIdentifier) { - this.userService - .get() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((user) => { - this.user = user; - - const dialogRef = this.dialog.open< - GfAssetProfileDialogComponent, - AssetProfileDialogParams, - AssetProfileIdentifier - >(GfAssetProfileDialogComponent, { - autoFocus: false, - data: { - dataSource, - symbol, - colorScheme: - this.user?.settings.colorScheme ?? DEFAULT_COLOR_SCHEME, - deviceType: this.deviceType(), - locale: this.user?.settings?.locale ?? DEFAULT_LOCALE - } satisfies AssetProfileDialogParams, - height: this.deviceType() === 'mobile' ? '98vh' : '80vh', - width: this.deviceType() === 'mobile' ? '100vw' : '50rem' - }); - - dialogRef - .afterClosed() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((newAssetProfileIdentifier) => { - this.reloadData(); - - if (newAssetProfileIdentifier) { - this.onOpenAssetProfileDialog(newAssetProfileIdentifier); - } else { - this.router.navigate(['.'], { relativeTo: this.route }); - } - }); - }); - } - - private openCreateAssetProfileDialog() { - this.userService - .get() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((user) => { - this.user = user; - - const dialogRef = this.dialog.open< - GfCreateAssetProfileDialogComponent, - CreateAssetProfileDialogParams - >(GfCreateAssetProfileDialogComponent, { - autoFocus: false, - data: { - deviceType: this.deviceType(), - locale: this.user?.settings?.locale ?? DEFAULT_LOCALE - } satisfies CreateAssetProfileDialogParams, - width: this.deviceType() === 'mobile' ? '100vw' : '50rem' - }); - - dialogRef - .afterClosed() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((result) => { - if (!result) { - this.router.navigate(['.'], { relativeTo: this.route }); - - return; - } - - const { addAssetProfile, dataSource, symbol } = result; - - if (addAssetProfile && dataSource && symbol) { - this.adminService - .addAssetProfile({ dataSource, symbol }) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.loadData(); - }); - } else { - this.loadData(); - } - - this.onOpenAssetProfileDialog({ dataSource, symbol }); - }); - }); - } - private reloadData({ pageIndex = this.paginator().pageIndex }: { pageIndex?: number } = {}) { diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.html b/apps/client/src/app/components/admin-market-data/admin-market-data.html index 729ffede3..9a0cacb5e 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.html +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.html @@ -303,12 +303,12 @@ @@ -381,5 +381,12 @@ - + + + diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts index 73ea1e86f..f86ba08a7 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts @@ -8,9 +8,15 @@ import { EMPTY, Subject, catchError, finalize, forkJoin } from 'rxjs'; @Service({ autoProvided: false }) export class AdminMarketDataService { + private readonly refreshSubject = new Subject(); + private readonly adminService = inject(AdminService); private readonly notificationService = inject(NotificationService); + public get refresh$() { + return this.refreshSubject.asObservable(); + } + public deleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) { const assetProfileDeleted = new Subject(); @@ -72,4 +78,8 @@ export class AdminMarketDataService { return assetProfilesDeleted.asObservable(); } + + public triggerRefresh() { + this.refreshSubject.next(); + } } diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog-host/asset-profile-dialog-host.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog-host/asset-profile-dialog-host.component.ts new file mode 100644 index 000000000..450cc3014 --- /dev/null +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog-host/asset-profile-dialog-host.component.ts @@ -0,0 +1,217 @@ +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { + DEFAULT_COLOR_SCHEME, + DEFAULT_LOCALE +} from '@ghostfolio/common/config'; +import { AssetProfileIdentifier, User } from '@ghostfolio/common/interfaces'; +import { internalRoutes } from '@ghostfolio/common/routes/routes'; +import { AdminService } from '@ghostfolio/ui/services'; + +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + inject, + OnDestroy, + OnInit +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { ActivatedRoute, Router } from '@angular/router'; +import { DataSource } from '@prisma/client'; +import { DeviceDetectorService } from 'ngx-device-detector'; +import { Subject } from 'rxjs'; +import { distinctUntilChanged, map, takeUntil, tap } from 'rxjs/operators'; + +import { AdminMarketDataService } from '../admin-market-data.service'; +import { GfAssetProfileDialogComponent } from '../asset-profile-dialog/asset-profile-dialog.component'; +import { AssetProfileDialogParams } from '../asset-profile-dialog/interfaces/interfaces'; +import { GfCreateAssetProfileDialogComponent } from '../create-asset-profile-dialog/create-asset-profile-dialog.component'; +import { CreateAssetProfileDialogParams } from '../create-asset-profile-dialog/interfaces/interfaces'; +import { AssetProfileDialogMode } from './types/asset-profile-dialog-mode.type'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'gf-asset-profile-dialog-host', + template: '' +}) +export class GfAssetProfileDialogHostComponent implements OnDestroy, OnInit { + private dialogRef: MatDialogRef< + GfAssetProfileDialogComponent | GfCreateAssetProfileDialogComponent + >; + + private readonly deviceType = computed(() => { + return this.deviceDetectorService.deviceInfo().deviceType; + }); + + private readonly dialogClosed = new Subject(); + + private readonly adminMarketDataService = inject(AdminMarketDataService); + private readonly adminService = inject(AdminService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly dialog = inject(MatDialog); + 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 AssetProfileDialogMode; + + // The router reuses this component when only the asset profile identifier + // changes, so the parameters are observed instead of read from the + // snapshot once + this.route.paramMap + .pipe( + map((paramMap) => { + return { + dataSource: paramMap.get('dataSource'), + symbol: paramMap.get('symbol') + }; + }), + distinctUntilChanged((previous, current) => { + return ( + previous.dataSource === current.dataSource && + previous.symbol === current.symbol + ); + }), + tap(() => { + this.closeDialog(); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(({ dataSource, symbol }) => { + this.userService + .get() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((user) => { + if (mode === 'create') { + this.openCreateAssetProfileDialog({ user }); + } else if (dataSource && symbol) { + this.openAssetProfileDialog({ + dataSource: dataSource as DataSource, + symbol, + user + }); + } else { + this.navigateBack(); + } + }); + }); + } + + 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 navigateBack() { + void this.router.navigate( + internalRoutes.adminControl.subRoutes.marketData.routerLink + ); + } + + private navigateToAssetProfileDialog({ + dataSource, + symbol + }: AssetProfileIdentifier) { + void this.router.navigate( + internalRoutes.adminControl.subRoutes.marketData.subRoutes.update.routerLink( + dataSource, + symbol + ) + ); + } + + private openAssetProfileDialog({ + dataSource, + symbol, + user + }: AssetProfileIdentifier & { user: User }) { + const dialogRef = this.dialog.open< + GfAssetProfileDialogComponent, + AssetProfileDialogParams, + AssetProfileIdentifier + >(GfAssetProfileDialogComponent, { + autoFocus: false, + data: { + dataSource, + symbol, + colorScheme: user?.settings.colorScheme ?? DEFAULT_COLOR_SCHEME, + deviceType: this.deviceType(), + locale: user?.settings?.locale ?? DEFAULT_LOCALE + } satisfies AssetProfileDialogParams, + 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((newAssetProfileIdentifier) => { + this.adminMarketDataService.triggerRefresh(); + + if (newAssetProfileIdentifier) { + this.navigateToAssetProfileDialog(newAssetProfileIdentifier); + } else { + this.navigateBack(); + } + }); + } + + private openCreateAssetProfileDialog({ user }: { user: User }) { + const dialogRef = this.dialog.open< + GfCreateAssetProfileDialogComponent, + CreateAssetProfileDialogParams, + AssetProfileIdentifier & { addAssetProfile: boolean } + >(GfCreateAssetProfileDialogComponent, { + autoFocus: false, + data: { + deviceType: this.deviceType(), + locale: user?.settings?.locale ?? DEFAULT_LOCALE + } satisfies CreateAssetProfileDialogParams, + 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 { addAssetProfile, dataSource, symbol } = result; + + if (addAssetProfile && dataSource && symbol) { + this.adminService + .addAssetProfile({ dataSource, symbol }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.navigateToAssetProfileDialog({ dataSource, symbol }); + }); + } else { + this.navigateToAssetProfileDialog({ dataSource, symbol }); + } + }); + } +} diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog-host/types/asset-profile-dialog-mode.type.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog-host/types/asset-profile-dialog-mode.type.ts new file mode 100644 index 000000000..13460a9ae --- /dev/null +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog-host/types/asset-profile-dialog-mode.type.ts @@ -0,0 +1 @@ +export type AssetProfileDialogMode = 'create' | 'update'; 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 b8f5ac569..d8385d70e 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 @@ -182,8 +182,9 @@ export class GfHoldingDetailDialogComponent implements OnInit { protected quantityPrecision = 2; protected reportDataGlitchMailHref: string; protected readonly round = round; - protected readonly routerLinkAdminControlMarketData = - internalRoutes.adminControl.subRoutes.marketData.routerLink; + protected readonly routerLinkAdminControlMarketDataUpdate = + internalRoutes.adminControl.subRoutes.marketData.subRoutes.update + .routerLink; protected sectors: { [name: string]: { name: string; value: number }; }; diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html index 51a0783fc..5b0f1682b 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -484,12 +484,12 @@ @if (data.hasPermissionToAccessAdminControl) { Manage Asset Profile { + return ['/admin', 'market-data', aDataSource, aSymbol, 'update']; + }, + title: $localize`Asset Profile` + } + }, title: $localize`Market Data` }, settings: { diff --git a/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts index a45e6224c..aabda169f 100644 --- a/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts +++ b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts @@ -55,14 +55,13 @@ export class GfAssistantListItemComponent this.queryParams = {}; this.routerLink = this.item.routerLink; } else if (this.item?.mode === SearchMode.ASSET_PROFILE) { - this.queryParams = { - assetProfileDialog: true, - dataSource: this.item.dataSource, - symbol: this.item.symbol - }; + this.queryParams = {}; this.routerLink = - internalRoutes.adminControl.subRoutes.marketData.routerLink ?? []; + internalRoutes.adminControl.subRoutes.marketData.subRoutes.update.routerLink( + this.item.dataSource, + this.item.symbol + ); } else if (this.item?.mode === SearchMode.HOLDING) { this.queryParams = { dataSource: this.item.dataSource,