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 the icon column to the benchmark component
- Added support for the `DIRECT_URL` environment variable to enable direct database connections
### 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
- Randomized the placeholder in the assistant
- 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
- 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
- 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 `Nx` from version `22.7.1` to `22.7.2`
### 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 { 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 { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import {
@ -33,6 +36,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatDialogModule } from '@angular/material/dialog';
import { PageEvent } from '@angular/material/paginator';
import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
@ -93,6 +97,8 @@ export class GfAccountDetailDialogComponent implements OnInit {
protected isLoadingActivities: boolean;
protected isLoadingChart: boolean;
protected name: string | null;
protected pageIndex = 0;
protected pageSize = DEFAULT_PAGE_SIZE;
protected platformName: string;
protected sortColumn = 'date';
protected sortDirection: SortDirection = 'desc';
@ -133,6 +139,21 @@ export class GfAccountDetailDialogComponent implements OnInit {
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) {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink,
@ -148,15 +169,6 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.dialogRef.close();
}
protected onAddAccountBalance(accountBalance: CreateAccountBalanceDto) {
this.dataService
.postAccountBalance(accountBalance)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.initialize();
});
}
protected onDeleteAccountBalance(aId: string) {
this.dataService
.deleteAccountBalance(aId)
@ -287,8 +299,10 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.dataService
.fetchActivities({
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }],
skip: this.pageIndex * this.pageSize,
sortColumn: this.sortColumn,
sortDirection: this.sortDirection
sortDirection: this.sortDirection,
take: this.pageSize
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ activities, count }) => {

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

@ -120,6 +120,8 @@
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale"
[pageIndex]="pageIndex"
[pageSize]="pageSize"
[showAccountColumn]="false"
[showActions]="
!data.hasImpersonationId &&
@ -133,6 +135,7 @@
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()"
(pageChanged)="onChangePage($event)"
(sortChanged)="onSortChanged($event)"
/>
</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 mb-3">
<h1 class="flex-grow-1 m-0" mat-dialog-title>
{{ assetProfile?.name ?? data.symbol }}
<span>{{ assetProfile?.name ?? data.symbol }}</span>
</h1>
<button
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,
Component,
DestroyRef,
inject,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -84,33 +85,33 @@ import ms, { StringValue } from 'ms';
templateUrl: './admin-overview.html'
})
export class GfAdminOverviewComponent implements OnInit {
public activitiesCount: number;
public couponDuration: StringValue = '14 days';
public couponsDataSource = new MatTableDataSource<Coupon>();
public couponsDisplayedColumns = ['code', 'duration', 'actions'];
public hasPermissionForSubscription: boolean;
public hasPermissionForSystemMessage: boolean;
public hasPermissionToSyncDemoUserAccount: boolean;
public hasPermissionToToggleReadOnlyMode: boolean;
public info: InfoItem;
public isDataGatheringEnabled: boolean;
public permissions = permissions;
public systemMessage: SystemMessage;
public userCount: number;
public user: User;
public version: string;
public constructor(
private adminService: AdminService,
private cacheService: CacheService,
private changeDetectorRef: ChangeDetectorRef,
private clipboard: Clipboard,
private dataService: DataService,
private destroyRef: DestroyRef,
private notificationService: NotificationService,
private snackBar: MatSnackBar,
private userService: UserService
) {
protected activitiesCount: number;
protected couponDuration: StringValue = '14 days';
protected readonly couponsDataSource = new MatTableDataSource<Coupon>();
protected readonly couponsDisplayedColumns = ['code', 'duration', 'actions'];
protected hasPermissionForSubscription: boolean;
protected hasPermissionForSystemMessage: boolean;
protected hasPermissionToSyncDemoUserAccount: boolean;
protected hasPermissionToToggleReadOnlyMode: boolean;
protected readonly info: InfoItem;
protected isDataGatheringEnabled: boolean;
protected readonly permissions = permissions;
protected systemMessage: SystemMessage;
protected userCount: number;
protected user: User;
protected version: string;
private readonly adminService = inject(AdminService);
private readonly cacheService = inject(CacheService);
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly clipboard = inject(Clipboard);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly notificationService = inject(NotificationService);
private readonly snackBar = inject(MatSnackBar);
private readonly userService = inject(UserService);
public constructor() {
this.info = this.dataService.fetchInfo();
this.userService.stateChanged
@ -150,7 +151,7 @@ export class GfAdminOverviewComponent implements OnInit {
});
}
public get activitiesCountPerUser() {
protected get activitiesCountPerUser() {
if (!this.activitiesCount || !this.userCount) {
return undefined;
}
@ -169,7 +170,7 @@ export class GfAdminOverviewComponent implements OnInit {
this.fetchAdminData();
}
public formatDistanceToNow(aDateString: string) {
protected formatDistanceToNow(aDateString: string) {
if (aDateString) {
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), {
addSuffix: true
@ -184,7 +185,7 @@ export class GfAdminOverviewComponent implements OnInit {
return '';
}
public formatStringValue(aStringValue: StringValue) {
protected formatStringValue(aStringValue: StringValue) {
return formatDistanceToNowStrict(
addMilliseconds(new Date(), ms(aStringValue)),
{
@ -193,7 +194,7 @@ export class GfAdminOverviewComponent implements OnInit {
);
}
public onAddCoupon() {
protected onAddCoupon() {
const newCoupon: Coupon = {
code: `${ghostfolioPrefix}${this.generateCouponCode(14)}`,
duration: this.couponDuration
@ -204,11 +205,11 @@ export class GfAdminOverviewComponent implements OnInit {
this.saveCoupons({ coupons, codeToCopy: newCoupon.code });
}
public onChangeCouponDuration(aCouponDuration: StringValue) {
protected onChangeCouponDuration(aCouponDuration: StringValue) {
this.couponDuration = aCouponDuration;
}
public onDeleteCoupon(aCouponCode: string) {
protected onDeleteCoupon(aCouponCode: string) {
this.notificationService.confirm({
confirmFn: () => {
const coupons = this.couponsDataSource.data.filter(({ code }) => {
@ -222,7 +223,7 @@ export class GfAdminOverviewComponent implements OnInit {
});
}
public onDeleteSystemMessage() {
protected onDeleteSystemMessage() {
this.notificationService.confirm({
confirmFn: () => {
this.putAdminSetting({
@ -235,14 +236,14 @@ export class GfAdminOverviewComponent implements OnInit {
});
}
public onEnableDataGatheringChange(aEvent: MatSlideToggleChange) {
protected onEnableDataGatheringChange(aEvent: MatSlideToggleChange) {
this.putAdminSetting({
key: PROPERTY_IS_DATA_GATHERING_ENABLED,
value: aEvent.checked ? undefined : false
});
}
public onFlushCache() {
protected onFlushCache() {
this.notificationService.confirm({
confirmFn: () => {
this.cacheService
@ -259,21 +260,21 @@ export class GfAdminOverviewComponent implements OnInit {
});
}
public onEnableUserSignupModeChange(aEvent: MatSlideToggleChange) {
protected onEnableUserSignupModeChange(aEvent: MatSlideToggleChange) {
this.putAdminSetting({
key: PROPERTY_IS_USER_SIGNUP_ENABLED,
value: aEvent.checked ? undefined : false
});
}
public onReadOnlyModeChange(aEvent: MatSlideToggleChange) {
protected onReadOnlyModeChange(aEvent: MatSlideToggleChange) {
this.putAdminSetting({
key: PROPERTY_IS_READ_ONLY_MODE,
value: aEvent.checked ? true : undefined
});
}
public onSetSystemMessage() {
protected onSetSystemMessage() {
const systemMessage = prompt(
$localize`Please set your system message:`,
JSON.stringify(
@ -293,7 +294,7 @@ export class GfAdminOverviewComponent implements OnInit {
}
}
public onSyncDemoUserAccount() {
protected onSyncDemoUserAccount() {
this.adminService
.syncDemoUserAccount()
.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>
<gf-value
class="d-inline-block justify-content-end"
[locale]="locale"
[locale]="locale()"
[value]="element.accountCount"
/>
</td>

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

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

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

@ -10,10 +10,12 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
DestroyRef,
Input,
inject,
input,
OnInit,
ViewChild
viewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
@ -52,26 +54,33 @@ import { CreateOrUpdateTagDialogParams } from './create-or-update-tag-dialog/int
templateUrl: './admin-tag.component.html'
})
export class GfAdminTagComponent implements OnInit {
@Input() locale = getLocale();
@ViewChild(MatSort) sort: MatSort;
public dataSource = new MatTableDataSource<Tag>();
public deviceType: string;
public displayedColumns = ['name', 'userId', 'activities', 'actions'];
public tags: Tag[];
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceDetectorService: DeviceDetectorService,
private dialog: MatDialog,
private notificationService: NotificationService,
private route: ActivatedRoute,
private router: Router,
private userService: UserService
) {
public readonly locale = input(getLocale());
protected dataSource = new MatTableDataSource<Tag>();
protected readonly displayedColumns = [
'name',
'userId',
'activities',
'actions'
];
protected tags: Tag[];
private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
private readonly sort = viewChild.required(MatSort);
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly notificationService = inject(NotificationService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
this.route.queryParams
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
@ -83,7 +92,9 @@ export class GfAdminTagComponent implements OnInit {
return id === params['tagId'];
});
if (tag) {
this.openUpdateTagDialog(tag);
}
} else {
this.router.navigate(['.'], { relativeTo: this.route });
}
@ -94,12 +105,10 @@ export class GfAdminTagComponent implements OnInit {
}
public ngOnInit() {
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
this.fetchTags();
}
public onDeleteTag(aId: string) {
protected onDeleteTag(aId: string) {
this.notificationService.confirm({
confirmFn: () => {
this.deleteTag(aId);
@ -109,7 +118,7 @@ export class GfAdminTagComponent implements OnInit {
});
}
public onUpdateTag({ id }: Tag) {
protected onUpdateTag({ id }: Tag) {
this.router.navigate([], {
queryParams: { editTagDialog: true, tagId: id }
});
@ -139,7 +148,7 @@ export class GfAdminTagComponent implements OnInit {
this.tags = tags;
this.dataSource = new MatTableDataSource(this.tags);
this.dataSource.sort = this.sort;
this.dataSource.sort = this.sort();
this.dataSource.sortingDataAccessor = get;
this.dataService.updateInfo();
@ -153,14 +162,9 @@ export class GfAdminTagComponent implements OnInit {
GfCreateOrUpdateTagDialogComponent,
CreateOrUpdateTagDialogParams
>(GfCreateOrUpdateTagDialogComponent, {
data: {
tag: {
id: null,
name: null
}
},
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
data: {} satisfies CreateOrUpdateTagDialogParams,
height: this.deviceType() === 'mobile' ? '98vh' : undefined,
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
dialogRef
@ -197,9 +201,9 @@ export class GfAdminTagComponent implements OnInit {
id,
name
}
},
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
} satisfies CreateOrUpdateTagDialogParams,
height: this.deviceType() === 'mobile' ? '98vh' : undefined,
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
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
) {
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
};
if (this.data.tag.id) {
if (this.data.tag?.id) {
(tag as UpdateTagDto).id = this.data.tag.id;
await validateObjectForForm({
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()"
(ngSubmit)="onSubmit()"
>
@if (data.tag.id) {
@if (data.tag?.id) {
<h1 i18n mat-dialog-title>Update tag</h1>
} @else {
<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';
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 { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { DEFAULT_PAGE_SIZE, locale } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import {
getDateFnsLocale,
@ -25,9 +28,11 @@ import { CommonModule } from '@angular/common';
import {
ChangeDetectorRef,
Component,
computed,
DestroyRef,
inject,
OnInit,
ViewChild
viewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
@ -76,37 +81,42 @@ import { switchMap, tap } from 'rxjs/operators';
templateUrl: './admin-users.html'
})
export class GfAdminUsersComponent implements OnInit {
@ViewChild(MatPaginator) paginator: MatPaginator;
public dataSource = new MatTableDataSource<AdminUsersResponse['users'][0]>();
public defaultDateFormat: string;
public deviceType: string;
public displayedColumns: string[] = [];
public getEmojiFlag = getEmojiFlag;
public hasPermissionForSubscription: boolean;
public hasPermissionToImpersonateAllUsers: boolean;
public info: InfoItem;
public isLoading = false;
public pageSize = DEFAULT_PAGE_SIZE;
public routerLinkAdminControlUsers =
protected dataSource = new MatTableDataSource<
AdminUsersResponse['users'][0]
>();
protected defaultDateFormat: string;
protected displayedColumns: string[] = [];
protected readonly getEmojiFlag = getEmojiFlag;
protected hasPermissionForSubscription: boolean;
protected hasPermissionToImpersonateAllUsers: boolean;
protected info: InfoItem;
protected isLoading = false;
protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected readonly routerLinkAdminControlUsers =
internalRoutes.adminControl.subRoutes.users.routerLink;
public totalItems = 0;
public user: User;
protected totalItems = 0;
protected user: User;
private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
private readonly paginator = viewChild.required(MatPaginator);
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceDetectorService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private notificationService: NotificationService,
private route: ActivatedRoute,
private router: Router,
private userService: UserService
) {
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
private readonly adminService = inject(AdminService);
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly notificationService = inject(NotificationService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
this.info = this.dataService.fetchInfo();
this.hasPermissionForSubscription = hasPermission(
@ -176,7 +186,7 @@ export class GfAdminUsersComponent implements OnInit {
this.fetchUsers();
}
public formatDistanceToNow(aDateString: string) {
protected formatDistanceToNow(aDateString: string) {
if (aDateString) {
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), {
addSuffix: true,
@ -192,13 +202,13 @@ export class GfAdminUsersComponent implements OnInit {
return '';
}
public onChangePage(page: PageEvent) {
protected onChangePage(page: PageEvent) {
this.fetchUsers({
pageIndex: page.pageIndex
});
}
public onDeleteUser(aId: string) {
protected onDeleteUser(aId: string) {
this.notificationService.confirm({
confirmFn: () => {
this.dataService
@ -216,7 +226,7 @@ export class GfAdminUsersComponent implements OnInit {
});
}
public onGenerateAccessToken(aUserId: string) {
protected onGenerateAccessToken(aUserId: string) {
this.notificationService.confirm({
confirmFn: () => {
this.dataService
@ -241,7 +251,7 @@ export class GfAdminUsersComponent implements OnInit {
});
}
public onImpersonateUser(aId: string) {
protected onImpersonateUser(aId: string) {
if (aId) {
this.impersonationStorageService.setId(aId);
} else {
@ -251,7 +261,7 @@ export class GfAdminUsersComponent implements OnInit {
window.location.reload();
}
public onOpenUserDetailDialog(userId: string) {
protected onOpenUserDetailDialog(userId: string) {
this.router.navigate(
internalRoutes.adminControl.subRoutes.users.routerLink.concat(userId)
);
@ -260,8 +270,8 @@ export class GfAdminUsersComponent implements OnInit {
private fetchUsers({ pageIndex }: { pageIndex: number } = { pageIndex: 0 }) {
this.isLoading = true;
if (pageIndex === 0 && this.paginator) {
this.paginator.pageIndex = 0;
if (pageIndex === 0 && this.paginator()) {
this.paginator().pageIndex = 0;
}
this.adminService
@ -283,18 +293,19 @@ export class GfAdminUsersComponent implements OnInit {
private openUserDetailDialog(aUserId: string) {
const dialogRef = this.dialog.open<
GfUserDetailDialogComponent,
UserDetailDialogParams
UserDetailDialogParams,
UserDetailDialogResult
>(GfUserDetailDialogComponent, {
autoFocus: false,
data: {
currentUserId: this.user?.id,
deviceType: this.deviceType,
deviceType: this.deviceType(),
hasPermissionForSubscription: this.hasPermissionForSubscription,
locale: this.user?.settings?.locale,
locale: this.user?.settings?.locale ?? locale,
userId: aUserId
},
height: this.deviceType === 'mobile' ? '98vh' : '60vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
} satisfies UserDetailDialogParams,
height: this.deviceType() === 'mobile' ? '98vh' : '60vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
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"
>
<span i18n>Performance</span>
@if (user?.subscription?.type === 'Basic') {
@if (user()?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</div>
@ -18,12 +18,12 @@
<mat-label i18n>Compare with...</mat-label>
<mat-select
name="benchmark"
[disabled]="user?.subscription?.type === 'Basic'"
[value]="benchmark?.id"
[disabled]="user()?.subscription?.type === 'Basic'"
[value]="benchmark()?.id"
(selectionChange)="onChangeBenchmark($event.value)"
>
<mat-option [value]="null" />
@for (symbolProfile of benchmarks; track symbolProfile) {
@for (symbolProfile of benchmarks(); track symbolProfile) {
<mat-option [value]="symbolProfile.id">{{
symbolProfile.name
}}</mat-option>
@ -41,7 +41,7 @@
</div>
</div>
<div class="chart-container">
@if (isLoading) {
@if (isLoading()) {
<ngx-skeleton-loader
animation="pulse"
[theme]="{
@ -53,6 +53,6 @@
<canvas
#chartCanvas
class="h-100"
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
[ngStyle]="{ display: isLoading() ? 'none' : 'block' }"
></canvas>
</div>

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

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

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

@ -1,14 +1,14 @@
<mat-toolbar class="px-0">
@if (user) {
<div class="d-flex h-100 logo-container" [class.filled]="hasTabs">
@if (user()) {
<div class="d-flex h-100 logo-container" [class.filled]="hasTabs()">
<a
class="align-items-center h-100 justify-content-start px-2 px-sm-3 rounded-0"
mat-button
[class.w-100]="hasTabs"
[class.w-100]="hasTabs()"
[routerLink]="['/']"
(click)="onLogoClick()"
>
<gf-logo [label]="pageTitle" />
<gf-logo [label]="pageTitle()" />
</a>
</div>
<span class="gf-spacer"></span>
@ -20,11 +20,11 @@
mat-button
[class]="{
'font-weight-bold':
currentRoute === internalRoutes.home.path ||
currentRoute === internalRoutes.zen.path,
currentRoute() === internalRoutes.home.path ||
currentRoute() === internalRoutes.zen.path,
'text-decoration-underline':
currentRoute === internalRoutes.home.path ||
currentRoute === internalRoutes.zen.path
currentRoute() === internalRoutes.home.path ||
currentRoute() === internalRoutes.zen.path
}"
[routerLink]="['/']"
>Overview</a
@ -36,9 +36,10 @@
i18n
mat-button
[class]="{
'font-weight-bold': currentRoute === internalRoutes.portfolio.path,
'font-weight-bold':
currentRoute() === internalRoutes.portfolio.path,
'text-decoration-underline':
currentRoute === internalRoutes.portfolio.path
currentRoute() === internalRoutes.portfolio.path
}"
[routerLink]="routerLinkPortfolio"
>Portfolio</a
@ -50,9 +51,9 @@
i18n
mat-button
[class]="{
'font-weight-bold': currentRoute === internalRoutes.accounts.path,
'font-weight-bold': currentRoute() === internalRoutes.accounts.path,
'text-decoration-underline':
currentRoute === internalRoutes.accounts.path
currentRoute() === internalRoutes.accounts.path
}"
[routerLink]="routerLinkAccounts"
>Accounts</a
@ -66,9 +67,9 @@
mat-button
[class]="{
'font-weight-bold':
currentRoute === internalRoutes.adminControl.path,
currentRoute() === internalRoutes.adminControl.path,
'text-decoration-underline':
currentRoute === internalRoutes.adminControl.path
currentRoute() === internalRoutes.adminControl.path
}"
[routerLink]="routerLinkAdminControl"
>Admin Control</a
@ -81,29 +82,29 @@
i18n
mat-button
[class]="{
'font-weight-bold': currentRoute === routeResources,
'text-decoration-underline': currentRoute === routeResources
'font-weight-bold': currentRoute() === routeResources,
'text-decoration-underline': currentRoute() === routeResources
}"
[routerLink]="routerLinkResources"
>Resources</a
>
</li>
@if (
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
hasPermissionForSubscription && user()?.subscription?.type === 'Basic'
) {
<li class="list-inline-item">
<a
class="d-none d-sm-block rounded"
mat-button
[class]="{
'font-weight-bold': currentRoute === routePricing,
'text-decoration-underline': currentRoute === routePricing
'font-weight-bold': currentRoute() === routePricing,
'text-decoration-underline': currentRoute() === routePricing
}"
[routerLink]="routerLinkPricing"
>
<span class="align-items-center d-flex">
<span i18n>Pricing</span>
@if (currentRoute !== routePricing && hasPromotion) {
@if (currentRoute() !== routePricing && hasPromotion()) {
<span class="badge badge-warning ml-1">%</span>
}
</span>
@ -116,8 +117,8 @@
i18n
mat-button
[class]="{
'font-weight-bold': currentRoute === routeAbout,
'text-decoration-underline': currentRoute === routeAbout
'font-weight-bold': currentRoute() === routeAbout,
'text-decoration-underline': currentRoute() === routeAbout
}"
[routerLink]="routerLinkAbout"
>About</a
@ -131,7 +132,7 @@
matBadge="&NoBreak;"
matBadgeSize="small"
matButton
[matBadgeHidden]="!hasFilters || !hasPermissionToChangeFilters"
[matBadgeHidden]="!hasFilters || !hasPermissionToChangeFilters()"
[matMenuTriggerFor]="assistantMenu"
[matMenuTriggerRestoreFocus]="false"
(menuOpened)="onOpenAssistant()"
@ -143,17 +144,19 @@
class="no-max-width"
xPosition="before"
[overlapTrigger]="true"
(closed)="assistantElement?.setIsOpen(false)"
(closed)="assistantElement()?.setIsOpen(false)"
>
<gf-assistant
#assistant
[deviceType]="deviceType"
[deviceType]="deviceType()"
[hasPermissionToAccessAdminControl]="
hasPermissionToAccessAdminControl
"
[hasPermissionToChangeDateRange]="hasPermissionToChangeDateRange"
[hasPermissionToChangeFilters]="hasPermissionToChangeFilters"
[user]="user"
[hasPermissionToChangeDateRange]="
hasPermissionToChangeDateRange()
"
[hasPermissionToChangeFilters]="hasPermissionToChangeFilters()"
[user]="user()"
(closed)="closeAssistant()"
(dateRangeChanged)="onDateRangeChange($event)"
(filtersChanged)="onFiltersChanged($event)"
@ -182,12 +185,13 @@
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
@if (
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
hasPermissionForSubscription &&
user()?.subscription?.type === 'Basic'
) {
<a class="d-flex" mat-menu-item [routerLink]="routerLinkPricing"
><span class="align-items-center d-flex"
><span>
@if (user.subscription.offer.isRenewal) {
@if (user().subscription.offer.isRenewal) {
<ng-container i18n>Renew Plan</ng-container>
} @else {
<ng-container i18n>Upgrade Plan</ng-container>
@ -199,7 +203,7 @@
></a>
<hr class="m-0" />
}
@if (user?.access?.length > 0) {
@if (user()?.access?.length > 0) {
<button mat-menu-item (click)="impersonateAccount(null)">
<span class="align-items-center d-flex">
<ion-icon
@ -213,7 +217,7 @@
<span i18n>Me</span>
</span>
</button>
@for (accessItem of user?.access; track accessItem) {
@for (accessItem of user()?.access; track accessItem) {
<button mat-menu-item (click)="impersonateAccount(accessItem.id)">
<span class="align-items-center d-flex">
<ion-icon
@ -240,8 +244,8 @@
i18n
mat-menu-item
[class.font-weight-bold]="
currentRoute === internalRoutes.home.path ||
currentRoute === internalRoutes.zen.path
currentRoute() === internalRoutes.home.path ||
currentRoute() === internalRoutes.zen.path
"
[routerLink]="['/']"
>Overview</a
@ -251,7 +255,7 @@
i18n
mat-menu-item
[class.font-weight-bold]="
currentRoute === internalRoutes.portfolio.path
currentRoute() === internalRoutes.portfolio.path
"
[routerLink]="routerLinkPortfolio"
>Portfolio</a
@ -261,7 +265,7 @@
i18n
mat-menu-item
[class.font-weight-bold]="
currentRoute === internalRoutes.accounts.path
currentRoute() === internalRoutes.accounts.path
"
[routerLink]="routerLinkAccounts"
>Accounts</a
@ -270,7 +274,7 @@
i18n
mat-menu-item
[class.font-weight-bold]="
currentRoute === internalRoutes.account.path
currentRoute() === internalRoutes.account.path
"
[routerLink]="routerLinkAccount"
>My Ghostfolio</a
@ -281,7 +285,7 @@
i18n
mat-menu-item
[class.font-weight-bold]="
currentRoute === internalRoutes.adminControl.path
currentRoute() === internalRoutes.adminControl.path
"
[routerLink]="routerLinkAdminControl"
>Admin Control</a
@ -292,22 +296,23 @@
class="d-flex d-sm-none"
i18n
mat-menu-item
[class.font-weight-bold]="currentRoute === routeResources"
[class.font-weight-bold]="currentRoute() === routeResources"
[routerLink]="routerLinkResources"
>Resources</a
>
@if (
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
hasPermissionForSubscription &&
user()?.subscription?.type === 'Basic'
) {
<a
class="d-flex d-sm-none"
mat-menu-item
[class.font-weight-bold]="currentRoute === routePricing"
[class.font-weight-bold]="currentRoute() === routePricing"
[routerLink]="routerLinkPricing"
>
<span class="align-items-center d-flex">
<span i18n>Pricing</span>
@if (currentRoute !== routePricing && hasPromotion) {
@if (currentRoute() !== routePricing && hasPromotion()) {
<span class="badge badge-warning ml-1">%</span>
}
</span>
@ -317,7 +322,7 @@
class="d-flex d-sm-none"
i18n
mat-menu-item
[class.font-weight-bold]="currentRoute === routeAbout"
[class.font-weight-bold]="currentRoute() === routeAbout"
[routerLink]="routerLinkAbout"
>About Ghostfolio</a
>
@ -327,17 +332,17 @@
</li>
</ul>
}
@if (user === null) {
<div class="d-flex h-100 logo-container" [class.filled]="hasTabs">
@if (user() === null) {
<div class="d-flex h-100 logo-container" [class.filled]="hasTabs()">
<a
class="align-items-center h-100 justify-content-start px-2 px-sm-3 rounded-0"
mat-button
[class.w-100]="hasTabs"
[class.w-100]="hasTabs()"
[routerLink]="['/']"
>
<gf-logo
[label]="pageTitle"
[showLabel]="currentRoute !== 'register'"
[label]="pageTitle()"
[showLabel]="currentRoute() !== 'register'"
/>
</a>
</div>
@ -349,8 +354,8 @@
i18n
mat-button
[class]="{
'font-weight-bold': currentRoute === routeFeatures,
'text-decoration-underline': currentRoute === routeFeatures
'font-weight-bold': currentRoute() === routeFeatures,
'text-decoration-underline': currentRoute() === routeFeatures
}"
[routerLink]="routerLinkFeatures"
>Features</a
@ -362,8 +367,8 @@
i18n
mat-button
[class]="{
'font-weight-bold': currentRoute === routeAbout,
'text-decoration-underline': currentRoute === routeAbout
'font-weight-bold': currentRoute() === routeAbout,
'text-decoration-underline': currentRoute() === routeAbout
}"
[routerLink]="routerLinkAbout"
>About</a
@ -375,14 +380,14 @@
class="d-sm-block rounded"
mat-button
[class]="{
'font-weight-bold': currentRoute === routePricing,
'text-decoration-underline': currentRoute === routePricing
'font-weight-bold': currentRoute() === routePricing,
'text-decoration-underline': currentRoute() === routePricing
}"
[routerLink]="routerLinkPricing"
>
<span class="align-items-center d-flex">
<span i18n>Pricing</span>
@if (currentRoute !== routePricing && hasPromotion) {
@if (currentRoute() !== routePricing && hasPromotion()) {
<span class="badge badge-warning ml-1">%</span>
}
</span>
@ -396,8 +401,8 @@
i18n
mat-button
[class]="{
'font-weight-bold': currentRoute === routeMarkets,
'text-decoration-underline': currentRoute === routeMarkets
'font-weight-bold': currentRoute() === routeMarkets,
'text-decoration-underline': currentRoute() === routeMarkets
}"
[routerLink]="routerLinkMarkets"
>Markets</a
@ -421,7 +426,7 @@
<ng-container i18n>Sign in</ng-container>
</button>
</li>
@if (currentRoute !== 'register' && hasPermissionToCreateUser) {
@if (currentRoute() !== 'register' && hasPermissionToCreateUser) {
<li class="list-inline-item ml-1">
<a
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 { LayoutService } from '@ghostfolio/client/core/layout.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
@ -24,12 +27,12 @@ import {
Component,
CUSTOM_ELEMENTS_SCHEMA,
DestroyRef,
EventEmitter,
HostListener,
Input,
inject,
input,
OnChanges,
Output,
ViewChild
output,
viewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatBadgeModule } from '@angular/material/badge';
@ -71,78 +74,67 @@ import { catchError } from 'rxjs/operators';
styleUrls: ['./header.component.scss']
})
export class GfHeaderComponent implements OnChanges {
@HostListener('window:keydown', ['$event'])
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();
}
}
@Input() currentRoute: string;
@Input() deviceType: string;
@Input() hasPermissionToChangeDateRange: boolean;
@Input() hasPermissionToChangeFilters: boolean;
@Input() hasPromotion: boolean;
@Input() hasTabs: boolean;
@Input() info: InfoItem;
@Input() pageTitle: string;
@Input() user: User;
@Output() signOut = new EventEmitter<void>();
@ViewChild('assistant') assistantElement: GfAssistantComponent;
@ViewChild('assistantTrigger') assistentMenuTriggerElement: MatMenuTrigger;
public hasFilters: boolean;
public hasImpersonationId: boolean;
public hasPermissionForAuthGoogle: boolean;
public hasPermissionForAuthOidc: boolean;
public hasPermissionForAuthToken: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean;
public hasPermissionToAccessAssistant: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasPermissionToCreateUser: boolean;
public impersonationId: string;
public internalRoutes = internalRoutes;
public isMenuOpen: boolean;
public routeAbout = publicRoutes.about.path;
public routeFeatures = publicRoutes.features.path;
public routeMarkets = publicRoutes.markets.path;
public routePricing = publicRoutes.pricing.path;
public routeResources = publicRoutes.resources.path;
public routerLinkAbout = publicRoutes.about.routerLink;
public routerLinkAccount = internalRoutes.account.routerLink;
public routerLinkAccounts = internalRoutes.accounts.routerLink;
public routerLinkAdminControl = internalRoutes.adminControl.routerLink;
public routerLinkFeatures = publicRoutes.features.routerLink;
public routerLinkMarkets = publicRoutes.markets.routerLink;
public routerLinkPortfolio = internalRoutes.portfolio.routerLink;
public routerLinkPricing = publicRoutes.pricing.routerLink;
public routerLinkRegister = publicRoutes.register.routerLink;
public routerLinkResources = publicRoutes.resources.routerLink;
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
) {
public readonly currentRoute = input.required<string>();
public readonly deviceType = input.required<string>();
public readonly hasPermissionToChangeDateRange = input.required<boolean>();
public readonly hasPermissionToChangeFilters = input.required<boolean>();
public readonly hasPromotion = input.required<boolean>();
public readonly hasTabs = input.required<boolean>();
public readonly info = input.required<InfoItem | undefined>();
public readonly pageTitle = input.required<string>();
public readonly user = input.required<User | undefined>();
public readonly signOut = output<void>();
protected readonly assistantElement =
viewChild.required<GfAssistantComponent>('assistant');
protected readonly assistentMenuTriggerElement =
viewChild.required<MatMenuTrigger>('assistantTrigger');
protected hasFilters: boolean;
protected hasImpersonationId: boolean;
protected hasPermissionForAuthGoogle: boolean;
protected hasPermissionForAuthOidc: boolean;
protected hasPermissionForAuthToken: boolean;
protected hasPermissionForSubscription: boolean;
protected hasPermissionToAccessAdminControl: boolean;
protected hasPermissionToAccessAssistant: boolean;
protected hasPermissionToAccessFearAndGreedIndex: boolean;
protected hasPermissionToCreateUser: boolean;
protected impersonationId: string;
protected readonly internalRoutes = internalRoutes;
protected isMenuOpen: boolean;
protected readonly routeAbout = publicRoutes.about.path;
protected readonly routeFeatures = publicRoutes.features.path;
protected readonly routeMarkets = publicRoutes.markets.path;
protected readonly routePricing = publicRoutes.pricing.path;
protected readonly routeResources = publicRoutes.resources.path;
protected readonly routerLinkAbout = publicRoutes.about.routerLink;
protected readonly routerLinkAccount = internalRoutes.account.routerLink;
protected readonly routerLinkAccounts = internalRoutes.accounts.routerLink;
protected readonly routerLinkAdminControl =
internalRoutes.adminControl.routerLink;
protected readonly routerLinkFeatures = publicRoutes.features.routerLink;
protected readonly routerLinkMarkets = publicRoutes.markets.routerLink;
protected readonly routerLinkPortfolio = internalRoutes.portfolio.routerLink;
protected readonly routerLinkPricing = publicRoutes.pricing.routerLink;
protected readonly routerLinkRegister = publicRoutes.register.routerLink;
protected readonly routerLinkResources = publicRoutes.resources.routerLink;
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly dialog = inject(MatDialog);
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly layoutService = inject(LayoutService);
private readonly notificationService = inject(NotificationService);
private readonly router = inject(Router);
private readonly settingsStorageService = inject(SettingsStorageService);
private readonly tokenStorageService = inject(TokenStorageService);
private readonly userService = inject(UserService);
public constructor() {
this.impersonationStorageService
.onChangeHasImpersonation()
.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() {
this.hasFilters = this.userService.hasFilters();
this.hasPermissionForAuthGoogle = hasPermission(
this.info?.globalPermissions,
this.info()?.globalPermissions,
permissions.enableAuthGoogle
);
this.hasPermissionForAuthOidc = hasPermission(
this.info?.globalPermissions,
this.info()?.globalPermissions,
permissions.enableAuthOidc
);
this.hasPermissionForAuthToken = hasPermission(
this.info?.globalPermissions,
this.info()?.globalPermissions,
permissions.enableAuthToken
);
this.hasPermissionForSubscription = hasPermission(
this.info?.globalPermissions,
this.info()?.globalPermissions,
permissions.enableSubscription
);
this.hasPermissionToAccessAdminControl = hasPermission(
this.user?.permissions,
this.user()?.permissions,
permissions.accessAdminControl
);
this.hasPermissionToAccessAssistant = hasPermission(
this.user?.permissions,
this.user()?.permissions,
permissions.accessAssistant
);
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.info?.globalPermissions,
this.info()?.globalPermissions,
permissions.enableFearAndGreedIndex
);
this.hasPermissionToCreateUser = hasPermission(
this.info?.globalPermissions,
this.info()?.globalPermissions,
permissions.createUserAccount
);
}
public closeAssistant() {
this.assistentMenuTriggerElement?.closeMenu();
protected closeAssistant() {
this.assistentMenuTriggerElement().closeMenu();
}
public impersonateAccount(aId: string) {
protected impersonateAccount(aId: string) {
if (aId) {
this.impersonationStorageService.setId(aId);
} else {
@ -220,7 +228,7 @@ export class GfHeaderComponent implements OnChanges {
window.location.reload();
}
public onDateRangeChange(dateRange: DateRange) {
protected onDateRangeChange(dateRange: DateRange) {
this.dataService
.putUserSetting({ dateRange })
.pipe(takeUntilDestroyed(this.destroyRef))
@ -232,7 +240,7 @@ export class GfHeaderComponent implements OnChanges {
});
}
public onFiltersChanged(filters: Filter[]) {
protected onFiltersChanged(filters: Filter[]) {
const userSetting: UpdateUserSettingDto = {};
for (const filter of filters) {
@ -260,32 +268,33 @@ export class GfHeaderComponent implements OnChanges {
});
}
public onLogoClick() {
if (['home', 'zen'].includes(this.currentRoute)) {
protected onLogoClick() {
if (['home', 'zen'].includes(this.currentRoute())) {
this.layoutService.getShouldReloadSubject().next();
}
}
public onMenuClosed() {
protected onMenuClosed() {
this.isMenuOpen = false;
}
public onMenuOpened() {
protected onMenuOpened() {
this.isMenuOpen = true;
}
public onOpenAssistant() {
this.assistantElement.initialize();
protected onOpenAssistant() {
this.assistantElement().initialize();
}
public onSignOut() {
this.signOut.next();
protected onSignOut() {
this.signOut.emit();
}
public openLoginDialog() {
protected openLoginDialog() {
const dialogRef = this.dialog.open<
GfLoginWithAccessTokenDialogComponent,
LoginWithAccessTokenDialogParams
LoginWithAccessTokenDialogParams,
LoginWithAccessTokenDialogResult
>(GfLoginWithAccessTokenDialogComponent, {
autoFocus: false,
data: {
@ -322,7 +331,7 @@ export class GfHeaderComponent implements OnChanges {
});
}
public setToken(aToken: string) {
private setToken(aToken: string) {
this.tokenStorageService.saveToken(
aToken,
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>
<div class="row">
<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()"
[hasPermissionToDeleteItem]="hasPermissionToDeleteWatchlistItem"
[locale]="user?.settings?.locale || undefined"
[showIcon]="true"
[user]="user"
(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;
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 { eyeOffOutline, eyeOutline } from 'ionicons/icons';
import { LoginWithAccessTokenDialogParams } from './interfaces/interfaces';
import {
LoginWithAccessTokenDialogParams,
LoginWithAccessTokenDialogResult
} from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -49,7 +52,10 @@ export class GfLoginWithAccessTokenDialogComponent {
public constructor(
@Inject(MAT_DIALOG_DATA) public data: LoginWithAccessTokenDialogParams,
public dialogRef: MatDialogRef<GfLoginWithAccessTokenDialogComponent>,
public dialogRef: MatDialogRef<
GfLoginWithAccessTokenDialogComponent,
LoginWithAccessTokenDialogResult
>,
private settingsStorageService: SettingsStorageService
) {
addIcons({ eyeOffOutline, eyeOutline });

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

@ -5,3 +5,8 @@ export interface UserDetailDialogParams {
locale: 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 { catchError } from 'rxjs/operators';
import { UserDetailDialogParams } from './interfaces/interfaces';
import {
UserDetailDialogParams,
UserDetailDialogResult
} from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -47,7 +50,10 @@ export class GfUserDetailDialogComponent implements OnInit {
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams,
private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfUserDetailDialogComponent>
public dialogRef: MatDialogRef<
GfUserDetailDialogComponent,
UserDetailDialogResult
>
) {
addIcons({
ellipsisVertical

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

@ -1,5 +1,5 @@
<div class="container">
<div class="mb-5 row">
<div class="row">
<div class="col">
<h1 class="h3 line-height-1 mb-4 text-center">
<span class="d-none d-sm-block"
@ -11,7 +11,7 @@
</h1>
<div class="row">
@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">
<mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-header>

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

@ -60,7 +60,7 @@
}
</div>
@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">
<span>{{ category.name }}</span>
@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()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ xRay: { categories, statistics } }) => {
this.categories = categories;
this.categories = categories.filter(({ rules }) => {
return rules?.length > 0;
});
this.inactiveRules = this.mergeInactiveRules(categories);
this.statistics = statistics;

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

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

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

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

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

@ -30,8 +30,8 @@
</div>
</div>
</div>
<div class="media">
<div class="mb-4 media">
<div class="media-body">
<h2 class="h5 mb-1 mt-0">Inflation Chart</h2>
<div class="mb-1">
<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>
<div class="overview-list">
@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>
<p class="mb-1">{{ item.description }}</p>
<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="mb-5 row">
<div class="row">
<div class="col">
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>
Discover Open Source Alternatives for Personal Finance Tools
@ -22,7 +22,7 @@
personalFinanceTool of personalFinanceTools;
track personalFinanceTool
) {
<mat-card appearance="outlined" class="mb-3">
<mat-card appearance="outlined" [class.mb-3]="!$last">
<mat-card-content class="p-0">
<div class="container p-0">
<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 {
display: flex;
height: calc(100svh - var(--mat-toolbar-standard-height));
overflow-y: auto;
padding-bottom: env(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()
@IsUrl({
protocols: ['https'],
protocols: ['http', 'https'],
require_protocol: true
})
url?: string;

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

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

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

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

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

@ -8,7 +8,7 @@ export class UpdatePlatformDto {
name: string;
@IsUrl({
protocols: ['https'],
protocols: ['http', 'https'],
require_protocol: true
})
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 {
@IsOptional()
@IsString()
value: string;
value?: string;
}

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

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

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

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

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

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

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

@ -7,11 +7,22 @@
matSortDirection="asc"
[dataSource]="dataSource"
>
<ng-container matColumnDef="name" sticky>
<th *matHeaderCellDef class="px-2" mat-header-cell mat-sort-header>
<ng-container matColumnDef="icon" sticky>
<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>
</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">
{{ element?.name }}
</div>
@ -26,14 +37,14 @@
<ng-container matColumnDef="trend50d">
<th
*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
>
<ng-container i18n>50-Day Trend</ng-container>
</th>
<td
*matCellDef="let element"
class="d-none d-lg-table-cell px-2"
class="d-none d-lg-table-cell px-1"
mat-cell
>
<div class="d-flex justify-content-end">
@ -55,14 +66,14 @@
<ng-container matColumnDef="trend200d">
<th
*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
>
<ng-container i18n>200-Day Trend</ng-container>
</th>
<td
*matCellDef="let element"
class="d-none d-lg-table-cell px-2"
class="d-none d-lg-table-cell px-1"
mat-cell
>
<div class="d-flex justify-content-end">
@ -84,14 +95,14 @@
<ng-container matColumnDef="date">
<th
*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
>
<ng-container i18n>Last All Time High</ng-container>
</th>
<td
*matCellDef="let element"
class="d-none d-lg-table-cell px-2"
class="d-none d-lg-table-cell px-1"
mat-cell
>
<div class="d-flex justify-content-end">
@ -109,7 +120,7 @@
<ng-container matColumnDef="change">
<th
*matHeaderCellDef
class="px-2 justify-content-end"
class="justify-content-end px-1"
mat-header-cell
mat-sort-header="performances.allTimeHigh.performancePercent"
>
@ -118,7 +129,7 @@
>
<span class="d-block d-sm-none text-nowrap" i18n>from ATH</span>
</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)) {
<gf-value
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 { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfEntityLogoComponent } from '../entity-logo/entity-logo.component';
import { translate } from '../i18n';
import { GfTrendIndicatorComponent } from '../trend-indicator/trend-indicator.component';
import { GfValueComponent } from '../value/value.component';
@ -45,6 +46,7 @@ import { BenchmarkDetailDialogParams } from './benchmark-detail-dialog/interface
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
GfEntityLogoComponent,
GfTrendIndicatorComponent,
GfValueComponent,
IonIcon,
@ -65,6 +67,7 @@ export class GfBenchmarkComponent {
public readonly deviceType = input.required<string>();
public readonly hasPermissionToDeleteItem = input<boolean>();
public readonly locale = input(getLocale());
public readonly showIcon = input(false);
public readonly showSymbol = input(true);
public readonly user = input<User>();
@ -75,6 +78,7 @@ export class GfBenchmarkComponent {
protected readonly dataSource = new MatTableDataSource<Benchmark>([]);
protected readonly displayedColumns = computed(() => {
return [
...(this.showIcon() ? ['icon'] : []),
'name',
...(this.user()?.settings?.isExperimentalFeatures
? ['trend50d', 'trend200d']

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

@ -3,7 +3,7 @@
:host {
display: flex;
flex-direction: column;
height: calc(100svh - var(--mat-toolbar-standard-height));
height: 100%;
width: 100%;
@include mat.tabs-overrides(
@ -23,6 +23,10 @@
.mat-mdc-tab-nav-panel {
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",
"@nestjs/schematics": "11.1.0",
"@nestjs/testing": "11.1.19",
"@nx/angular": "22.7.1",
"@nx/eslint-plugin": "22.7.1",
"@nx/jest": "22.7.1",
"@nx/js": "22.7.1",
"@nx/module-federation": "22.7.1",
"@nx/nest": "22.7.1",
"@nx/node": "22.7.1",
"@nx/storybook": "22.7.1",
"@nx/web": "22.7.1",
"@nx/workspace": "22.7.1",
"@nx/angular": "22.7.2",
"@nx/eslint-plugin": "22.7.2",
"@nx/jest": "22.7.2",
"@nx/js": "22.7.2",
"@nx/module-federation": "22.7.2",
"@nx/nest": "22.7.2",
"@nx/node": "22.7.2",
"@nx/storybook": "22.7.2",
"@nx/web": "22.7.2",
"@nx/workspace": "22.7.2",
"@schematics/angular": "21.2.6",
"@storybook/addon-docs": "10.1.10",
"@storybook/addon-themes": "10.1.10",
@ -193,7 +193,7 @@
"jest": "30.2.0",
"jest-environment-jsdom": "30.2.0",
"jest-preset-angular": "16.0.0",
"nx": "22.7.1",
"nx": "22.7.2",
"prettier": "3.8.3",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "7.8.0",

Loading…
Cancel
Save