Browse Source

Merge branch 'main' into task/improve-language-localization-in-tag-management-of-admin-control-panel

pull/7349/head
Thomas Kaul 4 days ago
committed by GitHub
parent
commit
552e22774d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 7
      apps/client/src/app/app.component.ts
  3. 47
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  4. 2
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  5. 4
      apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts
  6. 49
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  7. 2
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  8. 4
      apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts
  9. 14
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  10. 141
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  11. 11
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  12. 29
      apps/client/src/app/pages/portfolio/activities/activities-page.routes.ts
  13. 169
      apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts
  14. 1
      apps/client/src/app/pages/portfolio/activities/activity-dialog-host/types/activity-dialog-mode.type.ts
  15. 7
      apps/client/src/app/pages/portfolio/activities/interfaces/interfaces.ts
  16. 14
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  17. 2
      libs/common/src/lib/routes/interfaces/internal-route.interface.ts
  18. 21
      libs/common/src/lib/routes/routes.ts
  19. 14
      libs/ui/src/lib/activities-table/activities-table.component.html
  20. 33
      libs/ui/src/lib/activities-table/activities-table.component.stories.ts
  21. 35
      libs/ui/src/lib/activities-table/activities-table.component.ts
  22. 28
      libs/ui/src/lib/assistant/assistant.component.ts
  23. 2
      libs/ui/src/lib/fab/fab.component.html
  24. 3
      libs/ui/src/lib/fab/fab.component.ts

1
CHANGELOG.md

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Migrated the clone, create and edit activity dialogs to dedicated routes
- Improved the language localization in the tag management of the admin control panel
### Fixed

7
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,

47
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts

@ -15,7 +15,6 @@ import {
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
@ -41,7 +40,7 @@ import { PageEvent } from '@angular/material/paginator';
import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { Router } from '@angular/router';
import { NavigationStart, Router } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { Big } from 'big.js';
import { format, parseISO } from 'date-fns';
@ -53,9 +52,12 @@ import {
} from 'ionicons/icons';
import { isNumber } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { forkJoin } from 'rxjs';
import { filter, forkJoin } from 'rxjs';
import { AccountDetailDialogParams } from './interfaces/interfaces';
import {
AccountDetailDialogParams,
AccountDetailDialogResult
} from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -113,11 +115,24 @@ export class GfAccountDetailDialogComponent implements OnInit {
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly dialogRef =
inject<MatDialogRef<GfAccountDetailDialogComponent>>(MatDialogRef);
inject<
MatDialogRef<GfAccountDetailDialogComponent, AccountDetailDialogResult>
>(MatDialogRef);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
this.router.events
.pipe(
filter((event) => {
return event instanceof NavigationStart;
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
this.dialogRef.close({ isNavigating: true });
});
this.userService.stateChanged
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
@ -155,17 +170,6 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.fetchActivities();
}
protected onCloneActivity(aActivity: Activity) {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink,
{
queryParams: { activityId: aActivity.id, createDialog: true }
}
);
this.dialogRef.close();
}
protected onClose() {
this.dialogRef.close();
}
@ -208,17 +212,6 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.fetchActivities();
}
protected onUpdateActivity(aActivity: Activity) {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink,
{
queryParams: { activityId: aActivity.id, editDialog: true }
}
);
this.dialogRef.close();
}
protected showValuesInPercentage() {
return (
this.data.hasImpersonationId || this.user?.settings?.isRestrictedView

2
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html

@ -132,8 +132,6 @@
[sortColumn]="sortColumn"
[sortDirection]="sortDirection"
[totalItems]="totalItems"
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()"
(pageChanged)="onChangePage($event)"
(sortChanged)="onSortChanged($event)"

4
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;
}

49
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -63,7 +63,7 @@ import { PageEvent } from '@angular/material/paginator';
import { SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { Router, RouterModule } from '@angular/router';
import { NavigationStart, Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { Account, MarketData, Tag } from '@prisma/client';
import { isUUID } from 'class-validator';
@ -80,9 +80,12 @@ import {
} from 'ionicons/icons';
import { isNumber, round, uniqBy } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { switchMap } from 'rxjs/operators';
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<HoldingDetailDialogParams>(MAT_DIALOG_DATA);
protected readonly dialogRef = inject(
MatDialogRef<GfHoldingDetailDialogComponent>
);
protected readonly dialogRef =
inject<
MatDialogRef<GfHoldingDetailDialogComponent, HoldingDetailDialogResult>
>(MatDialogRef);
private tags: Tag[];
@ -200,6 +204,17 @@ export class GfHoldingDetailDialogComponent implements OnInit {
private readonly userService = inject(UserService);
public constructor() {
this.router.events
.pipe(
filter((event) => {
return event instanceof NavigationStart;
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
this.dialogRef.close({ isNavigating: true });
});
addIcons({
arrowDownCircleOutline,
createOutline,
@ -589,17 +604,6 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.fetchActivities();
}
protected onCloneActivity(aActivity: Activity) {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink,
{
queryParams: { activityId: aActivity.id, createDialog: true }
}
);
this.dialogRef.close();
}
protected onClose() {
this.dialogRef.close();
}
@ -661,17 +665,6 @@ export class GfHoldingDetailDialogComponent implements OnInit {
}
}
protected onUpdateActivity(aActivity: Activity) {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink,
{
queryParams: { activityId: aActivity.id, editDialog: true }
}
);
this.dialogRef.close();
}
private fetchActivities(filters: Filter[] = this.getActivityFilters()) {
this.dataService
.fetchActivities({

2
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -374,8 +374,6 @@
[sortDirection]="sortDirection"
[sortDisabled]="true"
[totalItems]="activitiesCount"
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()"
(pageChanged)="onChangePage($event)"
/>

4
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;
}

14
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 });

141
apps/client/src/app/pages/portfolio/activities/activities-page.component.ts

@ -2,7 +2,6 @@ import { IcsService } from '@ghostfolio/client/services/ics/ics.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { downloadAsFile } from '@ghostfolio/common/helper';
import {
Activity,
@ -10,6 +9,7 @@ import {
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { DateRange } from '@ghostfolio/common/types';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfFabComponent } from '@ghostfolio/ui/fab';
@ -29,17 +29,12 @@ import { PageEvent } from '@angular/material/paginator';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Router, RouterModule } from '@angular/router';
import { format, parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector';
import { of } from 'rxjs';
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';
import { GfImportActivitiesDialogComponent } from './import-activities-dialog/import-activities-dialog.component';
import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces';
import { ActivitiesPageParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -59,6 +54,7 @@ export class GfActivitiesPageComponent implements OnInit {
protected hasImpersonationId: boolean;
protected hasPermissionToCreateActivity: boolean;
protected hasPermissionToDeleteActivity: boolean;
protected readonly internalRoutes = internalRoutes;
protected pageIndex = 0;
protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected sortColumn = 'date';
@ -77,37 +73,9 @@ export class GfActivitiesPageComponent implements OnInit {
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
this.route.queryParams
.pipe(
takeUntilDestroyed(this.destroyRef),
switchMap((params: ActivitiesPageParams) => {
if (params.activityId && (params.createDialog || params.editDialog)) {
return this.dataService
.fetchActivity(params.activityId)
.pipe(map((activity) => ({ activity, params })));
}
return of({ params, activity: undefined });
})
)
.subscribe(({ activity, params }) => {
if (params.createDialog) {
this.openCreateActivityDialog(activity);
} else if (params.editDialog) {
if (activity) {
this.openUpdateActivityDialog(activity);
} else {
this.router.navigate(['.'], { relativeTo: this.route });
}
}
});
}
public ngOnInit() {
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
@ -149,10 +117,6 @@ export class GfActivitiesPageComponent implements OnInit {
});
}
protected onCloneActivity(aActivity: Activity) {
this.openCreateActivityDialog(aActivity);
}
protected onDeleteActivities() {
this.dataService
.deleteActivities({
@ -308,12 +272,6 @@ export class GfActivitiesPageComponent implements OnInit {
this.fetchActivities();
}
protected onUpdateActivity(aActivity: Activity) {
this.router.navigate([], {
queryParams: { activityId: aActivity.id, editDialog: true }
});
}
private fetchActivities() {
// Reset dataSource and totalItems to show loading state
this.dataSource = undefined;
@ -343,48 +301,16 @@ export class GfActivitiesPageComponent implements OnInit {
this.hasPermissionToCreateActivity &&
this.user?.activitiesCount === 0
) {
this.router.navigate([], { queryParams: { createDialog: true } });
void this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.subRoutes.create
.routerLink
);
}
this.changeDetectorRef.markForCheck();
});
}
private openUpdateActivityDialog(aActivity: Activity) {
const dialogRef = this.dialog.open<
GfCreateOrUpdateActivityDialogComponent,
CreateOrUpdateActivityDialogParams
>(GfCreateOrUpdateActivityDialogComponent, {
data: {
activity: aActivity,
accounts: this.user?.accounts,
user: this.user
},
height: this.deviceType === 'mobile' ? '98vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((activity: UpdateOrderDto) => {
if (activity) {
this.dataService
.putActivity(activity)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.fetchActivities();
this.changeDetectorRef.markForCheck();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private isCalendarYear(dateRange?: DateRange) {
if (!dateRange) {
return false;
@ -393,59 +319,6 @@ export class GfActivitiesPageComponent implements OnInit {
return /^\d{4}$/.test(dateRange);
}
private openCreateActivityDialog(aActivity?: Activity) {
this.userService
.get()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.updateUser(user);
const dialogRef = this.dialog.open<
GfCreateOrUpdateActivityDialogComponent,
CreateOrUpdateActivityDialogParams
>(GfCreateOrUpdateActivityDialogComponent, {
data: {
accounts: this.user?.accounts,
activity: {
...aActivity,
accountId: aActivity?.accountId,
assetProfile: aActivity?.assetProfile ?? null,
date: new Date(),
id: null,
fee: 0,
type: aActivity?.type ?? 'BUY',
unitPrice: null
},
user: this.user
} satisfies CreateOrUpdateActivityDialogParams,
height: this.deviceType === 'mobile' ? '98vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((transaction: CreateOrderDto | null) => {
if (transaction) {
this.dataService.postActivity(transaction).subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
this.fetchActivities();
this.changeDetectorRef.markForCheck();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
private updateUser(aUser: User) {
this.user = aUser;

11
apps/client/src/app/pages/portfolio/activities/activities-page.html

@ -25,8 +25,6 @@
(activitiesDeleted)="onDeleteActivities()"
(activityClicked)="onClickActivity($event)"
(activityDeleted)="onDeleteActivity($event)"
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()"
(exportDrafts)="onExportDrafts($event)"
(import)="onImport()"
@ -43,6 +41,13 @@
hasPermissionToCreateActivity &&
!user.settings.isRestrictedView
) {
<gf-fab [queryParams]="{ createDialog: true }" />
<gf-fab
[routerLink]="
internalRoutes.portfolio.subRoutes.activities.subRoutes.create
.routerLink
"
/>
}
</div>
<router-outlet />

29
apps/client/src/app/pages/portfolio/activities/activities-page.routes.ts

@ -4,10 +4,39 @@ import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { Routes } from '@angular/router';
import { GfActivitiesPageComponent } from './activities-page.component';
import { GfActivityDialogHostComponent } from './activity-dialog-host/activity-dialog-host.component';
const { clone, create, update } =
internalRoutes.portfolio.subRoutes.activities.subRoutes;
export const routes: Routes = [
{
canActivate: [AuthGuard],
children: [
{
component: GfActivityDialogHostComponent,
data: { mode: 'create' },
path: create.path,
title: create.title
},
{
children: [
{
component: GfActivityDialogHostComponent,
data: { mode: 'clone' },
path: clone.path,
title: clone.title
},
{
component: GfActivityDialogHostComponent,
data: { mode: 'update' },
path: update.path,
title: update.title
}
],
path: ':activityId'
}
],
component: GfActivitiesPageComponent,
path: '',
title: internalRoutes.portfolio.subRoutes.activities.title

169
apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts

@ -0,0 +1,169 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { Activity, User } from '@ghostfolio/common/interfaces';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectionStrategy,
Component,
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 } from 'rxjs';
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';
import { ActivityDialogMode } from './types/activity-dialog-mode.type';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-activity-dialog-host',
template: ''
})
export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
private dialogRef: MatDialogRef<GfCreateOrUpdateActivityDialogComponent>;
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 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<Activity | undefined> = activityId
? this.dataService.fetchActivity(activityId)
: of(undefined);
this.userService
.get()
.pipe(
switchMap((user) => {
return activity$.pipe(
map((activity) => {
return { activity, user };
})
);
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
error: () => {
this.navigateBack();
},
next: ({ activity, user }) => {
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({
activity,
isUpdate,
user
}: {
activity: CreateOrUpdateActivityDialogParams['activity'];
isUpdate: boolean;
user: User;
}) {
const deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
this.dialogRef = this.dialog.open<
GfCreateOrUpdateActivityDialogComponent,
CreateOrUpdateActivityDialogParams
>(GfCreateOrUpdateActivityDialogComponent, {
data: {
activity,
user,
accounts: user?.accounts
},
height: deviceType === 'mobile' ? '98vh' : '80vh',
width: deviceType === 'mobile' ? '100vw' : '50rem'
});
this.dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result: CreateOrderDto | UpdateOrderDto | null) => {
if (!result) {
this.navigateBack();
return;
}
const request$: Observable<unknown> = isUpdate
? this.dataService.putActivity(result as UpdateOrderDto)
: this.dataService.postActivity(result as CreateOrderDto);
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();
}
});
});
}
}

1
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';

7
apps/client/src/app/pages/portfolio/activities/interfaces/interfaces.ts

@ -1,7 +0,0 @@
import { Params } from '@angular/router';
export interface ActivitiesPageParams extends Params {
activityId?: string;
createDialog?: string;
editDialog?: string;
}

14
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 });
});
}

2
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<string, InternalRoute>;
title?: string;
}

21
libs/common/src/lib/routes/routes.ts

@ -125,6 +125,27 @@ export const internalRoutes = {
activities: {
path: 'activities',
routerLink: ['/portfolio', 'activities'],
subRoutes: {
clone: {
path: 'clone',
routerLink: (aActivityId: string) => {
return ['/portfolio', 'activities', aActivityId, 'clone'];
},
title: $localize`Clone Activity`
},
create: {
path: 'create',
routerLink: ['/portfolio', 'activities', 'create'],
title: $localize`Add Activity`
},
update: {
path: 'update',
routerLink: (aActivityId: string) => {
return ['/portfolio', 'activities', aActivityId, 'update'];
},
title: $localize`Update Activity`
}
},
title: $localize`Activities`
},
allocations: {

14
libs/ui/src/lib/activities-table/activities-table.component.html

@ -476,18 +476,24 @@
</span>
</button>
}
<button mat-menu-item (click)="onUpdateActivity(element)">
<a
mat-menu-item
[routerLink]="activityDialogRouterLinks().get(element.id)?.update"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" />
<span><ng-container i18n>Edit</ng-container>...</span>
</span>
</button>
<button mat-menu-item (click)="onCloneActivity(element)">
</a>
<a
mat-menu-item
[routerLink]="activityDialogRouterLinks().get(element.id)?.clone"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="copy-outline" />
<span><ng-container i18n>Clone</ng-container>...</span>
</span>
</button>
</a>
<button
mat-menu-item
[disabled]="!element.isDraft"

33
libs/ui/src/lib/activities-table/activities-table.component.stories.ts

@ -9,9 +9,9 @@ import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { provideRouter, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { moduleMetadata } from '@storybook/angular';
import { applicationConfig, moduleMetadata } from '@storybook/angular';
import type { Meta, StoryObj } from '@storybook/angular';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -366,6 +366,9 @@ export default {
title: 'Activities Table',
component: GfActivitiesTableComponent,
decorators: [
applicationConfig({
providers: [provideRouter([])]
}),
moduleMetadata({
imports: [
CommonModule,
@ -418,8 +421,8 @@ export const Loading: Story = {
export const Default: Story = {
args: {
baseCurrency: 'USD',
dataSource,
baseCurrency: 'USD',
deviceType: 'desktop',
hasActivities: true,
hasPermissionToCreateActivity: false,
@ -469,3 +472,27 @@ export const Pagination: Story = {
totalItems: 50
}
};
export const Actions: Story = {
args: {
dataSource,
baseCurrency: 'USD',
deviceType: 'desktop',
hasActivities: true,
hasPermissionToCreateActivity: false,
hasPermissionToDeleteActivity: true,
hasPermissionToExportActivities: false,
hasPermissionToOpenDetails: true,
locale: 'en-US',
pageIndex: 0,
pageSize: 10,
showAccountColumn: true,
showActions: true,
showCheckbox: false,
showNameColumn: true,
sortColumn: 'date',
sortDirection: 'desc',
sortDisabled: false,
totalItems: activities.length
}
};

35
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -9,7 +9,7 @@ import {
AssetProfileIdentifier
} from '@ghostfolio/common/interfaces';
import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { translate } from '@ghostfolio/ui/i18n';
import { NotificationService } from '@ghostfolio/ui/notifications';
@ -50,6 +50,7 @@ import {
} from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { Type as ActivityType } from '@prisma/client';
import { isUUID } from 'class-validator';
@ -95,7 +96,8 @@ import { GfValueComponent } from '../value/value.component';
MatTableModule,
MatTooltipModule,
NgxSkeletonLoaderModule,
ReactiveFormsModule
ReactiveFormsModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-activities-table',
@ -123,8 +125,6 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
@Output() activitiesDeleted = new EventEmitter<void>();
@Output() activityClicked = new EventEmitter<AssetProfileIdentifier>();
@Output() activityDeleted = new EventEmitter<string>();
@Output() activityToClone = new EventEmitter<OrderWithAccount>();
@Output() activityToUpdate = new EventEmitter<OrderWithAccount>();
@Output() export = new EventEmitter<void>();
@Output() exportDrafts = new EventEmitter<string[]>();
@Output() import = new EventEmitter<void>();
@ -151,6 +151,25 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
public readonly showCheckbox = input(false);
public readonly showNameColumn = input(true);
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 = [
'select',
@ -291,10 +310,6 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
}
}
public onCloneActivity(aActivity: OrderWithAccount) {
this.activityToClone.emit(aActivity);
}
public onDeleteActivities() {
this.notificationService.confirm({
confirmFn: () => {
@ -349,10 +364,6 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
});
}
public onUpdateActivity(aActivity: OrderWithAccount) {
this.activityToUpdate.emit(aActivity);
}
public sortByValue(
a: { key: ActivityType; value: string },
b: { key: ActivityType; value: string }

28
libs/ui/src/lib/assistant/assistant.component.ts

@ -688,21 +688,31 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
private searchQuickLinks(aSearchTerm: string): SearchResultItem[] {
const searchTerm = aSearchTerm.toLowerCase();
const allRoutes = Object.values<InternalRoute>(internalRoutes)
.filter(({ excludeFromAssistant }) => {
if (isFunction(excludeFromAssistant)) {
return excludeFromAssistant(this.user);
}
const isIncludedInAssistant = ({ excludeFromAssistant }: InternalRoute) => {
if (isFunction(excludeFromAssistant)) {
return excludeFromAssistant(this.user);
}
return !excludeFromAssistant;
})
return !excludeFromAssistant;
};
const allRoutes = Object.values<InternalRoute>(internalRoutes)
.filter(isIncludedInAssistant)
.reduce<InternalRoute[]>((acc, route) => {
acc.push(route);
if (route.subRoutes) {
acc.push(...Object.values(route.subRoutes));
acc.push(
...Object.values(route.subRoutes).filter(isIncludedInAssistant)
);
}
return acc;
}, []);
}, [])
.filter((route): route is InternalRoute & { routerLink: string[] } => {
// 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, {
keys: ['title'],

2
libs/ui/src/lib/fab/fab.component.html

@ -3,7 +3,7 @@
color="primary"
mat-fab
[queryParams]="queryParams()"
[routerLink]="[]"
[routerLink]="routerLink()"
>
<ion-icon name="add-outline" size="large" />
</a>

3
libs/ui/src/lib/fab/fab.component.ts

@ -13,7 +13,8 @@ import { addOutline } from 'ionicons/icons';
templateUrl: './fab.component.html'
})
export class GfFabComponent {
public readonly queryParams = input.required<Params>();
public readonly queryParams = input<Params>();
public readonly routerLink = input<string[]>([]);
public constructor() {
addIcons({ addOutline });

Loading…
Cancel
Save