Browse Source

Merge branch 'main' into task/improve-stripe-checkout-session-verification

pull/6872/head
Thomas Kaul 4 days ago
committed by GitHub
parent
commit
47ca4f3715
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 8
      CHANGELOG.md
  2. 36
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  3. 3
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  4. 2
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  5. 81
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  6. 2
      apps/client/src/app/components/admin-platform/admin-platform.component.html
  7. 88
      apps/client/src/app/components/admin-platform/admin-platform.component.ts
  8. 6
      apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.component.ts
  9. 2
      apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html
  10. 2
      apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/interfaces/interfaces.ts
  11. 2
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  12. 80
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  13. 4
      apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts
  14. 2
      apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.html
  15. 2
      apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/interfaces/interfaces.ts
  16. 103
      apps/client/src/app/components/admin-users/admin-users.component.ts
  17. 12
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html
  18. 81
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  19. 121
      apps/client/src/app/components/header/header.component.html
  20. 211
      apps/client/src/app/components/header/header.component.ts
  21. 2
      apps/client/src/app/components/home-summary/home-summary.html
  22. 1
      apps/client/src/app/components/home-watchlist/home-watchlist.html
  23. 4
      apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts
  24. 10
      apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts
  25. 5
      apps/client/src/app/components/user-detail-dialog/interfaces/interfaces.ts
  26. 10
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts
  27. 4
      apps/client/src/app/pages/about/oss-friends/oss-friends-page.html
  28. 2
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
  29. 5
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts
  30. 2
      apps/client/src/app/pages/resources/glossary/resources-glossary.component.html
  31. 2
      apps/client/src/app/pages/resources/guides/resources-guides.component.html
  32. 2
      apps/client/src/app/pages/resources/markets/resources-markets.component.html
  33. 2
      apps/client/src/app/pages/resources/overview/resources-overview.component.html
  34. 4
      apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.html
  35. 500
      apps/client/src/locales/messages.ca.xlf
  36. 500
      apps/client/src/locales/messages.de.xlf
  37. 500
      apps/client/src/locales/messages.es.xlf
  38. 500
      apps/client/src/locales/messages.fr.xlf
  39. 500
      apps/client/src/locales/messages.it.xlf
  40. 500
      apps/client/src/locales/messages.ko.xlf
  41. 500
      apps/client/src/locales/messages.nl.xlf
  42. 500
      apps/client/src/locales/messages.pl.xlf
  43. 500
      apps/client/src/locales/messages.pt.xlf
  44. 500
      apps/client/src/locales/messages.tr.xlf
  45. 500
      apps/client/src/locales/messages.uk.xlf
  46. 495
      apps/client/src/locales/messages.xlf
  47. 500
      apps/client/src/locales/messages.zh.xlf
  48. 1
      apps/client/src/styles.scss
  49. 2
      libs/common/src/lib/dtos/create-asset-profile.dto.ts
  50. 2
      libs/common/src/lib/dtos/create-platform.dto.ts
  51. 2
      libs/common/src/lib/dtos/update-asset-profile.dto.ts
  52. 2
      libs/common/src/lib/dtos/update-platform.dto.ts
  53. 2
      libs/common/src/lib/dtos/update-property.dto.ts
  54. 10
      libs/common/src/lib/dtos/update-user-setting.dto.ts
  55. 2
      libs/common/src/lib/helper.ts
  56. 20
      libs/ui/src/lib/assistant/assistant.component.ts
  57. 33
      libs/ui/src/lib/benchmark/benchmark.component.html
  58. 4
      libs/ui/src/lib/benchmark/benchmark.component.ts
  59. 6
      libs/ui/src/lib/page-tabs/page-tabs.component.scss
  60. 857
      package-lock.json
  61. 22
      package.json

8
CHANGELOG.md

@ -9,15 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added the icon column to the benchmark component
- Added support for the `DIRECT_URL` environment variable to enable direct database connections - Added support for the `DIRECT_URL` environment variable to enable direct database connections
### Changed ### Changed
- Improved the pagination in the activities table of the account detail dialog
- Improved the pagination in the activities table of the holding detail dialog - Improved the pagination in the activities table of the holding detail dialog
- Randomized the placeholder in the assistant
- Enabled the _Bull Dashboard_ in the admin control panel without requiring an environment variable (experimental) - Enabled the _Bull Dashboard_ in the admin control panel without requiring an environment variable (experimental)
- Improved the verification of the _Stripe_ checkout session when creating a subscription - Improved the verification of the _Stripe_ checkout session when creating a subscription
- Relaxed the URL validation in the asset profile DTOs to accept both `HTTP` and `HTTPS` protocols
- Relaxed the URL validation in the platform DTOs to accept both `HTTP` and `HTTPS` protocols
- Extracted the page tabs to a reusable component - Extracted the page tabs to a reusable component
- Improved the language localization for German (`de`)
- Improved the language localization for Spanish (`es`)
- Upgraded `bull-board` from version `7.0.0` to `7.1.5` - Upgraded `bull-board` from version `7.0.0` to `7.1.5`
- Upgraded `Nx` from version `22.7.1` to `22.7.2`
### Fixed ### Fixed

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

@ -1,6 +1,9 @@
import { GfInvestmentChartComponent } from '@ghostfolio/client/components/investment-chart/investment-chart.component'; import { GfInvestmentChartComponent } from '@ghostfolio/client/components/investment-chart/investment-chart.component';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config'; import {
DEFAULT_PAGE_SIZE,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
} from '@ghostfolio/common/config';
import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos'; import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import { import {
@ -33,6 +36,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { PageEvent } from '@angular/material/paginator';
import { Sort, SortDirection } from '@angular/material/sort'; import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
@ -93,6 +97,8 @@ export class GfAccountDetailDialogComponent implements OnInit {
protected isLoadingActivities: boolean; protected isLoadingActivities: boolean;
protected isLoadingChart: boolean; protected isLoadingChart: boolean;
protected name: string | null; protected name: string | null;
protected pageIndex = 0;
protected pageSize = DEFAULT_PAGE_SIZE;
protected platformName: string; protected platformName: string;
protected sortColumn = 'date'; protected sortColumn = 'date';
protected sortDirection: SortDirection = 'desc'; protected sortDirection: SortDirection = 'desc';
@ -133,6 +139,21 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.initialize(); this.initialize();
} }
protected onAddAccountBalance(accountBalance: CreateAccountBalanceDto) {
this.dataService
.postAccountBalance(accountBalance)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.initialize();
});
}
protected onChangePage(page: PageEvent) {
this.pageIndex = page.pageIndex;
this.fetchActivities();
}
protected onCloneActivity(aActivity: Activity) { protected onCloneActivity(aActivity: Activity) {
this.router.navigate( this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink, internalRoutes.portfolio.subRoutes.activities.routerLink,
@ -148,15 +169,6 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.dialogRef.close(); this.dialogRef.close();
} }
protected onAddAccountBalance(accountBalance: CreateAccountBalanceDto) {
this.dataService
.postAccountBalance(accountBalance)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.initialize();
});
}
protected onDeleteAccountBalance(aId: string) { protected onDeleteAccountBalance(aId: string) {
this.dataService this.dataService
.deleteAccountBalance(aId) .deleteAccountBalance(aId)
@ -287,8 +299,10 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.dataService this.dataService
.fetchActivities({ .fetchActivities({
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }], filters: [{ id: this.data.accountId, type: 'ACCOUNT' }],
skip: this.pageIndex * this.pageSize,
sortColumn: this.sortColumn, sortColumn: this.sortColumn,
sortDirection: this.sortDirection sortDirection: this.sortDirection,
take: this.pageSize
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ activities, count }) => { .subscribe(({ activities, count }) => {

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

@ -120,6 +120,8 @@
[hasPermissionToFilter]="false" [hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[pageIndex]="pageIndex"
[pageSize]="pageSize"
[showAccountColumn]="false" [showAccountColumn]="false"
[showActions]=" [showActions]="
!data.hasImpersonationId && !data.hasImpersonationId &&
@ -133,6 +135,7 @@
(activityToClone)="onCloneActivity($event)" (activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)" (activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()" (export)="onExport()"
(pageChanged)="onChangePage($event)"
(sortChanged)="onSortChanged($event)" (sortChanged)="onSortChanged($event)"
/> />
</mat-tab> </mat-tab>

2
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -1,7 +1,7 @@
<div class="d-flex flex-column h-100"> <div class="d-flex flex-column h-100">
<div class="d-flex mb-3"> <div class="d-flex mb-3">
<h1 class="flex-grow-1 m-0" mat-dialog-title> <h1 class="flex-grow-1 m-0" mat-dialog-title>
{{ assetProfile?.name ?? data.symbol }} <span>{{ assetProfile?.name ?? data.symbol }}</span>
</h1> </h1>
<button <button
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"

81
apps/client/src/app/components/admin-overview/admin-overview.component.ts

@ -30,6 +30,7 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef, DestroyRef,
inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -84,33 +85,33 @@ import ms, { StringValue } from 'ms';
templateUrl: './admin-overview.html' templateUrl: './admin-overview.html'
}) })
export class GfAdminOverviewComponent implements OnInit { export class GfAdminOverviewComponent implements OnInit {
public activitiesCount: number; protected activitiesCount: number;
public couponDuration: StringValue = '14 days'; protected couponDuration: StringValue = '14 days';
public couponsDataSource = new MatTableDataSource<Coupon>(); protected readonly couponsDataSource = new MatTableDataSource<Coupon>();
public couponsDisplayedColumns = ['code', 'duration', 'actions']; protected readonly couponsDisplayedColumns = ['code', 'duration', 'actions'];
public hasPermissionForSubscription: boolean; protected hasPermissionForSubscription: boolean;
public hasPermissionForSystemMessage: boolean; protected hasPermissionForSystemMessage: boolean;
public hasPermissionToSyncDemoUserAccount: boolean; protected hasPermissionToSyncDemoUserAccount: boolean;
public hasPermissionToToggleReadOnlyMode: boolean; protected hasPermissionToToggleReadOnlyMode: boolean;
public info: InfoItem; protected readonly info: InfoItem;
public isDataGatheringEnabled: boolean; protected isDataGatheringEnabled: boolean;
public permissions = permissions; protected readonly permissions = permissions;
public systemMessage: SystemMessage; protected systemMessage: SystemMessage;
public userCount: number; protected userCount: number;
public user: User; protected user: User;
public version: string; protected version: string;
public constructor( private readonly adminService = inject(AdminService);
private adminService: AdminService, private readonly cacheService = inject(CacheService);
private cacheService: CacheService, private readonly changeDetectorRef = inject(ChangeDetectorRef);
private changeDetectorRef: ChangeDetectorRef, private readonly clipboard = inject(Clipboard);
private clipboard: Clipboard, private readonly dataService = inject(DataService);
private dataService: DataService, private readonly destroyRef = inject(DestroyRef);
private destroyRef: DestroyRef, private readonly notificationService = inject(NotificationService);
private notificationService: NotificationService, private readonly snackBar = inject(MatSnackBar);
private snackBar: MatSnackBar, private readonly userService = inject(UserService);
private userService: UserService
) { public constructor() {
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();
this.userService.stateChanged this.userService.stateChanged
@ -150,7 +151,7 @@ export class GfAdminOverviewComponent implements OnInit {
}); });
} }
public get activitiesCountPerUser() { protected get activitiesCountPerUser() {
if (!this.activitiesCount || !this.userCount) { if (!this.activitiesCount || !this.userCount) {
return undefined; return undefined;
} }
@ -169,7 +170,7 @@ export class GfAdminOverviewComponent implements OnInit {
this.fetchAdminData(); this.fetchAdminData();
} }
public formatDistanceToNow(aDateString: string) { protected formatDistanceToNow(aDateString: string) {
if (aDateString) { if (aDateString) {
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), { const distanceString = formatDistanceToNowStrict(parseISO(aDateString), {
addSuffix: true addSuffix: true
@ -184,7 +185,7 @@ export class GfAdminOverviewComponent implements OnInit {
return ''; return '';
} }
public formatStringValue(aStringValue: StringValue) { protected formatStringValue(aStringValue: StringValue) {
return formatDistanceToNowStrict( return formatDistanceToNowStrict(
addMilliseconds(new Date(), ms(aStringValue)), addMilliseconds(new Date(), ms(aStringValue)),
{ {
@ -193,7 +194,7 @@ export class GfAdminOverviewComponent implements OnInit {
); );
} }
public onAddCoupon() { protected onAddCoupon() {
const newCoupon: Coupon = { const newCoupon: Coupon = {
code: `${ghostfolioPrefix}${this.generateCouponCode(14)}`, code: `${ghostfolioPrefix}${this.generateCouponCode(14)}`,
duration: this.couponDuration duration: this.couponDuration
@ -204,11 +205,11 @@ export class GfAdminOverviewComponent implements OnInit {
this.saveCoupons({ coupons, codeToCopy: newCoupon.code }); this.saveCoupons({ coupons, codeToCopy: newCoupon.code });
} }
public onChangeCouponDuration(aCouponDuration: StringValue) { protected onChangeCouponDuration(aCouponDuration: StringValue) {
this.couponDuration = aCouponDuration; this.couponDuration = aCouponDuration;
} }
public onDeleteCoupon(aCouponCode: string) { protected onDeleteCoupon(aCouponCode: string) {
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
const coupons = this.couponsDataSource.data.filter(({ code }) => { const coupons = this.couponsDataSource.data.filter(({ code }) => {
@ -222,7 +223,7 @@ export class GfAdminOverviewComponent implements OnInit {
}); });
} }
public onDeleteSystemMessage() { protected onDeleteSystemMessage() {
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
this.putAdminSetting({ this.putAdminSetting({
@ -235,14 +236,14 @@ export class GfAdminOverviewComponent implements OnInit {
}); });
} }
public onEnableDataGatheringChange(aEvent: MatSlideToggleChange) { protected onEnableDataGatheringChange(aEvent: MatSlideToggleChange) {
this.putAdminSetting({ this.putAdminSetting({
key: PROPERTY_IS_DATA_GATHERING_ENABLED, key: PROPERTY_IS_DATA_GATHERING_ENABLED,
value: aEvent.checked ? undefined : false value: aEvent.checked ? undefined : false
}); });
} }
public onFlushCache() { protected onFlushCache() {
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
this.cacheService this.cacheService
@ -259,21 +260,21 @@ export class GfAdminOverviewComponent implements OnInit {
}); });
} }
public onEnableUserSignupModeChange(aEvent: MatSlideToggleChange) { protected onEnableUserSignupModeChange(aEvent: MatSlideToggleChange) {
this.putAdminSetting({ this.putAdminSetting({
key: PROPERTY_IS_USER_SIGNUP_ENABLED, key: PROPERTY_IS_USER_SIGNUP_ENABLED,
value: aEvent.checked ? undefined : false value: aEvent.checked ? undefined : false
}); });
} }
public onReadOnlyModeChange(aEvent: MatSlideToggleChange) { protected onReadOnlyModeChange(aEvent: MatSlideToggleChange) {
this.putAdminSetting({ this.putAdminSetting({
key: PROPERTY_IS_READ_ONLY_MODE, key: PROPERTY_IS_READ_ONLY_MODE,
value: aEvent.checked ? true : undefined value: aEvent.checked ? true : undefined
}); });
} }
public onSetSystemMessage() { protected onSetSystemMessage() {
const systemMessage = prompt( const systemMessage = prompt(
$localize`Please set your system message:`, $localize`Please set your system message:`,
JSON.stringify( JSON.stringify(
@ -293,7 +294,7 @@ export class GfAdminOverviewComponent implements OnInit {
} }
} }
public onSyncDemoUserAccount() { protected onSyncDemoUserAccount() {
this.adminService this.adminService
.syncDemoUserAccount() .syncDemoUserAccount()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))

2
apps/client/src/app/components/admin-platform/admin-platform.component.html

@ -54,7 +54,7 @@
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[locale]="locale" [locale]="locale()"
[value]="element.accountCount" [value]="element.accountCount"
/> />
</td> </td>

88
apps/client/src/app/components/admin-platform/admin-platform.component.ts

@ -11,10 +11,12 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
computed,
DestroyRef, DestroyRef,
Input, inject,
input,
OnInit, OnInit,
ViewChild viewChild
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@ -54,27 +56,29 @@ import { CreateOrUpdatePlatformDialogParams } from './create-or-update-platform-
templateUrl: './admin-platform.component.html' templateUrl: './admin-platform.component.html'
}) })
export class GfAdminPlatformComponent implements OnInit { export class GfAdminPlatformComponent implements OnInit {
@Input() locale = getLocale(); public readonly locale = input(getLocale());
@ViewChild(MatSort) sort: MatSort; protected dataSource = new MatTableDataSource<Platform>();
protected readonly displayedColumns = ['name', 'url', 'accounts', 'actions'];
public dataSource = new MatTableDataSource<Platform>(); protected platforms: Platform[];
public deviceType: string;
public displayedColumns = ['name', 'url', 'accounts', 'actions']; private readonly deviceType = computed(
public platforms: Platform[]; () => this.deviceDetectorService.deviceInfo().deviceType
);
public constructor( private readonly sort = viewChild.required(MatSort);
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private readonly adminService = inject(AdminService);
private dataService: DataService, private readonly changeDetectorRef = inject(ChangeDetectorRef);
private destroyRef: DestroyRef, private readonly dataService = inject(DataService);
private deviceDetectorService: DeviceDetectorService, private readonly destroyRef = inject(DestroyRef);
private dialog: MatDialog, private readonly deviceDetectorService = inject(DeviceDetectorService);
private notificationService: NotificationService, private readonly dialog = inject(MatDialog);
private route: ActivatedRoute, private readonly notificationService = inject(NotificationService);
private router: Router, private readonly route = inject(ActivatedRoute);
private userService: UserService private readonly router = inject(Router);
) { private readonly userService = inject(UserService);
public constructor() {
this.route.queryParams this.route.queryParams
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => { .subscribe((params) => {
@ -86,7 +90,9 @@ export class GfAdminPlatformComponent implements OnInit {
return id === params['platformId']; return id === params['platformId'];
}); });
if (platform) {
this.openUpdatePlatformDialog(platform); this.openUpdatePlatformDialog(platform);
}
} else { } else {
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });
} }
@ -97,12 +103,10 @@ export class GfAdminPlatformComponent implements OnInit {
} }
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
this.fetchPlatforms(); this.fetchPlatforms();
} }
public onDeletePlatform(aId: string) { protected onDeletePlatform(aId: string) {
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
this.deletePlatform(aId); this.deletePlatform(aId);
@ -112,7 +116,7 @@ export class GfAdminPlatformComponent implements OnInit {
}); });
} }
public onUpdatePlatform({ id }: Platform) { protected onUpdatePlatform({ id }: Platform) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { editPlatformDialog: true, platformId: id } queryParams: { editPlatformDialog: true, platformId: id }
}); });
@ -142,7 +146,7 @@ export class GfAdminPlatformComponent implements OnInit {
this.platforms = platforms; this.platforms = platforms;
this.dataSource = new MatTableDataSource(platforms); this.dataSource = new MatTableDataSource(platforms);
this.dataSource.sort = this.sort; this.dataSource.sort = this.sort();
this.dataSource.sortingDataAccessor = get; this.dataSource.sortingDataAccessor = get;
this.dataService.updateInfo(); this.dataService.updateInfo();
@ -156,15 +160,9 @@ export class GfAdminPlatformComponent implements OnInit {
GfCreateOrUpdatePlatformDialogComponent, GfCreateOrUpdatePlatformDialogComponent,
CreateOrUpdatePlatformDialogParams CreateOrUpdatePlatformDialogParams
>(GfCreateOrUpdatePlatformDialogComponent, { >(GfCreateOrUpdatePlatformDialogComponent, {
data: { data: {} satisfies CreateOrUpdatePlatformDialogParams,
platform: { height: this.deviceType() === 'mobile' ? '98vh' : undefined,
id: null, width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
name: null,
url: null
}
},
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });
dialogRef dialogRef
@ -191,15 +189,7 @@ export class GfAdminPlatformComponent implements OnInit {
}); });
} }
private openUpdatePlatformDialog({ private openUpdatePlatformDialog({ id, name, url }: Platform) {
id,
name,
url
}: {
id: string;
name: string;
url: string;
}) {
const dialogRef = this.dialog.open< const dialogRef = this.dialog.open<
GfCreateOrUpdatePlatformDialogComponent, GfCreateOrUpdatePlatformDialogComponent,
CreateOrUpdatePlatformDialogParams CreateOrUpdatePlatformDialogParams
@ -210,9 +200,9 @@ export class GfAdminPlatformComponent implements OnInit {
name, name,
url url
} }
}, } satisfies CreateOrUpdatePlatformDialogParams,
height: this.deviceType === 'mobile' ? '98vh' : undefined, height: this.deviceType() === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
}); });
dialogRef dialogRef

6
apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.component.ts

@ -46,8 +46,8 @@ export class GfCreateOrUpdatePlatformDialogComponent {
private formBuilder: FormBuilder private formBuilder: FormBuilder
) { ) {
this.platformForm = this.formBuilder.group({ this.platformForm = this.formBuilder.group({
name: [this.data.platform.name, Validators.required], name: [this.data.platform?.name, Validators.required],
url: [this.data.platform.url ?? 'https://', Validators.required] url: [this.data.platform?.url ?? 'https://', Validators.required]
}); });
} }
@ -62,7 +62,7 @@ export class GfCreateOrUpdatePlatformDialogComponent {
url: this.platformForm.get('url')?.value url: this.platformForm.get('url')?.value
}; };
if (this.data.platform.id) { if (this.data.platform?.id) {
(platform as UpdatePlatformDto).id = this.data.platform.id; (platform as UpdatePlatformDto).id = this.data.platform.id;
await validateObjectForForm({ await validateObjectForForm({
classDto: UpdatePlatformDto, classDto: UpdatePlatformDto,

2
apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html

@ -4,7 +4,7 @@
(keyup.enter)="platformForm.valid && onSubmit()" (keyup.enter)="platformForm.valid && onSubmit()"
(ngSubmit)="onSubmit()" (ngSubmit)="onSubmit()"
> >
@if (data.platform.id) { @if (data.platform?.id) {
<h1 i18n mat-dialog-title>Update platform</h1> <h1 i18n mat-dialog-title>Update platform</h1>
} @else { } @else {
<h1 i18n mat-dialog-title>Add platform</h1> <h1 i18n mat-dialog-title>Add platform</h1>

2
apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/interfaces/interfaces.ts

@ -1,5 +1,5 @@
import { Platform } from '@prisma/client'; import { Platform } from '@prisma/client';
export interface CreateOrUpdatePlatformDialogParams { export interface CreateOrUpdatePlatformDialogParams {
platform: Platform; platform?: Platform;
} }

2
apps/client/src/app/components/admin-tag/admin-tag.component.html

@ -47,7 +47,7 @@
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[locale]="locale" [locale]="locale()"
[value]="element.activityCount" [value]="element.activityCount"
/> />
</td> </td>

80
apps/client/src/app/components/admin-tag/admin-tag.component.ts

@ -10,10 +10,12 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
computed,
DestroyRef, DestroyRef,
Input, inject,
input,
OnInit, OnInit,
ViewChild viewChild
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@ -52,26 +54,33 @@ import { CreateOrUpdateTagDialogParams } from './create-or-update-tag-dialog/int
templateUrl: './admin-tag.component.html' templateUrl: './admin-tag.component.html'
}) })
export class GfAdminTagComponent implements OnInit { export class GfAdminTagComponent implements OnInit {
@Input() locale = getLocale(); public readonly locale = input(getLocale());
@ViewChild(MatSort) sort: MatSort; protected dataSource = new MatTableDataSource<Tag>();
protected readonly displayedColumns = [
public dataSource = new MatTableDataSource<Tag>(); 'name',
public deviceType: string; 'userId',
public displayedColumns = ['name', 'userId', 'activities', 'actions']; 'activities',
public tags: Tag[]; 'actions'
];
public constructor( protected tags: Tag[];
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private readonly deviceType = computed(
private destroyRef: DestroyRef, () => this.deviceDetectorService.deviceInfo().deviceType
private deviceDetectorService: DeviceDetectorService, );
private dialog: MatDialog, private readonly sort = viewChild.required(MatSort);
private notificationService: NotificationService,
private route: ActivatedRoute, private readonly changeDetectorRef = inject(ChangeDetectorRef);
private router: Router, private readonly dataService = inject(DataService);
private userService: UserService private readonly destroyRef = inject(DestroyRef);
) { private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly notificationService = inject(NotificationService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
this.route.queryParams this.route.queryParams
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => { .subscribe((params) => {
@ -83,7 +92,9 @@ export class GfAdminTagComponent implements OnInit {
return id === params['tagId']; return id === params['tagId'];
}); });
if (tag) {
this.openUpdateTagDialog(tag); this.openUpdateTagDialog(tag);
}
} else { } else {
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });
} }
@ -94,12 +105,10 @@ export class GfAdminTagComponent implements OnInit {
} }
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
this.fetchTags(); this.fetchTags();
} }
public onDeleteTag(aId: string) { protected onDeleteTag(aId: string) {
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
this.deleteTag(aId); this.deleteTag(aId);
@ -109,7 +118,7 @@ export class GfAdminTagComponent implements OnInit {
}); });
} }
public onUpdateTag({ id }: Tag) { protected onUpdateTag({ id }: Tag) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { editTagDialog: true, tagId: id } queryParams: { editTagDialog: true, tagId: id }
}); });
@ -139,7 +148,7 @@ export class GfAdminTagComponent implements OnInit {
this.tags = tags; this.tags = tags;
this.dataSource = new MatTableDataSource(this.tags); this.dataSource = new MatTableDataSource(this.tags);
this.dataSource.sort = this.sort; this.dataSource.sort = this.sort();
this.dataSource.sortingDataAccessor = get; this.dataSource.sortingDataAccessor = get;
this.dataService.updateInfo(); this.dataService.updateInfo();
@ -153,14 +162,9 @@ export class GfAdminTagComponent implements OnInit {
GfCreateOrUpdateTagDialogComponent, GfCreateOrUpdateTagDialogComponent,
CreateOrUpdateTagDialogParams CreateOrUpdateTagDialogParams
>(GfCreateOrUpdateTagDialogComponent, { >(GfCreateOrUpdateTagDialogComponent, {
data: { data: {} satisfies CreateOrUpdateTagDialogParams,
tag: { height: this.deviceType() === 'mobile' ? '98vh' : undefined,
id: null, width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
name: null
}
},
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });
dialogRef dialogRef
@ -197,9 +201,9 @@ export class GfAdminTagComponent implements OnInit {
id, id,
name name
} }
}, } satisfies CreateOrUpdateTagDialogParams,
height: this.deviceType === 'mobile' ? '98vh' : undefined, height: this.deviceType() === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
}); });
dialogRef dialogRef

4
apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts

@ -43,7 +43,7 @@ export class GfCreateOrUpdateTagDialogComponent {
private formBuilder: FormBuilder private formBuilder: FormBuilder
) { ) {
this.tagForm = this.formBuilder.group({ this.tagForm = this.formBuilder.group({
name: [this.data.tag.name] name: [this.data.tag?.name]
}); });
} }
@ -57,7 +57,7 @@ export class GfCreateOrUpdateTagDialogComponent {
name: this.tagForm.get('name')?.value name: this.tagForm.get('name')?.value
}; };
if (this.data.tag.id) { if (this.data.tag?.id) {
(tag as UpdateTagDto).id = this.data.tag.id; (tag as UpdateTagDto).id = this.data.tag.id;
await validateObjectForForm({ await validateObjectForForm({
classDto: UpdateTagDto, classDto: UpdateTagDto,

2
apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.html

@ -4,7 +4,7 @@
(keyup.enter)="tagForm.valid && onSubmit()" (keyup.enter)="tagForm.valid && onSubmit()"
(ngSubmit)="onSubmit()" (ngSubmit)="onSubmit()"
> >
@if (data.tag.id) { @if (data.tag?.id) {
<h1 i18n mat-dialog-title>Update tag</h1> <h1 i18n mat-dialog-title>Update tag</h1>
} @else { } @else {
<h1 i18n mat-dialog-title>Add tag</h1> <h1 i18n mat-dialog-title>Add tag</h1>

2
apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/interfaces/interfaces.ts

@ -1,5 +1,5 @@
import { Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
export interface CreateOrUpdateTagDialogParams { export interface CreateOrUpdateTagDialogParams {
tag: Pick<Tag, 'id' | 'name'>; tag?: Pick<Tag, 'id' | 'name'>;
} }

103
apps/client/src/app/components/admin-users/admin-users.component.ts

@ -1,8 +1,11 @@
import { UserDetailDialogParams } from '@ghostfolio/client/components/user-detail-dialog/interfaces/interfaces'; import {
UserDetailDialogParams,
UserDetailDialogResult
} from '@ghostfolio/client/components/user-detail-dialog/interfaces/interfaces';
import { GfUserDetailDialogComponent } from '@ghostfolio/client/components/user-detail-dialog/user-detail-dialog.component'; import { GfUserDetailDialogComponent } from '@ghostfolio/client/components/user-detail-dialog/user-detail-dialog.component';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { DEFAULT_PAGE_SIZE, locale } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { import {
getDateFnsLocale, getDateFnsLocale,
@ -25,9 +28,11 @@ import { CommonModule } from '@angular/common';
import { import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
computed,
DestroyRef, DestroyRef,
inject,
OnInit, OnInit,
ViewChild viewChild
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@ -76,37 +81,42 @@ import { switchMap, tap } from 'rxjs/operators';
templateUrl: './admin-users.html' templateUrl: './admin-users.html'
}) })
export class GfAdminUsersComponent implements OnInit { export class GfAdminUsersComponent implements OnInit {
@ViewChild(MatPaginator) paginator: MatPaginator; protected dataSource = new MatTableDataSource<
AdminUsersResponse['users'][0]
public dataSource = new MatTableDataSource<AdminUsersResponse['users'][0]>(); >();
public defaultDateFormat: string; protected defaultDateFormat: string;
public deviceType: string; protected displayedColumns: string[] = [];
public displayedColumns: string[] = []; protected readonly getEmojiFlag = getEmojiFlag;
public getEmojiFlag = getEmojiFlag; protected hasPermissionForSubscription: boolean;
public hasPermissionForSubscription: boolean; protected hasPermissionToImpersonateAllUsers: boolean;
public hasPermissionToImpersonateAllUsers: boolean; protected info: InfoItem;
public info: InfoItem; protected isLoading = false;
public isLoading = false; protected readonly pageSize = DEFAULT_PAGE_SIZE;
public pageSize = DEFAULT_PAGE_SIZE; protected readonly routerLinkAdminControlUsers =
public routerLinkAdminControlUsers =
internalRoutes.adminControl.subRoutes.users.routerLink; internalRoutes.adminControl.subRoutes.users.routerLink;
public totalItems = 0; protected totalItems = 0;
public user: User; protected user: User;
private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
private readonly paginator = viewChild.required(MatPaginator);
public constructor( private readonly adminService = inject(AdminService);
private adminService: AdminService, private readonly changeDetectorRef = inject(ChangeDetectorRef);
private changeDetectorRef: ChangeDetectorRef, private readonly dataService = inject(DataService);
private dataService: DataService, private readonly destroyRef = inject(DestroyRef);
private destroyRef: DestroyRef, private readonly deviceDetectorService = inject(DeviceDetectorService);
private deviceDetectorService: DeviceDetectorService, private readonly dialog = inject(MatDialog);
private dialog: MatDialog, private readonly impersonationStorageService = inject(
private impersonationStorageService: ImpersonationStorageService, ImpersonationStorageService
private notificationService: NotificationService, );
private route: ActivatedRoute, private readonly notificationService = inject(NotificationService);
private router: Router, private readonly route = inject(ActivatedRoute);
private userService: UserService private readonly router = inject(Router);
) { private readonly userService = inject(UserService);
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
public constructor() {
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();
this.hasPermissionForSubscription = hasPermission( this.hasPermissionForSubscription = hasPermission(
@ -176,7 +186,7 @@ export class GfAdminUsersComponent implements OnInit {
this.fetchUsers(); this.fetchUsers();
} }
public formatDistanceToNow(aDateString: string) { protected formatDistanceToNow(aDateString: string) {
if (aDateString) { if (aDateString) {
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), { const distanceString = formatDistanceToNowStrict(parseISO(aDateString), {
addSuffix: true, addSuffix: true,
@ -192,13 +202,13 @@ export class GfAdminUsersComponent implements OnInit {
return ''; return '';
} }
public onChangePage(page: PageEvent) { protected onChangePage(page: PageEvent) {
this.fetchUsers({ this.fetchUsers({
pageIndex: page.pageIndex pageIndex: page.pageIndex
}); });
} }
public onDeleteUser(aId: string) { protected onDeleteUser(aId: string) {
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
this.dataService this.dataService
@ -216,7 +226,7 @@ export class GfAdminUsersComponent implements OnInit {
}); });
} }
public onGenerateAccessToken(aUserId: string) { protected onGenerateAccessToken(aUserId: string) {
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
this.dataService this.dataService
@ -241,7 +251,7 @@ export class GfAdminUsersComponent implements OnInit {
}); });
} }
public onImpersonateUser(aId: string) { protected onImpersonateUser(aId: string) {
if (aId) { if (aId) {
this.impersonationStorageService.setId(aId); this.impersonationStorageService.setId(aId);
} else { } else {
@ -251,7 +261,7 @@ export class GfAdminUsersComponent implements OnInit {
window.location.reload(); window.location.reload();
} }
public onOpenUserDetailDialog(userId: string) { protected onOpenUserDetailDialog(userId: string) {
this.router.navigate( this.router.navigate(
internalRoutes.adminControl.subRoutes.users.routerLink.concat(userId) internalRoutes.adminControl.subRoutes.users.routerLink.concat(userId)
); );
@ -260,8 +270,8 @@ export class GfAdminUsersComponent implements OnInit {
private fetchUsers({ pageIndex }: { pageIndex: number } = { pageIndex: 0 }) { private fetchUsers({ pageIndex }: { pageIndex: number } = { pageIndex: 0 }) {
this.isLoading = true; this.isLoading = true;
if (pageIndex === 0 && this.paginator) { if (pageIndex === 0 && this.paginator()) {
this.paginator.pageIndex = 0; this.paginator().pageIndex = 0;
} }
this.adminService this.adminService
@ -283,18 +293,19 @@ export class GfAdminUsersComponent implements OnInit {
private openUserDetailDialog(aUserId: string) { private openUserDetailDialog(aUserId: string) {
const dialogRef = this.dialog.open< const dialogRef = this.dialog.open<
GfUserDetailDialogComponent, GfUserDetailDialogComponent,
UserDetailDialogParams UserDetailDialogParams,
UserDetailDialogResult
>(GfUserDetailDialogComponent, { >(GfUserDetailDialogComponent, {
autoFocus: false, autoFocus: false,
data: { data: {
currentUserId: this.user?.id, currentUserId: this.user?.id,
deviceType: this.deviceType, deviceType: this.deviceType(),
hasPermissionForSubscription: this.hasPermissionForSubscription, hasPermissionForSubscription: this.hasPermissionForSubscription,
locale: this.user?.settings?.locale, locale: this.user?.settings?.locale ?? locale,
userId: aUserId userId: aUserId
}, } satisfies UserDetailDialogParams,
height: this.deviceType === 'mobile' ? '98vh' : '60vh', height: this.deviceType() === 'mobile' ? '98vh' : '60vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
}); });
dialogRef dialogRef

12
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html

@ -4,7 +4,7 @@
class="align-items-center d-flex flex-grow-1 h5 mb-0 py-2 text-truncate" class="align-items-center d-flex flex-grow-1 h5 mb-0 py-2 text-truncate"
> >
<span i18n>Performance</span> <span i18n>Performance</span>
@if (user?.subscription?.type === 'Basic') { @if (user()?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" /> <gf-premium-indicator class="ml-1" />
} }
</div> </div>
@ -18,12 +18,12 @@
<mat-label i18n>Compare with...</mat-label> <mat-label i18n>Compare with...</mat-label>
<mat-select <mat-select
name="benchmark" name="benchmark"
[disabled]="user?.subscription?.type === 'Basic'" [disabled]="user()?.subscription?.type === 'Basic'"
[value]="benchmark?.id" [value]="benchmark()?.id"
(selectionChange)="onChangeBenchmark($event.value)" (selectionChange)="onChangeBenchmark($event.value)"
> >
<mat-option [value]="null" /> <mat-option [value]="null" />
@for (symbolProfile of benchmarks; track symbolProfile) { @for (symbolProfile of benchmarks(); track symbolProfile) {
<mat-option [value]="symbolProfile.id">{{ <mat-option [value]="symbolProfile.id">{{
symbolProfile.name symbolProfile.name
}}</mat-option> }}</mat-option>
@ -41,7 +41,7 @@
</div> </div>
</div> </div>
<div class="chart-container"> <div class="chart-container">
@if (isLoading) { @if (isLoading()) {
<ngx-skeleton-loader <ngx-skeleton-loader
animation="pulse" animation="pulse"
[theme]="{ [theme]="{
@ -53,6 +53,6 @@
<canvas <canvas
#chartCanvas #chartCanvas
class="h-100" class="h-100"
[ngStyle]="{ display: isLoading ? 'none' : 'block' }" [ngStyle]="{ display: isLoading() ? 'none' : 'block' }"
></canvas> ></canvas>
</div> </div>

81
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts

@ -22,12 +22,11 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
type ElementRef, type ElementRef,
EventEmitter, input,
Input,
OnChanges, OnChanges,
OnDestroy, OnDestroy,
Output, output,
ViewChild viewChild
} from '@angular/core'; } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
@ -68,24 +67,25 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
templateUrl: './benchmark-comparator.component.html' templateUrl: './benchmark-comparator.component.html'
}) })
export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
@Input() benchmark: Partial<SymbolProfile>; public readonly benchmark = input<Partial<SymbolProfile>>();
@Input() benchmarkDataItems: LineChartItem[] = []; public readonly benchmarkDataItems = input<LineChartItem[]>([]);
@Input() benchmarks: Partial<SymbolProfile>[]; public readonly benchmarks = input<Partial<SymbolProfile>[]>();
@Input() colorScheme: ColorScheme; public readonly colorScheme = input.required<ColorScheme>();
@Input() isLoading: boolean; public readonly isLoading = input<boolean>();
@Input() locale = getLocale(); public readonly locale = input(getLocale());
@Input() performanceDataItems: LineChartItem[]; public readonly performanceDataItems = input.required<LineChartItem[]>();
@Input() user: User; public readonly user = input<User>();
@Output() benchmarkChanged = new EventEmitter<string>(); public readonly benchmarkChanged = output<string>();
@ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>; protected chart: Chart<'line'>;
protected hasPermissionToAccessAdminControl: boolean;
public chart: Chart<'line'>; protected readonly routerLinkAdminControlMarketData =
public hasPermissionToAccessAdminControl: boolean;
public routerLinkAdminControlMarketData =
internalRoutes.adminControl.subRoutes.marketData.routerLink; internalRoutes.adminControl.subRoutes.marketData.routerLink;
private readonly chartCanvas =
viewChild.required<ElementRef<HTMLCanvasElement>>('chartCanvas');
public constructor() { public constructor() {
Chart.register( Chart.register(
annotationPlugin, annotationPlugin,
@ -104,27 +104,27 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
public ngOnChanges() { public ngOnChanges() {
this.hasPermissionToAccessAdminControl = hasPermission( this.hasPermissionToAccessAdminControl = hasPermission(
this.user?.permissions, this.user()?.permissions,
permissions.accessAdminControl permissions.accessAdminControl
); );
if (this.performanceDataItems) { if (this.performanceDataItems()) {
this.initialize(); this.initialize();
} }
} }
public onChangeBenchmark(symbolProfileId: string) {
this.benchmarkChanged.next(symbolProfileId);
}
public ngOnDestroy() { public ngOnDestroy() {
this.chart?.destroy(); this.chart?.destroy();
} }
protected onChangeBenchmark(symbolProfileId: string) {
this.benchmarkChanged.emit(symbolProfileId);
}
private initialize() { private initialize() {
const benchmarkDataValues: Record<string, number> = {}; const benchmarkDataValues: Record<string, number> = {};
for (const { date, value } of this.benchmarkDataItems) { for (const { date, value } of this.benchmarkDataItems()) {
benchmarkDataValues[date] = value; benchmarkDataValues[date] = value;
} }
@ -134,8 +134,11 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2, borderWidth: 2,
data: this.performanceDataItems.map(({ date, value }) => { data: this.performanceDataItems().map(({ date, value }) => {
return { x: parseDate(date).getTime(), y: value * 100 }; return {
x: parseDate(date)?.getTime() ?? null,
y: value * 100
};
}), }),
label: $localize`Portfolio` label: $localize`Portfolio`
}, },
@ -143,9 +146,9 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderWidth: 2, borderWidth: 2,
data: this.performanceDataItems.map(({ date }) => { data: this.performanceDataItems().map(({ date }) => {
return { return {
x: parseDate(date).getTime(), x: parseDate(date)?.getTime() ?? null,
y: benchmarkDataValues[date] y: benchmarkDataValues[date]
}; };
}), }),
@ -163,7 +166,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart(this.chartCanvas.nativeElement, { this.chart = new Chart<'line'>(this.chartCanvas().nativeElement, {
data, data,
options: { options: {
animation: false, animation: false,
@ -172,7 +175,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
tension: 0 tension: 0
}, },
point: { point: {
hoverBackgroundColor: getBackgroundColor(this.colorScheme), hoverBackgroundColor: getBackgroundColor(this.colorScheme()),
hoverRadius: 2, hoverRadius: 2,
radius: 0 radius: 0
} }
@ -183,7 +186,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
annotation: { annotation: {
annotations: { annotations: {
yAxis: { yAxis: {
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`, borderColor: `rgba(${getTextColor(this.colorScheme())}, 0.1)`,
borderWidth: 1, borderWidth: 1,
scaleID: 'y', scaleID: 'y',
type: 'line', type: 'line',
@ -196,14 +199,14 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
}, },
tooltip: this.getTooltipPluginConfiguration(), tooltip: this.getTooltipPluginConfiguration(),
verticalHoverLine: { verticalHoverLine: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)` color: `rgba(${getTextColor(this.colorScheme())}, 0.1)`
} }
}, },
responsive: true, responsive: true,
scales: { scales: {
x: { x: {
border: { border: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`, color: `rgba(${getTextColor(this.colorScheme())}, 0.1)`,
width: 1 width: 1
}, },
display: true, display: true,
@ -212,7 +215,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
}, },
type: 'time', type: 'time',
time: { time: {
tooltipFormat: getDateFormatString(this.locale), tooltipFormat: getDateFormatString(this.locale()),
unit: 'year' unit: 'year'
} }
}, },
@ -228,7 +231,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
tick.value === scale.max || tick.value === scale.max ||
tick.value === scale.min tick.value === scale.min
) { ) {
return `rgba(${getTextColor(this.colorScheme)}, 0.1)`; return `rgba(${getTextColor(this.colorScheme())}, 0.1)`;
} }
return 'transparent'; return 'transparent';
@ -247,7 +250,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
} }
}, },
plugins: [ plugins: [
getVerticalHoverLinePlugin(this.chartCanvas, this.colorScheme) getVerticalHoverLinePlugin(this.chartCanvas(), this.colorScheme())
], ],
type: 'line' type: 'line'
}); });
@ -258,8 +261,8 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
private getTooltipPluginConfiguration(): Partial<TooltipOptions<'line'>> { private getTooltipPluginConfiguration(): Partial<TooltipOptions<'line'>> {
return { return {
...getTooltipOptions({ ...getTooltipOptions({
colorScheme: this.colorScheme, colorScheme: this.colorScheme(),
locale: this.locale, locale: this.locale(),
unit: '%' unit: '%'
}), }),
mode: 'index', mode: 'index',

121
apps/client/src/app/components/header/header.component.html

@ -1,14 +1,14 @@
<mat-toolbar class="px-0"> <mat-toolbar class="px-0">
@if (user) { @if (user()) {
<div class="d-flex h-100 logo-container" [class.filled]="hasTabs"> <div class="d-flex h-100 logo-container" [class.filled]="hasTabs()">
<a <a
class="align-items-center h-100 justify-content-start px-2 px-sm-3 rounded-0" class="align-items-center h-100 justify-content-start px-2 px-sm-3 rounded-0"
mat-button mat-button
[class.w-100]="hasTabs" [class.w-100]="hasTabs()"
[routerLink]="['/']" [routerLink]="['/']"
(click)="onLogoClick()" (click)="onLogoClick()"
> >
<gf-logo [label]="pageTitle" /> <gf-logo [label]="pageTitle()" />
</a> </a>
</div> </div>
<span class="gf-spacer"></span> <span class="gf-spacer"></span>
@ -20,11 +20,11 @@
mat-button mat-button
[class]="{ [class]="{
'font-weight-bold': 'font-weight-bold':
currentRoute === internalRoutes.home.path || currentRoute() === internalRoutes.home.path ||
currentRoute === internalRoutes.zen.path, currentRoute() === internalRoutes.zen.path,
'text-decoration-underline': 'text-decoration-underline':
currentRoute === internalRoutes.home.path || currentRoute() === internalRoutes.home.path ||
currentRoute === internalRoutes.zen.path currentRoute() === internalRoutes.zen.path
}" }"
[routerLink]="['/']" [routerLink]="['/']"
>Overview</a >Overview</a
@ -36,9 +36,10 @@
i18n i18n
mat-button mat-button
[class]="{ [class]="{
'font-weight-bold': currentRoute === internalRoutes.portfolio.path, 'font-weight-bold':
currentRoute() === internalRoutes.portfolio.path,
'text-decoration-underline': 'text-decoration-underline':
currentRoute === internalRoutes.portfolio.path currentRoute() === internalRoutes.portfolio.path
}" }"
[routerLink]="routerLinkPortfolio" [routerLink]="routerLinkPortfolio"
>Portfolio</a >Portfolio</a
@ -50,9 +51,9 @@
i18n i18n
mat-button mat-button
[class]="{ [class]="{
'font-weight-bold': currentRoute === internalRoutes.accounts.path, 'font-weight-bold': currentRoute() === internalRoutes.accounts.path,
'text-decoration-underline': 'text-decoration-underline':
currentRoute === internalRoutes.accounts.path currentRoute() === internalRoutes.accounts.path
}" }"
[routerLink]="routerLinkAccounts" [routerLink]="routerLinkAccounts"
>Accounts</a >Accounts</a
@ -66,9 +67,9 @@
mat-button mat-button
[class]="{ [class]="{
'font-weight-bold': 'font-weight-bold':
currentRoute === internalRoutes.adminControl.path, currentRoute() === internalRoutes.adminControl.path,
'text-decoration-underline': 'text-decoration-underline':
currentRoute === internalRoutes.adminControl.path currentRoute() === internalRoutes.adminControl.path
}" }"
[routerLink]="routerLinkAdminControl" [routerLink]="routerLinkAdminControl"
>Admin Control</a >Admin Control</a
@ -81,29 +82,29 @@
i18n i18n
mat-button mat-button
[class]="{ [class]="{
'font-weight-bold': currentRoute === routeResources, 'font-weight-bold': currentRoute() === routeResources,
'text-decoration-underline': currentRoute === routeResources 'text-decoration-underline': currentRoute() === routeResources
}" }"
[routerLink]="routerLinkResources" [routerLink]="routerLinkResources"
>Resources</a >Resources</a
> >
</li> </li>
@if ( @if (
hasPermissionForSubscription && user?.subscription?.type === 'Basic' hasPermissionForSubscription && user()?.subscription?.type === 'Basic'
) { ) {
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block rounded" class="d-none d-sm-block rounded"
mat-button mat-button
[class]="{ [class]="{
'font-weight-bold': currentRoute === routePricing, 'font-weight-bold': currentRoute() === routePricing,
'text-decoration-underline': currentRoute === routePricing 'text-decoration-underline': currentRoute() === routePricing
}" }"
[routerLink]="routerLinkPricing" [routerLink]="routerLinkPricing"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<span i18n>Pricing</span> <span i18n>Pricing</span>
@if (currentRoute !== routePricing && hasPromotion) { @if (currentRoute() !== routePricing && hasPromotion()) {
<span class="badge badge-warning ml-1">%</span> <span class="badge badge-warning ml-1">%</span>
} }
</span> </span>
@ -116,8 +117,8 @@
i18n i18n
mat-button mat-button
[class]="{ [class]="{
'font-weight-bold': currentRoute === routeAbout, 'font-weight-bold': currentRoute() === routeAbout,
'text-decoration-underline': currentRoute === routeAbout 'text-decoration-underline': currentRoute() === routeAbout
}" }"
[routerLink]="routerLinkAbout" [routerLink]="routerLinkAbout"
>About</a >About</a
@ -131,7 +132,7 @@
matBadge="&NoBreak;" matBadge="&NoBreak;"
matBadgeSize="small" matBadgeSize="small"
matButton matButton
[matBadgeHidden]="!hasFilters || !hasPermissionToChangeFilters" [matBadgeHidden]="!hasFilters || !hasPermissionToChangeFilters()"
[matMenuTriggerFor]="assistantMenu" [matMenuTriggerFor]="assistantMenu"
[matMenuTriggerRestoreFocus]="false" [matMenuTriggerRestoreFocus]="false"
(menuOpened)="onOpenAssistant()" (menuOpened)="onOpenAssistant()"
@ -143,17 +144,19 @@
class="no-max-width" class="no-max-width"
xPosition="before" xPosition="before"
[overlapTrigger]="true" [overlapTrigger]="true"
(closed)="assistantElement?.setIsOpen(false)" (closed)="assistantElement()?.setIsOpen(false)"
> >
<gf-assistant <gf-assistant
#assistant #assistant
[deviceType]="deviceType" [deviceType]="deviceType()"
[hasPermissionToAccessAdminControl]=" [hasPermissionToAccessAdminControl]="
hasPermissionToAccessAdminControl hasPermissionToAccessAdminControl
" "
[hasPermissionToChangeDateRange]="hasPermissionToChangeDateRange" [hasPermissionToChangeDateRange]="
[hasPermissionToChangeFilters]="hasPermissionToChangeFilters" hasPermissionToChangeDateRange()
[user]="user" "
[hasPermissionToChangeFilters]="hasPermissionToChangeFilters()"
[user]="user()"
(closed)="closeAssistant()" (closed)="closeAssistant()"
(dateRangeChanged)="onDateRangeChange($event)" (dateRangeChanged)="onDateRangeChange($event)"
(filtersChanged)="onFiltersChanged($event)" (filtersChanged)="onFiltersChanged($event)"
@ -182,12 +185,13 @@
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
@if ( @if (
hasPermissionForSubscription && user?.subscription?.type === 'Basic' hasPermissionForSubscription &&
user()?.subscription?.type === 'Basic'
) { ) {
<a class="d-flex" mat-menu-item [routerLink]="routerLinkPricing" <a class="d-flex" mat-menu-item [routerLink]="routerLinkPricing"
><span class="align-items-center d-flex" ><span class="align-items-center d-flex"
><span> ><span>
@if (user.subscription.offer.isRenewal) { @if (user().subscription.offer.isRenewal) {
<ng-container i18n>Renew Plan</ng-container> <ng-container i18n>Renew Plan</ng-container>
} @else { } @else {
<ng-container i18n>Upgrade Plan</ng-container> <ng-container i18n>Upgrade Plan</ng-container>
@ -199,7 +203,7 @@
></a> ></a>
<hr class="m-0" /> <hr class="m-0" />
} }
@if (user?.access?.length > 0) { @if (user()?.access?.length > 0) {
<button mat-menu-item (click)="impersonateAccount(null)"> <button mat-menu-item (click)="impersonateAccount(null)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon <ion-icon
@ -213,7 +217,7 @@
<span i18n>Me</span> <span i18n>Me</span>
</span> </span>
</button> </button>
@for (accessItem of user?.access; track accessItem) { @for (accessItem of user()?.access; track accessItem) {
<button mat-menu-item (click)="impersonateAccount(accessItem.id)"> <button mat-menu-item (click)="impersonateAccount(accessItem.id)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon <ion-icon
@ -240,8 +244,8 @@
i18n i18n
mat-menu-item mat-menu-item
[class.font-weight-bold]=" [class.font-weight-bold]="
currentRoute === internalRoutes.home.path || currentRoute() === internalRoutes.home.path ||
currentRoute === internalRoutes.zen.path currentRoute() === internalRoutes.zen.path
" "
[routerLink]="['/']" [routerLink]="['/']"
>Overview</a >Overview</a
@ -251,7 +255,7 @@
i18n i18n
mat-menu-item mat-menu-item
[class.font-weight-bold]=" [class.font-weight-bold]="
currentRoute === internalRoutes.portfolio.path currentRoute() === internalRoutes.portfolio.path
" "
[routerLink]="routerLinkPortfolio" [routerLink]="routerLinkPortfolio"
>Portfolio</a >Portfolio</a
@ -261,7 +265,7 @@
i18n i18n
mat-menu-item mat-menu-item
[class.font-weight-bold]=" [class.font-weight-bold]="
currentRoute === internalRoutes.accounts.path currentRoute() === internalRoutes.accounts.path
" "
[routerLink]="routerLinkAccounts" [routerLink]="routerLinkAccounts"
>Accounts</a >Accounts</a
@ -270,7 +274,7 @@
i18n i18n
mat-menu-item mat-menu-item
[class.font-weight-bold]=" [class.font-weight-bold]="
currentRoute === internalRoutes.account.path currentRoute() === internalRoutes.account.path
" "
[routerLink]="routerLinkAccount" [routerLink]="routerLinkAccount"
>My Ghostfolio</a >My Ghostfolio</a
@ -281,7 +285,7 @@
i18n i18n
mat-menu-item mat-menu-item
[class.font-weight-bold]=" [class.font-weight-bold]="
currentRoute === internalRoutes.adminControl.path currentRoute() === internalRoutes.adminControl.path
" "
[routerLink]="routerLinkAdminControl" [routerLink]="routerLinkAdminControl"
>Admin Control</a >Admin Control</a
@ -292,22 +296,23 @@
class="d-flex d-sm-none" class="d-flex d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
[class.font-weight-bold]="currentRoute === routeResources" [class.font-weight-bold]="currentRoute() === routeResources"
[routerLink]="routerLinkResources" [routerLink]="routerLinkResources"
>Resources</a >Resources</a
> >
@if ( @if (
hasPermissionForSubscription && user?.subscription?.type === 'Basic' hasPermissionForSubscription &&
user()?.subscription?.type === 'Basic'
) { ) {
<a <a
class="d-flex d-sm-none" class="d-flex d-sm-none"
mat-menu-item mat-menu-item
[class.font-weight-bold]="currentRoute === routePricing" [class.font-weight-bold]="currentRoute() === routePricing"
[routerLink]="routerLinkPricing" [routerLink]="routerLinkPricing"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<span i18n>Pricing</span> <span i18n>Pricing</span>
@if (currentRoute !== routePricing && hasPromotion) { @if (currentRoute() !== routePricing && hasPromotion()) {
<span class="badge badge-warning ml-1">%</span> <span class="badge badge-warning ml-1">%</span>
} }
</span> </span>
@ -317,7 +322,7 @@
class="d-flex d-sm-none" class="d-flex d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
[class.font-weight-bold]="currentRoute === routeAbout" [class.font-weight-bold]="currentRoute() === routeAbout"
[routerLink]="routerLinkAbout" [routerLink]="routerLinkAbout"
>About Ghostfolio</a >About Ghostfolio</a
> >
@ -327,17 +332,17 @@
</li> </li>
</ul> </ul>
} }
@if (user === null) { @if (user() === null) {
<div class="d-flex h-100 logo-container" [class.filled]="hasTabs"> <div class="d-flex h-100 logo-container" [class.filled]="hasTabs()">
<a <a
class="align-items-center h-100 justify-content-start px-2 px-sm-3 rounded-0" class="align-items-center h-100 justify-content-start px-2 px-sm-3 rounded-0"
mat-button mat-button
[class.w-100]="hasTabs" [class.w-100]="hasTabs()"
[routerLink]="['/']" [routerLink]="['/']"
> >
<gf-logo <gf-logo
[label]="pageTitle" [label]="pageTitle()"
[showLabel]="currentRoute !== 'register'" [showLabel]="currentRoute() !== 'register'"
/> />
</a> </a>
</div> </div>
@ -349,8 +354,8 @@
i18n i18n
mat-button mat-button
[class]="{ [class]="{
'font-weight-bold': currentRoute === routeFeatures, 'font-weight-bold': currentRoute() === routeFeatures,
'text-decoration-underline': currentRoute === routeFeatures 'text-decoration-underline': currentRoute() === routeFeatures
}" }"
[routerLink]="routerLinkFeatures" [routerLink]="routerLinkFeatures"
>Features</a >Features</a
@ -362,8 +367,8 @@
i18n i18n
mat-button mat-button
[class]="{ [class]="{
'font-weight-bold': currentRoute === routeAbout, 'font-weight-bold': currentRoute() === routeAbout,
'text-decoration-underline': currentRoute === routeAbout 'text-decoration-underline': currentRoute() === routeAbout
}" }"
[routerLink]="routerLinkAbout" [routerLink]="routerLinkAbout"
>About</a >About</a
@ -375,14 +380,14 @@
class="d-sm-block rounded" class="d-sm-block rounded"
mat-button mat-button
[class]="{ [class]="{
'font-weight-bold': currentRoute === routePricing, 'font-weight-bold': currentRoute() === routePricing,
'text-decoration-underline': currentRoute === routePricing 'text-decoration-underline': currentRoute() === routePricing
}" }"
[routerLink]="routerLinkPricing" [routerLink]="routerLinkPricing"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<span i18n>Pricing</span> <span i18n>Pricing</span>
@if (currentRoute !== routePricing && hasPromotion) { @if (currentRoute() !== routePricing && hasPromotion()) {
<span class="badge badge-warning ml-1">%</span> <span class="badge badge-warning ml-1">%</span>
} }
</span> </span>
@ -396,8 +401,8 @@
i18n i18n
mat-button mat-button
[class]="{ [class]="{
'font-weight-bold': currentRoute === routeMarkets, 'font-weight-bold': currentRoute() === routeMarkets,
'text-decoration-underline': currentRoute === routeMarkets 'text-decoration-underline': currentRoute() === routeMarkets
}" }"
[routerLink]="routerLinkMarkets" [routerLink]="routerLinkMarkets"
>Markets</a >Markets</a
@ -421,7 +426,7 @@
<ng-container i18n>Sign in</ng-container> <ng-container i18n>Sign in</ng-container>
</button> </button>
</li> </li>
@if (currentRoute !== 'register' && hasPermissionToCreateUser) { @if (currentRoute() !== 'register' && hasPermissionToCreateUser) {
<li class="list-inline-item ml-1"> <li class="list-inline-item ml-1">
<a <a
class="d-none d-sm-block px-3 rounded" class="d-none d-sm-block px-3 rounded"

211
apps/client/src/app/components/header/header.component.ts

@ -1,4 +1,7 @@
import { LoginWithAccessTokenDialogParams } from '@ghostfolio/client/components/login-with-access-token-dialog/interfaces/interfaces'; import {
LoginWithAccessTokenDialogParams,
LoginWithAccessTokenDialogResult
} from '@ghostfolio/client/components/login-with-access-token-dialog/interfaces/interfaces';
import { GfLoginWithAccessTokenDialogComponent } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component'; import { GfLoginWithAccessTokenDialogComponent } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
import { LayoutService } from '@ghostfolio/client/core/layout.service'; import { LayoutService } from '@ghostfolio/client/core/layout.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
@ -24,12 +27,12 @@ import {
Component, Component,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
DestroyRef, DestroyRef,
EventEmitter,
HostListener, HostListener,
Input, inject,
input,
OnChanges, OnChanges,
Output, output,
ViewChild viewChild
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatBadgeModule } from '@angular/material/badge'; import { MatBadgeModule } from '@angular/material/badge';
@ -71,78 +74,67 @@ import { catchError } from 'rxjs/operators';
styleUrls: ['./header.component.scss'] styleUrls: ['./header.component.scss']
}) })
export class GfHeaderComponent implements OnChanges { export class GfHeaderComponent implements OnChanges {
@HostListener('window:keydown', ['$event']) public readonly currentRoute = input.required<string>();
openAssistantWithHotKey(event: KeyboardEvent) { public readonly deviceType = input.required<string>();
if ( public readonly hasPermissionToChangeDateRange = input.required<boolean>();
event.key === '/' && public readonly hasPermissionToChangeFilters = input.required<boolean>();
event.target instanceof Element && public readonly hasPromotion = input.required<boolean>();
event.target?.nodeName?.toLowerCase() !== 'input' && public readonly hasTabs = input.required<boolean>();
event.target?.nodeName?.toLowerCase() !== 'textarea' && public readonly info = input.required<InfoItem | undefined>();
this.hasPermissionToAccessAssistant public readonly pageTitle = input.required<string>();
) { public readonly user = input.required<User | undefined>();
this.assistantElement.setIsOpen(true);
this.assistentMenuTriggerElement.openMenu(); public readonly signOut = output<void>();
event.preventDefault(); protected readonly assistantElement =
} viewChild.required<GfAssistantComponent>('assistant');
} protected readonly assistentMenuTriggerElement =
viewChild.required<MatMenuTrigger>('assistantTrigger');
@Input() currentRoute: string;
@Input() deviceType: string; protected hasFilters: boolean;
@Input() hasPermissionToChangeDateRange: boolean; protected hasImpersonationId: boolean;
@Input() hasPermissionToChangeFilters: boolean; protected hasPermissionForAuthGoogle: boolean;
@Input() hasPromotion: boolean; protected hasPermissionForAuthOidc: boolean;
@Input() hasTabs: boolean; protected hasPermissionForAuthToken: boolean;
@Input() info: InfoItem; protected hasPermissionForSubscription: boolean;
@Input() pageTitle: string; protected hasPermissionToAccessAdminControl: boolean;
@Input() user: User; protected hasPermissionToAccessAssistant: boolean;
protected hasPermissionToAccessFearAndGreedIndex: boolean;
@Output() signOut = new EventEmitter<void>(); protected hasPermissionToCreateUser: boolean;
protected impersonationId: string;
@ViewChild('assistant') assistantElement: GfAssistantComponent; protected readonly internalRoutes = internalRoutes;
@ViewChild('assistantTrigger') assistentMenuTriggerElement: MatMenuTrigger; protected isMenuOpen: boolean;
protected readonly routeAbout = publicRoutes.about.path;
public hasFilters: boolean; protected readonly routeFeatures = publicRoutes.features.path;
public hasImpersonationId: boolean; protected readonly routeMarkets = publicRoutes.markets.path;
public hasPermissionForAuthGoogle: boolean; protected readonly routePricing = publicRoutes.pricing.path;
public hasPermissionForAuthOidc: boolean; protected readonly routeResources = publicRoutes.resources.path;
public hasPermissionForAuthToken: boolean; protected readonly routerLinkAbout = publicRoutes.about.routerLink;
public hasPermissionForSubscription: boolean; protected readonly routerLinkAccount = internalRoutes.account.routerLink;
public hasPermissionToAccessAdminControl: boolean; protected readonly routerLinkAccounts = internalRoutes.accounts.routerLink;
public hasPermissionToAccessAssistant: boolean; protected readonly routerLinkAdminControl =
public hasPermissionToAccessFearAndGreedIndex: boolean; internalRoutes.adminControl.routerLink;
public hasPermissionToCreateUser: boolean; protected readonly routerLinkFeatures = publicRoutes.features.routerLink;
public impersonationId: string; protected readonly routerLinkMarkets = publicRoutes.markets.routerLink;
public internalRoutes = internalRoutes; protected readonly routerLinkPortfolio = internalRoutes.portfolio.routerLink;
public isMenuOpen: boolean; protected readonly routerLinkPricing = publicRoutes.pricing.routerLink;
public routeAbout = publicRoutes.about.path; protected readonly routerLinkRegister = publicRoutes.register.routerLink;
public routeFeatures = publicRoutes.features.path; protected readonly routerLinkResources = publicRoutes.resources.routerLink;
public routeMarkets = publicRoutes.markets.path;
public routePricing = publicRoutes.pricing.path; private readonly dataService = inject(DataService);
public routeResources = publicRoutes.resources.path; private readonly destroyRef = inject(DestroyRef);
public routerLinkAbout = publicRoutes.about.routerLink; private readonly dialog = inject(MatDialog);
public routerLinkAccount = internalRoutes.account.routerLink; private readonly impersonationStorageService = inject(
public routerLinkAccounts = internalRoutes.accounts.routerLink; ImpersonationStorageService
public routerLinkAdminControl = internalRoutes.adminControl.routerLink; );
public routerLinkFeatures = publicRoutes.features.routerLink; private readonly layoutService = inject(LayoutService);
public routerLinkMarkets = publicRoutes.markets.routerLink; private readonly notificationService = inject(NotificationService);
public routerLinkPortfolio = internalRoutes.portfolio.routerLink; private readonly router = inject(Router);
public routerLinkPricing = publicRoutes.pricing.routerLink; private readonly settingsStorageService = inject(SettingsStorageService);
public routerLinkRegister = publicRoutes.register.routerLink; private readonly tokenStorageService = inject(TokenStorageService);
public routerLinkResources = publicRoutes.resources.routerLink; private readonly userService = inject(UserService);
public constructor( public constructor() {
private dataService: DataService,
private destroyRef: DestroyRef,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private layoutService: LayoutService,
private notificationService: NotificationService,
private router: Router,
private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService,
private userService: UserService
) {
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -162,55 +154,71 @@ export class GfHeaderComponent implements OnChanges {
}); });
} }
@HostListener('window:keydown', ['$event'])
protected openAssistantWithHotKey(event: KeyboardEvent) {
if (
event.key === '/' &&
event.target instanceof Element &&
event.target?.nodeName?.toLowerCase() !== 'input' &&
event.target?.nodeName?.toLowerCase() !== 'textarea' &&
this.hasPermissionToAccessAssistant
) {
this.assistantElement().setIsOpen(true);
this.assistentMenuTriggerElement().openMenu();
event.preventDefault();
}
}
public ngOnChanges() { public ngOnChanges() {
this.hasFilters = this.userService.hasFilters(); this.hasFilters = this.userService.hasFilters();
this.hasPermissionForAuthGoogle = hasPermission( this.hasPermissionForAuthGoogle = hasPermission(
this.info?.globalPermissions, this.info()?.globalPermissions,
permissions.enableAuthGoogle permissions.enableAuthGoogle
); );
this.hasPermissionForAuthOidc = hasPermission( this.hasPermissionForAuthOidc = hasPermission(
this.info?.globalPermissions, this.info()?.globalPermissions,
permissions.enableAuthOidc permissions.enableAuthOidc
); );
this.hasPermissionForAuthToken = hasPermission( this.hasPermissionForAuthToken = hasPermission(
this.info?.globalPermissions, this.info()?.globalPermissions,
permissions.enableAuthToken permissions.enableAuthToken
); );
this.hasPermissionForSubscription = hasPermission( this.hasPermissionForSubscription = hasPermission(
this.info?.globalPermissions, this.info()?.globalPermissions,
permissions.enableSubscription permissions.enableSubscription
); );
this.hasPermissionToAccessAdminControl = hasPermission( this.hasPermissionToAccessAdminControl = hasPermission(
this.user?.permissions, this.user()?.permissions,
permissions.accessAdminControl permissions.accessAdminControl
); );
this.hasPermissionToAccessAssistant = hasPermission( this.hasPermissionToAccessAssistant = hasPermission(
this.user?.permissions, this.user()?.permissions,
permissions.accessAssistant permissions.accessAssistant
); );
this.hasPermissionToAccessFearAndGreedIndex = hasPermission( this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.info?.globalPermissions, this.info()?.globalPermissions,
permissions.enableFearAndGreedIndex permissions.enableFearAndGreedIndex
); );
this.hasPermissionToCreateUser = hasPermission( this.hasPermissionToCreateUser = hasPermission(
this.info?.globalPermissions, this.info()?.globalPermissions,
permissions.createUserAccount permissions.createUserAccount
); );
} }
public closeAssistant() { protected closeAssistant() {
this.assistentMenuTriggerElement?.closeMenu(); this.assistentMenuTriggerElement().closeMenu();
} }
public impersonateAccount(aId: string) { protected impersonateAccount(aId: string) {
if (aId) { if (aId) {
this.impersonationStorageService.setId(aId); this.impersonationStorageService.setId(aId);
} else { } else {
@ -220,7 +228,7 @@ export class GfHeaderComponent implements OnChanges {
window.location.reload(); window.location.reload();
} }
public onDateRangeChange(dateRange: DateRange) { protected onDateRangeChange(dateRange: DateRange) {
this.dataService this.dataService
.putUserSetting({ dateRange }) .putUserSetting({ dateRange })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -232,7 +240,7 @@ export class GfHeaderComponent implements OnChanges {
}); });
} }
public onFiltersChanged(filters: Filter[]) { protected onFiltersChanged(filters: Filter[]) {
const userSetting: UpdateUserSettingDto = {}; const userSetting: UpdateUserSettingDto = {};
for (const filter of filters) { for (const filter of filters) {
@ -260,32 +268,33 @@ export class GfHeaderComponent implements OnChanges {
}); });
} }
public onLogoClick() { protected onLogoClick() {
if (['home', 'zen'].includes(this.currentRoute)) { if (['home', 'zen'].includes(this.currentRoute())) {
this.layoutService.getShouldReloadSubject().next(); this.layoutService.getShouldReloadSubject().next();
} }
} }
public onMenuClosed() { protected onMenuClosed() {
this.isMenuOpen = false; this.isMenuOpen = false;
} }
public onMenuOpened() { protected onMenuOpened() {
this.isMenuOpen = true; this.isMenuOpen = true;
} }
public onOpenAssistant() { protected onOpenAssistant() {
this.assistantElement.initialize(); this.assistantElement().initialize();
} }
public onSignOut() { protected onSignOut() {
this.signOut.next(); this.signOut.emit();
} }
public openLoginDialog() { protected openLoginDialog() {
const dialogRef = this.dialog.open< const dialogRef = this.dialog.open<
GfLoginWithAccessTokenDialogComponent, GfLoginWithAccessTokenDialogComponent,
LoginWithAccessTokenDialogParams LoginWithAccessTokenDialogParams,
LoginWithAccessTokenDialogResult
>(GfLoginWithAccessTokenDialogComponent, { >(GfLoginWithAccessTokenDialogComponent, {
autoFocus: false, autoFocus: false,
data: { data: {
@ -322,7 +331,7 @@ export class GfHeaderComponent implements OnChanges {
}); });
} }
public setToken(aToken: string) { private setToken(aToken: string) {
this.tokenStorageService.saveToken( this.tokenStorageService.saveToken(
aToken, aToken,
this.settingsStorageService.getSetting(KEY_STAY_SIGNED_IN) === 'true' this.settingsStorageService.getSetting(KEY_STAY_SIGNED_IN) === 'true'

2
apps/client/src/app/components/home-summary/home-summary.html

@ -1,4 +1,4 @@
<div class="container pb-3 px-3"> <div class="container px-3">
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Summary</h1> <h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Summary</h1>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-8 offset-md-2"> <div class="col-xs-12 col-md-8 offset-md-2">

1
apps/client/src/app/components/home-watchlist/home-watchlist.html

@ -14,6 +14,7 @@
[deviceType]="deviceType()" [deviceType]="deviceType()"
[hasPermissionToDeleteItem]="hasPermissionToDeleteWatchlistItem" [hasPermissionToDeleteItem]="hasPermissionToDeleteWatchlistItem"
[locale]="user?.settings?.locale || undefined" [locale]="user?.settings?.locale || undefined"
[showIcon]="true"
[user]="user" [user]="user"
(itemDeleted)="onWatchlistItemDeleted($event)" (itemDeleted)="onWatchlistItemDeleted($event)"
/> />

4
apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts

@ -5,3 +5,7 @@ export interface LoginWithAccessTokenDialogParams {
hasPermissionToUseAuthToken: boolean; hasPermissionToUseAuthToken: boolean;
title: string; title: string;
} }
export interface LoginWithAccessTokenDialogResult {
accessToken: string | null;
}

10
apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts

@ -22,7 +22,10 @@ import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { eyeOffOutline, eyeOutline } from 'ionicons/icons'; import { eyeOffOutline, eyeOutline } from 'ionicons/icons';
import { LoginWithAccessTokenDialogParams } from './interfaces/interfaces'; import {
LoginWithAccessTokenDialogParams,
LoginWithAccessTokenDialogResult
} from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -49,7 +52,10 @@ export class GfLoginWithAccessTokenDialogComponent {
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: LoginWithAccessTokenDialogParams, @Inject(MAT_DIALOG_DATA) public data: LoginWithAccessTokenDialogParams,
public dialogRef: MatDialogRef<GfLoginWithAccessTokenDialogComponent>, public dialogRef: MatDialogRef<
GfLoginWithAccessTokenDialogComponent,
LoginWithAccessTokenDialogResult
>,
private settingsStorageService: SettingsStorageService private settingsStorageService: SettingsStorageService
) { ) {
addIcons({ eyeOffOutline, eyeOutline }); addIcons({ eyeOffOutline, eyeOutline });

5
apps/client/src/app/components/user-detail-dialog/interfaces/interfaces.ts

@ -5,3 +5,8 @@ export interface UserDetailDialogParams {
locale: string; locale: string;
userId: string; userId: string;
} }
export interface UserDetailDialogResult {
action: 'delete';
userId: string;
}

10
apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts

@ -22,7 +22,10 @@ import { ellipsisVertical } from 'ionicons/icons';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { UserDetailDialogParams } from './interfaces/interfaces'; import {
UserDetailDialogParams,
UserDetailDialogResult
} from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -47,7 +50,10 @@ export class GfUserDetailDialogComponent implements OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams, @Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams,
private destroyRef: DestroyRef, private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfUserDetailDialogComponent> public dialogRef: MatDialogRef<
GfUserDetailDialogComponent,
UserDetailDialogResult
>
) { ) {
addIcons({ addIcons({
ellipsisVertical ellipsisVertical

4
apps/client/src/app/pages/about/oss-friends/oss-friends-page.html

@ -1,5 +1,5 @@
<div class="container"> <div class="container">
<div class="mb-5 row"> <div class="row">
<div class="col"> <div class="col">
<h1 class="h3 line-height-1 mb-4 text-center"> <h1 class="h3 line-height-1 mb-4 text-center">
<span class="d-none d-sm-block" <span class="d-none d-sm-block"
@ -11,7 +11,7 @@
</h1> </h1>
<div class="row"> <div class="row">
@for (ossFriend of ossFriends; track ossFriend) { @for (ossFriend of ossFriends; track ossFriend) {
<div class="col-xs-12 col-md-4 mb-3"> <div class="col-xs-12 col-md-4" [class.mb-3]="!$last">
<a target="_blank" [href]="ossFriend.href"> <a target="_blank" [href]="ossFriend.href">
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-header> <mat-card-header>

2
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html

@ -60,7 +60,7 @@
} }
</div> </div>
@for (category of categories; track category.key) { @for (category of categories; track category.key) {
<div class="mb-4" [class.d-none]="category.rules?.length === 0"> <div [class.mb-4]="!$last || inactiveRules?.length > 0">
<h4 class="align-items-center d-flex m-0"> <h4 class="align-items-center d-flex m-0">
<span>{{ category.name }}</span> <span>{{ category.name }}</span>
@if (user?.subscription?.type === 'Basic') { @if (user?.subscription?.type === 'Basic') {

5
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts

@ -107,7 +107,10 @@ export class GfXRayPageComponent {
.fetchPortfolioReport() .fetchPortfolioReport()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ xRay: { categories, statistics } }) => { .subscribe(({ xRay: { categories, statistics } }) => {
this.categories = categories; this.categories = categories.filter(({ rules }) => {
return rules?.length > 0;
});
this.inactiveRules = this.mergeInactiveRules(categories); this.inactiveRules = this.mergeInactiveRules(categories);
this.statistics = statistics; this.statistics = statistics;

2
apps/client/src/app/pages/resources/glossary/resources-glossary.component.html

@ -132,7 +132,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mb-4 media"> <div class="media">
<div class="media-body"> <div class="media-body">
<h2 class="h5 mb-1 mt-0">Stealth Wealth</h2> <h2 class="h5 mb-1 mt-0">Stealth Wealth</h2>
<div class="mb-1"> <div class="mb-1">

2
apps/client/src/app/pages/resources/guides/resources-guides.component.html

@ -19,7 +19,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mb-4 media"> <div class="media">
<div class="media-body"> <div class="media-body">
<h2 class="h5 mb-1 mt-0">How do I get my finances in order?</h2> <h2 class="h5 mb-1 mt-0">How do I get my finances in order?</h2>
<div class="mb-1"> <div class="mb-1">

2
apps/client/src/app/pages/resources/markets/resources-markets.component.html

@ -30,8 +30,8 @@
</div> </div>
</div> </div>
</div> </div>
<div class="media">
<div class="mb-4 media"> <div class="mb-4 media">
<div class="media-body">
<h2 class="h5 mb-1 mt-0">Inflation Chart</h2> <h2 class="h5 mb-1 mt-0">Inflation Chart</h2>
<div class="mb-1"> <div class="mb-1">
<i>Inflation Chart</i> helps you find the intrinsic value of stock <i>Inflation Chart</i> helps you find the intrinsic value of stock

2
apps/client/src/app/pages/resources/overview/resources-overview.component.html

@ -4,7 +4,7 @@
<h1 class="h3 mb-4 text-center" i18n>Resources</h1> <h1 class="h3 mb-4 text-center" i18n>Resources</h1>
<div class="overview-list"> <div class="overview-list">
@for (item of overviewItems; track item) { @for (item of overviewItems; track item) {
<div class="mb-4"> <div [class.mb-4]="!$last">
<h2 class="h5 mb-1 mt-0">{{ item.title }}</h2> <h2 class="h5 mb-1 mt-0">{{ item.title }}</h2>
<p class="mb-1">{{ item.description }}</p> <p class="mb-1">{{ item.description }}</p>
<a [routerLink]="item.routerLink" <a [routerLink]="item.routerLink"

4
apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.html

@ -1,5 +1,5 @@
<div class="container"> <div class="container">
<div class="mb-5 row"> <div class="row">
<div class="col"> <div class="col">
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n> <h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>
Discover Open Source Alternatives for Personal Finance Tools Discover Open Source Alternatives for Personal Finance Tools
@ -22,7 +22,7 @@
personalFinanceTool of personalFinanceTools; personalFinanceTool of personalFinanceTools;
track personalFinanceTool track personalFinanceTool
) { ) {
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" [class.mb-3]="!$last">
<mat-card-content class="p-0"> <mat-card-content class="p-0">
<div class="container p-0"> <div class="container p-0">
<div class="flex-nowrap no-gutters row"> <div class="flex-nowrap no-gutters row">

500
apps/client/src/locales/messages.ca.xlf

File diff suppressed because it is too large

500
apps/client/src/locales/messages.de.xlf

File diff suppressed because it is too large

500
apps/client/src/locales/messages.es.xlf

File diff suppressed because it is too large

500
apps/client/src/locales/messages.fr.xlf

File diff suppressed because it is too large

500
apps/client/src/locales/messages.it.xlf

File diff suppressed because it is too large

500
apps/client/src/locales/messages.ko.xlf

File diff suppressed because it is too large

500
apps/client/src/locales/messages.nl.xlf

File diff suppressed because it is too large

500
apps/client/src/locales/messages.pl.xlf

File diff suppressed because it is too large

500
apps/client/src/locales/messages.pt.xlf

File diff suppressed because it is too large

500
apps/client/src/locales/messages.tr.xlf

File diff suppressed because it is too large

500
apps/client/src/locales/messages.uk.xlf

File diff suppressed because it is too large

495
apps/client/src/locales/messages.xlf

File diff suppressed because it is too large

500
apps/client/src/locales/messages.zh.xlf

File diff suppressed because it is too large

1
apps/client/src/styles.scss

@ -479,6 +479,7 @@ ngx-skeleton-loader {
.page { .page {
display: flex; display: flex;
height: calc(100svh - var(--mat-toolbar-standard-height));
overflow-y: auto; overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
padding-bottom: constant(safe-area-inset-bottom); padding-bottom: constant(safe-area-inset-bottom);

2
libs/common/src/lib/dtos/create-asset-profile.dto.ts

@ -74,7 +74,7 @@ export class CreateAssetProfileDto {
@IsOptional() @IsOptional()
@IsUrl({ @IsUrl({
protocols: ['https'], protocols: ['http', 'https'],
require_protocol: true require_protocol: true
}) })
url?: string; url?: string;

2
libs/common/src/lib/dtos/create-platform.dto.ts

@ -5,7 +5,7 @@ export class CreatePlatformDto {
name: string; name: string;
@IsUrl({ @IsUrl({
protocols: ['https'], protocols: ['http', 'https'],
require_protocol: true require_protocol: true
}) })
url: string; url: string;

2
libs/common/src/lib/dtos/update-asset-profile.dto.ts

@ -64,7 +64,7 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
@IsUrl({ @IsUrl({
protocols: ['https'], protocols: ['http', 'https'],
require_protocol: true require_protocol: true
}) })
url?: string; url?: string;

2
libs/common/src/lib/dtos/update-platform.dto.ts

@ -8,7 +8,7 @@ export class UpdatePlatformDto {
name: string; name: string;
@IsUrl({ @IsUrl({
protocols: ['https'], protocols: ['http', 'https'],
require_protocol: true require_protocol: true
}) })
url: string; url: string;

2
libs/common/src/lib/dtos/update-property.dto.ts

@ -3,5 +3,5 @@ import { IsOptional, IsString } from 'class-validator';
export class UpdatePropertyDto { export class UpdatePropertyDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
value: string; value?: string;
} }

10
libs/common/src/lib/dtos/update-user-setting.dto.ts

@ -58,23 +58,23 @@ export class UpdateUserSettingDto {
@IsArray() @IsArray()
@IsOptional() @IsOptional()
'filters.accounts'?: string[]; 'filters.accounts'?: string[] | null;
@IsArray() @IsArray()
@IsOptional() @IsOptional()
'filters.assetClasses'?: string[]; 'filters.assetClasses'?: string[] | null;
@IsString() @IsString()
@IsOptional() @IsOptional()
'filters.dataSource'?: string; 'filters.dataSource'?: string | null;
@IsString() @IsString()
@IsOptional() @IsOptional()
'filters.symbol'?: string; 'filters.symbol'?: string | null;
@IsArray() @IsArray()
@IsOptional() @IsOptional()
'filters.tags'?: string[]; 'filters.tags'?: string[] | null;
@IsIn(['CHART', 'TABLE'] as HoldingsViewMode[]) @IsIn(['CHART', 'TABLE'] as HoldingsViewMode[])
@IsOptional() @IsOptional()

2
libs/common/src/lib/helper.ts

@ -187,7 +187,7 @@ export function getCurrencyFromSymbol(aSymbol = '') {
return aSymbol.replace(DEFAULT_CURRENCY, ''); return aSymbol.replace(DEFAULT_CURRENCY, '');
} }
export function getDateFnsLocale(aLanguageCode: string) { export function getDateFnsLocale(aLanguageCode?: string) {
if (aLanguageCode === 'ca') { if (aLanguageCode === 'ca') {
return ca; return ca;
} else if (aLanguageCode === 'de') { } else if (aLanguageCode === 'de') {

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

@ -40,7 +40,7 @@ import {
closeOutline, closeOutline,
searchOutline searchOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { isFunction } from 'lodash'; import { isFunction, sample } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { EMPTY, Observable, merge, of } from 'rxjs'; import { EMPTY, Observable, merge, of } from 'rxjs';
import { import {
@ -106,14 +106,17 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
public dateRangeFormControl = new FormControl<string | null>(null); public dateRangeFormControl = new FormControl<string | null>(null);
public dateRangeOptions: DateRangeOption[] = []; public dateRangeOptions: DateRangeOption[] = [];
public holdings: PortfolioPosition[] = []; public holdings: PortfolioPosition[] = [];
public isLoading = { public isLoading = {
accounts: false, accounts: false,
assetProfiles: false, assetProfiles: false,
holdings: false, holdings: false,
quickLinks: false quickLinks: false
}; };
public isOpen = false; public isOpen = false;
public placeholder = $localize`Find account, holding or page...`; public placeholder: string;
public portfolioFilterFormControl = new FormControl<PortfolioFilterFormValue>( public portfolioFilterFormControl = new FormControl<PortfolioFilterFormValue>(
{ {
account: null, account: null,
@ -122,13 +125,16 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
tag: null tag: null
} }
); );
public searchFormControl = new FormControl(''); public searchFormControl = new FormControl('');
public searchResults: SearchResults = { public searchResults: SearchResults = {
accounts: [], accounts: [],
assetProfiles: [], assetProfiles: [],
holdings: [], holdings: [],
quickLinks: [] quickLinks: []
}; };
public tags: Filter[] = []; public tags: Filter[] = [];
protected readonly closed = output<void>(); protected readonly closed = output<void>();
@ -458,7 +464,15 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
holdings: true, holdings: true,
quickLinks: true quickLinks: true
}; };
this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap(); this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap();
this.placeholder = sample([
$localize`Find an account...`,
$localize`Find a holding...`,
$localize`Jump to a page...`
]);
this.searchResults = { this.searchResults = {
accounts: [], accounts: [],
assetProfiles: [], assetProfiles: [],
@ -471,6 +485,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
} }
this.searchFormControl.setValue(''); this.searchFormControl.setValue('');
setTimeout(() => { setTimeout(() => {
this.searchElement?.nativeElement?.focus(); this.searchElement?.nativeElement?.focus();
}); });
@ -481,6 +496,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
holdings: false, holdings: false,
quickLinks: false quickLinks: false
}; };
this.setIsOpen(true); this.setIsOpen(true);
this.dataService this.dataService

33
libs/ui/src/lib/benchmark/benchmark.component.html

@ -7,11 +7,22 @@
matSortDirection="asc" matSortDirection="asc"
[dataSource]="dataSource" [dataSource]="dataSource"
> >
<ng-container matColumnDef="name" sticky> <ng-container matColumnDef="icon" sticky>
<th *matHeaderCellDef class="px-2" mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<gf-entity-logo
[dataSource]="element.dataSource"
[symbol]="element.symbol"
[tooltip]="element.name"
/>
</td>
</ng-container>
<ng-container matColumnDef="name">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Name</ng-container> <ng-container i18n>Name</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-2 text-nowrap" mat-cell> <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<div class="text-truncate"> <div class="text-truncate">
{{ element?.name }} {{ element?.name }}
</div> </div>
@ -26,14 +37,14 @@
<ng-container matColumnDef="trend50d"> <ng-container matColumnDef="trend50d">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="d-none d-lg-table-cell px-2 text-right" class="d-none d-lg-table-cell px-1 text-right"
mat-header-cell mat-header-cell
> >
<ng-container i18n>50-Day Trend</ng-container> <ng-container i18n>50-Day Trend</ng-container>
</th> </th>
<td <td
*matCellDef="let element" *matCellDef="let element"
class="d-none d-lg-table-cell px-2" class="d-none d-lg-table-cell px-1"
mat-cell mat-cell
> >
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
@ -55,14 +66,14 @@
<ng-container matColumnDef="trend200d"> <ng-container matColumnDef="trend200d">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="d-none d-lg-table-cell px-2 text-right" class="d-none d-lg-table-cell px-1 text-right"
mat-header-cell mat-header-cell
> >
<ng-container i18n>200-Day Trend</ng-container> <ng-container i18n>200-Day Trend</ng-container>
</th> </th>
<td <td
*matCellDef="let element" *matCellDef="let element"
class="d-none d-lg-table-cell px-2" class="d-none d-lg-table-cell px-1"
mat-cell mat-cell
> >
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
@ -84,14 +95,14 @@
<ng-container matColumnDef="date"> <ng-container matColumnDef="date">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="d-none d-lg-table-cell px-2 text-right" class="d-none d-lg-table-cell px-1 text-right"
mat-header-cell mat-header-cell
> >
<ng-container i18n>Last All Time High</ng-container> <ng-container i18n>Last All Time High</ng-container>
</th> </th>
<td <td
*matCellDef="let element" *matCellDef="let element"
class="d-none d-lg-table-cell px-2" class="d-none d-lg-table-cell px-1"
mat-cell mat-cell
> >
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
@ -109,7 +120,7 @@
<ng-container matColumnDef="change"> <ng-container matColumnDef="change">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="px-2 justify-content-end" class="justify-content-end px-1"
mat-header-cell mat-header-cell
mat-sort-header="performances.allTimeHigh.performancePercent" mat-sort-header="performances.allTimeHigh.performancePercent"
> >
@ -118,7 +129,7 @@
> >
<span class="d-block d-sm-none text-nowrap" i18n>from ATH</span> <span class="d-block d-sm-none text-nowrap" i18n>from ATH</span>
</th> </th>
<td *matCellDef="let element" class="px-2 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
@if (isNumber(element?.performances?.allTimeHigh?.performancePercent)) { @if (isNumber(element?.performances?.allTimeHigh?.performancePercent)) {
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"

4
libs/ui/src/lib/benchmark/benchmark.component.ts

@ -36,6 +36,7 @@ import { ellipsisHorizontal, trashOutline } from 'ionicons/icons';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfEntityLogoComponent } from '../entity-logo/entity-logo.component';
import { translate } from '../i18n'; import { translate } from '../i18n';
import { GfTrendIndicatorComponent } from '../trend-indicator/trend-indicator.component'; import { GfTrendIndicatorComponent } from '../trend-indicator/trend-indicator.component';
import { GfValueComponent } from '../value/value.component'; import { GfValueComponent } from '../value/value.component';
@ -45,6 +46,7 @@ import { BenchmarkDetailDialogParams } from './benchmark-detail-dialog/interface
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
GfEntityLogoComponent,
GfTrendIndicatorComponent, GfTrendIndicatorComponent,
GfValueComponent, GfValueComponent,
IonIcon, IonIcon,
@ -65,6 +67,7 @@ export class GfBenchmarkComponent {
public readonly deviceType = input.required<string>(); public readonly deviceType = input.required<string>();
public readonly hasPermissionToDeleteItem = input<boolean>(); public readonly hasPermissionToDeleteItem = input<boolean>();
public readonly locale = input(getLocale()); public readonly locale = input(getLocale());
public readonly showIcon = input(false);
public readonly showSymbol = input(true); public readonly showSymbol = input(true);
public readonly user = input<User>(); public readonly user = input<User>();
@ -75,6 +78,7 @@ export class GfBenchmarkComponent {
protected readonly dataSource = new MatTableDataSource<Benchmark>([]); protected readonly dataSource = new MatTableDataSource<Benchmark>([]);
protected readonly displayedColumns = computed(() => { protected readonly displayedColumns = computed(() => {
return [ return [
...(this.showIcon() ? ['icon'] : []),
'name', 'name',
...(this.user()?.settings?.isExperimentalFeatures ...(this.user()?.settings?.isExperimentalFeatures
? ['trend50d', 'trend200d'] ? ['trend50d', 'trend200d']

6
libs/ui/src/lib/page-tabs/page-tabs.component.scss

@ -3,7 +3,7 @@
:host { :host {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: calc(100svh - var(--mat-toolbar-standard-height)); height: 100%;
width: 100%; width: 100%;
@include mat.tabs-overrides( @include mat.tabs-overrides(
@ -23,6 +23,10 @@
.mat-mdc-tab-nav-panel { .mat-mdc-tab-nav-panel {
padding: 2rem 0; padding: 2rem 0;
@media (max-width: 575.98px) {
padding: 1rem 0;
}
} }
} }

857
package-lock.json

File diff suppressed because it is too large

22
package.json

@ -157,16 +157,16 @@
"@eslint/js": "9.35.0", "@eslint/js": "9.35.0",
"@nestjs/schematics": "11.1.0", "@nestjs/schematics": "11.1.0",
"@nestjs/testing": "11.1.19", "@nestjs/testing": "11.1.19",
"@nx/angular": "22.7.1", "@nx/angular": "22.7.2",
"@nx/eslint-plugin": "22.7.1", "@nx/eslint-plugin": "22.7.2",
"@nx/jest": "22.7.1", "@nx/jest": "22.7.2",
"@nx/js": "22.7.1", "@nx/js": "22.7.2",
"@nx/module-federation": "22.7.1", "@nx/module-federation": "22.7.2",
"@nx/nest": "22.7.1", "@nx/nest": "22.7.2",
"@nx/node": "22.7.1", "@nx/node": "22.7.2",
"@nx/storybook": "22.7.1", "@nx/storybook": "22.7.2",
"@nx/web": "22.7.1", "@nx/web": "22.7.2",
"@nx/workspace": "22.7.1", "@nx/workspace": "22.7.2",
"@schematics/angular": "21.2.6", "@schematics/angular": "21.2.6",
"@storybook/addon-docs": "10.1.10", "@storybook/addon-docs": "10.1.10",
"@storybook/addon-themes": "10.1.10", "@storybook/addon-themes": "10.1.10",
@ -193,7 +193,7 @@
"jest": "30.2.0", "jest": "30.2.0",
"jest-environment-jsdom": "30.2.0", "jest-environment-jsdom": "30.2.0",
"jest-preset-angular": "16.0.0", "jest-preset-angular": "16.0.0",
"nx": "22.7.1", "nx": "22.7.2",
"prettier": "3.8.3", "prettier": "3.8.3",
"prettier-plugin-organize-attributes": "1.0.0", "prettier-plugin-organize-attributes": "1.0.0",
"prisma": "7.8.0", "prisma": "7.8.0",

Loading…
Cancel
Save