Browse Source

Merge branch 'main' into task/upgrade-svgmap-to-version-2.19.3

pull/6729/head
Thomas Kaul 3 days ago
committed by GitHub
parent
commit
540af4839f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 13
      CHANGELOG.md
  2. 16
      apps/api/src/app/import/import.controller.ts
  3. 97
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  4. 74
      apps/client/src/app/components/admin-jobs/admin-jobs.component.ts
  5. 287
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  6. 4
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  7. 44
      apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.ts
  8. 8
      apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/interfaces/interfaces.ts
  9. 52
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  10. 2
      apps/client/src/app/components/home-watchlist/home-watchlist.html
  11. 16
      apps/client/src/app/pages/faq/overview/faq-overview-page.component.ts
  12. 2
      libs/ui/src/lib/accounts-table/accounts-table.component.ts
  13. 2
      libs/ui/src/lib/activities-table/activities-table.component.html
  14. 7
      libs/ui/src/lib/activities-table/activities-table.component.ts
  15. 15
      libs/ui/src/lib/assistant/assistant.component.ts
  16. 16
      package-lock.json
  17. 4
      package.json

13
CHANGELOG.md

@ -9,9 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Sorted the activity types alphabetically on the activities page (experimental)
- Sorted the asset classes of the assistant alphabetically
- Sorted the tags of the assistant alphabetically
- Upgraded `angular` from version `21.1.1` to `21.2.7` - Upgraded `angular` from version `21.1.1` to `21.2.7`
- Upgraded `Nx` from version `22.5.3` to `22.6.4` - Upgraded `Nx` from version `22.5.3` to `22.6.4`
- Upgraded `prettier` from version `3.8.1` to `3.8.2`
- Upgraded `svgmap` from version `2.19.2` to `2.19.3` - Upgraded `svgmap` from version `2.19.2` to `2.19.3`
- Upgraded `yahoo-finance2` from version `3.13.2` to `3.14.0`
### Fixed
- Fixed the missing value column of the accounts table component on mobile
## 2.254.0 - 2026-03-10 ## 2.254.0 - 2026-03-10
@ -6045,10 +6054,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Removed the alias from the user interface as a preparation to remove it from the `User` database schema - Removed the alias from the user interface as a preparation to remove it from the `User` database schema
- Removed the activities import limit for users with a subscription - Removed the activities import limit for users with a subscription
### Todo
- Rename the environment variable from `MAX_ORDERS_TO_IMPORT` to `MAX_ACTIVITIES_TO_IMPORT`
## 1.169.0 - 14.07.2022 ## 1.169.0 - 14.07.2022
### Added ### Added

16
apps/api/src/app/import/import.controller.ts

@ -3,6 +3,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { ImportResponse } from '@ghostfolio/common/interfaces'; import { ImportResponse } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -62,7 +63,7 @@ export class ImportController {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Premium' this.request.user.subscription.type === SubscriptionType.Premium
) { ) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER; maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
} }
@ -100,6 +101,17 @@ export class ImportController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<ImportResponse> { ): Promise<ImportResponse> {
let maxActivitiesToImport = this.configurationService.get(
'MAX_ACTIVITIES_TO_IMPORT'
);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Premium
) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
}
const activities = await this.importService.getDividends({ const activities = await this.importService.getDividends({
dataSource, dataSource,
symbol, symbol,
@ -107,6 +119,6 @@ export class ImportController {
userId: this.request.user.id userId: this.request.user.id
}); });
return { activities }; return { activities: activities.slice(0, maxActivitiesToImport) };
} }
} }

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

@ -12,7 +12,6 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances'; import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
@ -27,7 +26,7 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef, DestroyRef,
Inject, inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
@ -78,41 +77,42 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
templateUrl: 'account-detail-dialog.html' templateUrl: 'account-detail-dialog.html'
}) })
export class GfAccountDetailDialogComponent implements OnInit { export class GfAccountDetailDialogComponent implements OnInit {
public accountBalances: AccountBalancesResponse['balances']; protected accountBalances: AccountBalancesResponse['balances'];
public activities: OrderWithAccount[]; protected activitiesCount: number;
public activitiesCount: number; protected balance: number;
public balance: number; protected balancePrecision = 2;
public balancePrecision = 2; protected currency: string | null;
public currency: string; protected dataSource: MatTableDataSource<Activity>;
public dataSource: MatTableDataSource<Activity>; protected dividendInBaseCurrency: number;
public dividendInBaseCurrency: number; protected dividendInBaseCurrencyPrecision = 2;
public dividendInBaseCurrencyPrecision = 2; protected equity: number | null;
public equity: number; protected equityPrecision = 2;
public equityPrecision = 2; protected hasPermissionToDeleteAccountBalance: boolean;
public hasPermissionToDeleteAccountBalance: boolean; protected historicalDataItems: HistoricalDataItem[];
public historicalDataItems: HistoricalDataItem[]; protected holdings: PortfolioPosition[];
public holdings: PortfolioPosition[]; protected interestInBaseCurrency: number;
public interestInBaseCurrency: number; protected interestInBaseCurrencyPrecision = 2;
public interestInBaseCurrencyPrecision = 2; protected isLoadingActivities: boolean;
public isLoadingActivities: boolean; protected isLoadingChart: boolean;
public isLoadingChart: boolean; protected name: string | null;
public name: string; protected platformName: string;
public platformName: string; protected sortColumn = 'date';
public sortColumn = 'date'; protected sortDirection: SortDirection = 'desc';
public sortDirection: SortDirection = 'desc'; protected totalItems: number;
public totalItems: number; protected user: User;
public user: User; protected valueInBaseCurrency: number;
public valueInBaseCurrency: number;
protected readonly data = inject<AccountDetailDialogParams>(MAT_DIALOG_DATA);
public constructor(
private changeDetectorRef: ChangeDetectorRef, private readonly changeDetectorRef = inject(ChangeDetectorRef);
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams, private readonly dataService = inject(DataService);
private dataService: DataService, private readonly destroyRef = inject(DestroyRef);
private destroyRef: DestroyRef, private readonly dialogRef =
public dialogRef: MatDialogRef<GfAccountDetailDialogComponent>, inject<MatDialogRef<GfAccountDetailDialogComponent>>(MatDialogRef);
private router: Router, private readonly router = inject(Router);
private userService: UserService private readonly userService = inject(UserService);
) {
public constructor() {
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
@ -135,7 +135,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.initialize(); this.initialize();
} }
public onCloneActivity(aActivity: Activity) { protected onCloneActivity(aActivity: Activity) {
this.router.navigate( this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink, internalRoutes.portfolio.subRoutes.activities.routerLink,
{ {
@ -146,11 +146,11 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.dialogRef.close(); this.dialogRef.close();
} }
public onClose() { protected onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }
public onAddAccountBalance(accountBalance: CreateAccountBalanceDto) { protected onAddAccountBalance(accountBalance: CreateAccountBalanceDto) {
this.dataService this.dataService
.postAccountBalance(accountBalance) .postAccountBalance(accountBalance)
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -159,7 +159,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
}); });
} }
public onDeleteAccountBalance(aId: string) { protected onDeleteAccountBalance(aId: string) {
this.dataService this.dataService
.deleteAccountBalance(aId) .deleteAccountBalance(aId)
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -168,7 +168,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
}); });
} }
public onExport() { protected onExport() {
const activityIds = this.dataSource.data.map(({ id }) => { const activityIds = this.dataSource.data.map(({ id }) => {
return id; return id;
}); });
@ -180,7 +180,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
downloadAsFile({ downloadAsFile({
content: data, content: data,
fileName: `ghostfolio-export-${this.name fileName: `ghostfolio-export-${this.name
.replace(/\s+/g, '-') ?.replace(/\s+/g, '-')
.toLowerCase()}-${format( .toLowerCase()}-${format(
parseISO(data.meta.date), parseISO(data.meta.date),
'yyyyMMddHHmm' 'yyyyMMddHHmm'
@ -190,14 +190,14 @@ export class GfAccountDetailDialogComponent implements OnInit {
}); });
} }
public onSortChanged({ active, direction }: Sort) { protected onSortChanged({ active, direction }: Sort) {
this.sortColumn = active; this.sortColumn = active;
this.sortDirection = direction; this.sortDirection = direction;
this.fetchActivities(); this.fetchActivities();
} }
public onUpdateActivity(aActivity: Activity) { protected onUpdateActivity(aActivity: Activity) {
this.router.navigate( this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink, internalRoutes.portfolio.subRoutes.activities.routerLink,
{ {
@ -208,7 +208,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.dialogRef.close(); this.dialogRef.close();
} }
public showValuesInPercentage() { protected showValuesInPercentage() {
return ( return (
this.data.hasImpersonationId || this.user?.settings?.isRestrictedView this.data.hasImpersonationId || this.user?.settings?.isRestrictedView
); );
@ -330,7 +330,10 @@ export class GfAccountDetailDialogComponent implements OnInit {
next: ({ accountBalances, portfolioPerformance }) => { next: ({ accountBalances, portfolioPerformance }) => {
this.accountBalances = accountBalances.balances; this.accountBalances = accountBalances.balances;
if (portfolioPerformance.chart.length > 0) { if (
portfolioPerformance.chart &&
portfolioPerformance.chart.length > 0
) {
this.historicalDataItems = portfolioPerformance.chart.map( this.historicalDataItems = portfolioPerformance.chart.map(
({ date, netWorth, netWorthInPercentage }) => ({ ({ date, netWorth, netWorthInPercentage }) => ({
date, date,

74
apps/client/src/app/components/admin-jobs/admin-jobs.component.ts

@ -20,12 +20,13 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
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 { import {
FormBuilder, FormControl,
FormGroup, FormGroup,
FormsModule, FormsModule,
ReactiveFormsModule ReactiveFormsModule
@ -74,19 +75,25 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
templateUrl: './admin-jobs.html' templateUrl: './admin-jobs.html'
}) })
export class GfAdminJobsComponent implements OnInit { export class GfAdminJobsComponent implements OnInit {
@ViewChild(MatSort) sort: MatSort; protected readonly sort = viewChild.required(MatSort);
public DATA_GATHERING_QUEUE_PRIORITY_LOW = DATA_GATHERING_QUEUE_PRIORITY_LOW; protected readonly DATA_GATHERING_QUEUE_PRIORITY_HIGH =
public DATA_GATHERING_QUEUE_PRIORITY_HIGH =
DATA_GATHERING_QUEUE_PRIORITY_HIGH; DATA_GATHERING_QUEUE_PRIORITY_HIGH;
public DATA_GATHERING_QUEUE_PRIORITY_MEDIUM =
protected readonly DATA_GATHERING_QUEUE_PRIORITY_LOW =
DATA_GATHERING_QUEUE_PRIORITY_LOW;
protected readonly DATA_GATHERING_QUEUE_PRIORITY_MEDIUM =
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM; DATA_GATHERING_QUEUE_PRIORITY_MEDIUM;
public dataSource = new MatTableDataSource<AdminJobs['jobs'][0]>(); protected dataSource = new MatTableDataSource<AdminJobs['jobs'][0]>();
public defaultDateTimeFormat: string; protected defaultDateTimeFormat: string;
public filterForm: FormGroup;
protected readonly filterForm = new FormGroup({
status: new FormControl<JobStatus | null>(null)
});
public displayedColumns = [ protected readonly displayedColumns = [
'index', 'index',
'type', 'type',
'symbol', 'symbol',
@ -99,21 +106,20 @@ export class GfAdminJobsComponent implements OnInit {
'actions' 'actions'
]; ];
public hasPermissionToAccessBullBoard = false; protected hasPermissionToAccessBullBoard = false;
public isLoading = false; protected isLoading = false;
public statusFilterOptions = QUEUE_JOB_STATUS_LIST; protected readonly statusFilterOptions = QUEUE_JOB_STATUS_LIST;
private user: User; private user: User;
public constructor( private readonly adminService = inject(AdminService);
private adminService: AdminService, private readonly changeDetectorRef = inject(ChangeDetectorRef);
private changeDetectorRef: ChangeDetectorRef, private readonly destroyRef = inject(DestroyRef);
private destroyRef: DestroyRef, private readonly notificationService = inject(NotificationService);
private formBuilder: FormBuilder, private readonly tokenStorageService = inject(TokenStorageService);
private notificationService: NotificationService, private readonly userService = inject(UserService);
private tokenStorageService: TokenStorageService,
private userService: UserService public constructor() {
) {
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
@ -148,21 +154,17 @@ export class GfAdminJobsComponent implements OnInit {
} }
public ngOnInit() { public ngOnInit() {
this.filterForm = this.formBuilder.group({
status: []
});
this.filterForm.valueChanges this.filterForm.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
const currentFilter = this.filterForm.get('status').value; const currentFilter = this.filterForm.controls.status.value;
this.fetchJobs(currentFilter ? [currentFilter] : undefined); this.fetchJobs(currentFilter ? [currentFilter] : undefined);
}); });
this.fetchJobs(); this.fetchJobs();
} }
public onDeleteJob(aId: string) { protected onDeleteJob(aId: string) {
this.adminService this.adminService
.deleteJob(aId) .deleteJob(aId)
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -171,18 +173,18 @@ export class GfAdminJobsComponent implements OnInit {
}); });
} }
public onDeleteJobs() { protected onDeleteJobs() {
const currentFilter = this.filterForm.get('status').value; const currentFilter = this.filterForm.controls.status.value;
this.adminService this.adminService
.deleteJobs({ status: currentFilter ? [currentFilter] : undefined }) .deleteJobs({ status: currentFilter ? [currentFilter] : [] })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.fetchJobs(currentFilter ? [currentFilter] : undefined); this.fetchJobs(currentFilter ? [currentFilter] : undefined);
}); });
} }
public onExecuteJob(aId: string) { protected onExecuteJob(aId: string) {
this.adminService this.adminService
.executeJob(aId) .executeJob(aId)
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -191,7 +193,7 @@ export class GfAdminJobsComponent implements OnInit {
}); });
} }
public onOpenBullBoard() { protected onOpenBullBoard() {
const token = this.tokenStorageService.getToken(); const token = this.tokenStorageService.getToken();
document.cookie = [ document.cookie = [
@ -203,13 +205,13 @@ export class GfAdminJobsComponent implements OnInit {
window.open(BULL_BOARD_ROUTE, '_blank'); window.open(BULL_BOARD_ROUTE, '_blank');
} }
public onViewData(aData: AdminJobs['jobs'][0]['data']) { protected onViewData(aData: AdminJobs['jobs'][0]['data']) {
this.notificationService.alert({ this.notificationService.alert({
title: JSON.stringify(aData, null, ' ') title: JSON.stringify(aData, null, ' ')
}); });
} }
public onViewStacktrace(aStacktrace: AdminJobs['jobs'][0]['stacktrace']) { protected onViewStacktrace(aStacktrace: AdminJobs['jobs'][0]['stacktrace']) {
this.notificationService.alert({ this.notificationService.alert({
title: JSON.stringify(aStacktrace, null, ' ') title: JSON.stringify(aStacktrace, null, ' ')
}); });
@ -223,7 +225,7 @@ export class GfAdminJobsComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ jobs }) => { .subscribe(({ jobs }) => {
this.dataSource = new MatTableDataSource(jobs); this.dataSource = new MatTableDataSource(jobs);
this.dataSource.sort = this.sort; this.dataSource.sort = this.sort();
this.dataSource.sortingDataAccessor = get; this.dataSource.sortingDataAccessor = get;
this.isLoading = false; this.isLoading = false;

287
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -87,6 +87,7 @@ import {
readerOutline, readerOutline,
serverOutline serverOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { isBoolean } from 'lodash';
import ms from 'ms'; import ms from 'ms';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@ -129,12 +130,13 @@ export class GfAssetProfileDialogComponent implements OnInit {
)};123.45`; )};123.45`;
@ViewChild('assetProfileFormElement') @ViewChild('assetProfileFormElement')
assetProfileFormElement: ElementRef<HTMLFormElement>; public readonly assetProfileFormElement: ElementRef<HTMLFormElement>;
public assetClassLabel: string; protected assetClassLabel: string;
public assetSubClassLabel: string; protected assetSubClassLabel: string;
public assetClassOptions: AssetClassSelectorOption[] = Object.keys(AssetClass) protected readonly assetClassOptions: AssetClassSelectorOption[] =
Object.keys(AssetClass)
.map((id) => { .map((id) => {
return { id, label: translate(id) } as AssetClassSelectorOption; return { id, label: translate(id) } as AssetClassSelectorOption;
}) })
@ -142,12 +144,12 @@ export class GfAssetProfileDialogComponent implements OnInit {
return a.label.localeCompare(b.label); return a.label.localeCompare(b.label);
}); });
public assetSubClassOptions: AssetClassSelectorOption[] = []; protected assetSubClassOptions: AssetClassSelectorOption[] = [];
public assetProfile: AdminMarketDataDetails['assetProfile']; protected assetProfile: AdminMarketDataDetails['assetProfile'];
public assetProfileForm = this.formBuilder.group({ protected readonly assetProfileForm = this.formBuilder.group({
assetClass: new FormControl<AssetClass>(undefined), assetClass: new FormControl<AssetClass | null>(null),
assetSubClass: new FormControl<AssetSubClass>(undefined), assetSubClass: new FormControl<AssetSubClass | null>(null),
comment: '', comment: '',
countries: ['', jsonValidator()], countries: ['', jsonValidator()],
currency: '', currency: '',
@ -156,11 +158,15 @@ export class GfAssetProfileDialogComponent implements OnInit {
}), }),
isActive: [true], isActive: [true],
name: ['', Validators.required], name: ['', Validators.required],
scraperConfiguration: this.formBuilder.group({ scraperConfiguration: this.formBuilder.group<
defaultMarketPrice: null, Omit<ScraperConfiguration, 'headers'> & {
headers: [JSON.stringify({}), jsonValidator()], headers: FormControl<string | null>;
}
>({
defaultMarketPrice: undefined,
headers: new FormControl(JSON.stringify({}), jsonValidator()),
locale: '', locale: '',
mode: '', mode: 'lazy',
selector: '', selector: '',
url: '' url: ''
}), }),
@ -169,12 +175,11 @@ export class GfAssetProfileDialogComponent implements OnInit {
url: '' url: ''
}); });
public assetProfileIdentifierForm = this.formBuilder.group( protected readonly assetProfileIdentifierForm = this.formBuilder.group(
{ {
assetProfileIdentifier: new FormControl<AssetProfileIdentifier>( assetProfileIdentifier: new FormControl<
{ symbol: null, dataSource: null }, AssetProfileIdentifier | { dataSource: null; symbol: null }
[Validators.required] >({ dataSource: null, symbol: null }, [Validators.required])
)
}, },
{ {
validators: (control) => { validators: (control) => {
@ -183,16 +188,15 @@ export class GfAssetProfileDialogComponent implements OnInit {
} }
); );
public benchmarks: Partial<SymbolProfile>[]; protected canEditAssetProfile = true;
public canEditAssetProfile = true;
public countries: { protected countries: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
public currencies: string[] = []; protected currencies: string[] = [];
public dateRangeOptions = [ protected readonly dateRangeOptions = [
{ {
label: $localize`Current week` + ' (' + $localize`WTD` + ')', label: $localize`Current week` + ' (' + $localize`WTD` + ')',
value: 'wtd' value: 'wtd'
@ -218,14 +222,14 @@ export class GfAssetProfileDialogComponent implements OnInit {
value: 'max' value: 'max'
} }
]; ];
public historicalDataItems: LineChartItem[]; protected historicalDataItems: LineChartItem[];
public isBenchmark = false; protected isBenchmark = false;
public isDataGatheringEnabled: boolean; protected isDataGatheringEnabled: boolean;
public isEditAssetProfileIdentifierMode = false; protected isEditAssetProfileIdentifierMode = false;
public isUUID = isUUID; protected readonly isUUID = isUUID;
public marketDataItems: MarketData[] = []; protected marketDataItems: MarketData[] = [];
public modeValues = [ protected readonly modeValues = [
{ {
value: 'lazy', value: 'lazy',
viewValue: $localize`Lazy` + ' (' + $localize`end of day` + ')' viewValue: $localize`Lazy` + ' (' + $localize`end of day` + ')'
@ -236,20 +240,22 @@ export class GfAssetProfileDialogComponent implements OnInit {
} }
]; ];
public sectors: { protected sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
public user: User; protected user: User;
private benchmarks: Partial<SymbolProfile>[];
public constructor( public constructor(
public adminMarketDataService: AdminMarketDataService, protected adminMarketDataService: AdminMarketDataService,
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams, @Inject(MAT_DIALOG_DATA) protected data: AssetProfileDialogParams,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef, private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfAssetProfileDialogComponent>, private dialogRef: MatDialogRef<GfAssetProfileDialogComponent>,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private notificationService: NotificationService, private notificationService: NotificationService,
private snackBar: MatSnackBar, private snackBar: MatSnackBar,
@ -264,7 +270,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
}); });
} }
public get canSaveAssetProfileIdentifier() { protected get canSaveAssetProfileIdentifier() {
return !this.assetProfileForm.dirty && this.canEditAssetProfile; return !this.assetProfileForm.dirty && this.canEditAssetProfile;
} }
@ -277,8 +283,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.initialize(); this.initialize();
} }
public initialize() { protected initialize() {
this.historicalDataItems = undefined; this.historicalDataItems = [];
this.adminService this.adminService
.fetchAdminData() .fetchAdminData()
@ -298,10 +304,13 @@ export class GfAssetProfileDialogComponent implements OnInit {
} }
}); });
this.assetProfileForm this.assetProfileForm.controls.assetClass.valueChanges
.get('assetClass') .pipe(takeUntilDestroyed(this.destroyRef))
.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((assetClass) => { .subscribe((assetClass) => {
if (!assetClass) {
return;
}
const assetSubClasses = ASSET_CLASS_MAPPING.get(assetClass) ?? []; const assetSubClasses = ASSET_CLASS_MAPPING.get(assetClass) ?? [];
this.assetSubClassOptions = assetSubClasses this.assetSubClassOptions = assetSubClasses
@ -313,7 +322,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
}) })
.sort((a, b) => a.label.localeCompare(b.label)); .sort((a, b) => a.label.localeCompare(b.label));
this.assetProfileForm.get('assetSubClass').setValue(null); this.assetProfileForm.controls.assetSubClass.setValue(null);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -327,8 +336,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
.subscribe(({ assetProfile, marketData }) => { .subscribe(({ assetProfile, marketData }) => {
this.assetProfile = assetProfile; this.assetProfile = assetProfile;
this.assetClassLabel = translate(this.assetProfile?.assetClass); this.assetClassLabel = translate(this.assetProfile?.assetClass ?? '');
this.assetSubClassLabel = translate(this.assetProfile?.assetSubClass); this.assetSubClassLabel = translate(
this.assetProfile?.assetSubClass ?? ''
);
this.canEditAssetProfile = !isCurrency( this.canEditAssetProfile = !isCurrency(
getCurrencyFromSymbol(this.data.symbol) getCurrencyFromSymbol(this.data.symbol)
@ -350,7 +361,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.marketDataItems = marketData; this.marketDataItems = marketData;
this.sectors = {}; this.sectors = {};
if (this.assetProfile?.countries?.length > 0) { if (
this.assetProfile?.countries &&
this.assetProfile.countries.length > 0
) {
for (const { code, name, weight } of this.assetProfile.countries) { for (const { code, name, weight } of this.assetProfile.countries) {
this.countries[code] = { this.countries[code] = {
name, name,
@ -359,7 +373,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
} }
} }
if (this.assetProfile?.sectors?.length > 0) { if (
this.assetProfile?.sectors &&
this.assetProfile.sectors.length > 0
) {
for (const { name, weight } of this.assetProfile.sectors) { for (const { name, weight } of this.assetProfile.sectors) {
this.sectors[name] = { this.sectors[name] = {
name, name,
@ -377,12 +394,14 @@ export class GfAssetProfileDialogComponent implements OnInit {
return { code, weight }; return { code, weight };
}) ?? [] }) ?? []
), ),
currency: this.assetProfile?.currency, currency: this.assetProfile?.currency ?? null,
historicalData: { historicalData: {
csvString: GfAssetProfileDialogComponent.HISTORICAL_DATA_TEMPLATE csvString: GfAssetProfileDialogComponent.HISTORICAL_DATA_TEMPLATE
}, },
isActive: this.assetProfile?.isActive, isActive: isBoolean(this.assetProfile?.isActive)
name: this.assetProfile.name ?? this.assetProfile.symbol, ? this.assetProfile.isActive
: null,
name: this.assetProfile.name ?? this.assetProfile.symbol ?? null,
scraperConfiguration: { scraperConfiguration: {
defaultMarketPrice: defaultMarketPrice:
this.assetProfile?.scraperConfiguration?.defaultMarketPrice ?? this.assetProfile?.scraperConfiguration?.defaultMarketPrice ??
@ -410,7 +429,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
}); });
} }
public onCancelEditAssetProfileIdentifierMode() { protected onCancelEditAssetProfileIdentifierMode() {
this.isEditAssetProfileIdentifierMode = false; this.isEditAssetProfileIdentifierMode = false;
if (this.canEditAssetProfile) { if (this.canEditAssetProfile) {
@ -420,17 +439,20 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.assetProfileIdentifierForm.reset(); this.assetProfileIdentifierForm.reset();
} }
public onClose() { protected onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }
public onDeleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) { protected onDeleteProfileData({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol }); this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol });
this.dialogRef.close(); this.dialogRef.close();
} }
public onGatherProfileDataBySymbol({ protected onGatherProfileDataBySymbol({
dataSource, dataSource,
symbol symbol
}: AssetProfileIdentifier) { }: AssetProfileIdentifier) {
@ -440,7 +462,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
.subscribe(); .subscribe();
} }
public onGatherSymbol({ protected onGatherSymbol({
dataSource, dataSource,
range, range,
symbol symbol
@ -453,13 +475,13 @@ export class GfAssetProfileDialogComponent implements OnInit {
.subscribe(); .subscribe();
} }
public onMarketDataChanged(withRefresh: boolean = false) { protected onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) { if (withRefresh) {
this.initialize(); this.initialize();
} }
} }
public onSetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) { protected onSetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
this.dataService this.dataService
.postBenchmark({ dataSource, symbol }) .postBenchmark({ dataSource, symbol })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -472,50 +494,48 @@ export class GfAssetProfileDialogComponent implements OnInit {
}); });
} }
public onSetEditAssetProfileIdentifierMode() { protected onSetEditAssetProfileIdentifierMode() {
this.isEditAssetProfileIdentifierMode = true; this.isEditAssetProfileIdentifierMode = true;
this.assetProfileForm.disable(); this.assetProfileForm.disable();
} }
public async onSubmitAssetProfileForm() { protected async onSubmitAssetProfileForm() {
let countries = []; let countries: Prisma.InputJsonArray = [];
let scraperConfiguration: ScraperConfiguration = { let scraperConfiguration: Prisma.InputJsonObject | undefined = {
selector: '', selector: '',
url: '' url: ''
}; };
let sectors = []; let sectors: Prisma.InputJsonArray = [];
let symbolMapping = {}; let symbolMapping: Record<string, string> = {};
try { try {
countries = JSON.parse(this.assetProfileForm.get('countries').value); countries = JSON.parse(
this.assetProfileForm.controls.countries.value ?? '[]'
) as Prisma.InputJsonArray;
} catch {} } catch {}
try { try {
scraperConfiguration = { scraperConfiguration = {
defaultMarketPrice: defaultMarketPrice:
(this.assetProfileForm.controls['scraperConfiguration'].controls[ this.assetProfileForm.controls.scraperConfiguration.controls
'defaultMarketPrice' .defaultMarketPrice?.value ?? undefined,
].value as number) || undefined,
headers: JSON.parse( headers: JSON.parse(
this.assetProfileForm.controls['scraperConfiguration'].controls[ this.assetProfileForm.controls.scraperConfiguration.controls.headers
'headers' .value ?? '{}'
].value ) as Record<string, string>,
),
locale: locale:
this.assetProfileForm.controls['scraperConfiguration'].controls[ this.assetProfileForm.controls.scraperConfiguration.controls.locale
'locale' ?.value ?? undefined,
].value || undefined, mode:
mode: this.assetProfileForm.controls['scraperConfiguration'].controls[ this.assetProfileForm.controls.scraperConfiguration.controls.mode
'mode' ?.value ?? undefined,
].value as ScraperConfiguration['mode'],
selector: selector:
this.assetProfileForm.controls['scraperConfiguration'].controls[ this.assetProfileForm.controls.scraperConfiguration.controls.selector
'selector' .value ?? '',
].value, url:
url: this.assetProfileForm.controls['scraperConfiguration'].controls[ this.assetProfileForm.controls.scraperConfiguration.controls.url
'url' .value ?? ''
].value
}; };
if (!scraperConfiguration.selector || !scraperConfiguration.url) { if (!scraperConfiguration.selector || !scraperConfiguration.url) {
@ -536,28 +556,32 @@ export class GfAssetProfileDialogComponent implements OnInit {
} }
try { try {
sectors = JSON.parse(this.assetProfileForm.get('sectors').value); sectors = JSON.parse(
this.assetProfileForm.controls.sectors.value ?? '[]'
) as Prisma.InputJsonArray;
} catch {} } catch {}
try { try {
symbolMapping = JSON.parse( symbolMapping = JSON.parse(
this.assetProfileForm.get('symbolMapping').value this.assetProfileForm.controls.symbolMapping.value ?? '{}'
); ) as Record<string, string>;
} catch {} } catch {}
const assetProfile: UpdateAssetProfileDto = { const assetProfile: UpdateAssetProfileDto = {
countries, countries,
scraperConfiguration,
sectors, sectors,
symbolMapping, symbolMapping,
assetClass: this.assetProfileForm.get('assetClass').value, assetClass: this.assetProfileForm.controls.assetClass.value ?? undefined,
assetSubClass: this.assetProfileForm.get('assetSubClass').value, assetSubClass:
comment: this.assetProfileForm.get('comment').value || null, this.assetProfileForm.controls.assetSubClass.value ?? undefined,
currency: this.assetProfileForm.get('currency').value, comment: this.assetProfileForm.controls.comment.value ?? undefined,
isActive: this.assetProfileForm.get('isActive').value, currency: this.assetProfileForm.controls.currency.value ?? undefined,
name: this.assetProfileForm.get('name').value, isActive: isBoolean(this.assetProfileForm.controls.isActive.value)
scraperConfiguration: ? this.assetProfileForm.controls.isActive.value
scraperConfiguration as unknown as Prisma.InputJsonObject, : undefined,
url: this.assetProfileForm.get('url').value || null name: this.assetProfileForm.controls.name.value ?? undefined,
url: this.assetProfileForm.controls.url.value ?? undefined
}; };
try { try {
@ -600,7 +624,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.initialize(); this.initialize();
}, },
error: (error) => { error: (error: HttpErrorResponse) => {
console.error($localize`Could not save asset profile`, error); console.error($localize`Could not save asset profile`, error);
this.snackBar.open( this.snackBar.open(
@ -614,12 +638,14 @@ export class GfAssetProfileDialogComponent implements OnInit {
}); });
} }
public async onSubmitAssetProfileIdentifierForm() { protected async onSubmitAssetProfileIdentifierForm() {
const assetProfileIdentifier: UpdateAssetProfileDto = { const assetProfileIdentifier: UpdateAssetProfileDto = {
dataSource: this.assetProfileIdentifierForm.get('assetProfileIdentifier') dataSource:
.value.dataSource, this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value
symbol: this.assetProfileIdentifierForm.get('assetProfileIdentifier') ?.dataSource ?? undefined,
.value.symbol symbol:
this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value
?.symbol ?? undefined
}; };
try { try {
@ -676,38 +702,33 @@ export class GfAssetProfileDialogComponent implements OnInit {
}); });
} }
public onTestMarketData() { protected onTestMarketData() {
this.adminService this.adminService
.testMarketData({ .testMarketData({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
scraperConfiguration: { scraperConfiguration: {
defaultMarketPrice: this.assetProfileForm.controls[ defaultMarketPrice:
'scraperConfiguration' this.assetProfileForm.controls.scraperConfiguration.controls
].controls['defaultMarketPrice'].value as number, .defaultMarketPrice?.value,
headers: JSON.parse( headers: JSON.parse(
this.assetProfileForm.controls['scraperConfiguration'].controls[ this.assetProfileForm.controls.scraperConfiguration.controls.headers
'headers' .value ?? '{}'
].value ) as Record<string, string>,
),
locale: locale:
this.assetProfileForm.controls['scraperConfiguration'].controls[ this.assetProfileForm.controls.scraperConfiguration.controls.locale
'locale' ?.value || undefined,
].value || undefined, mode: this.assetProfileForm.controls.scraperConfiguration.controls
mode: this.assetProfileForm.controls['scraperConfiguration'].controls[ .mode?.value,
'mode'
].value,
selector: selector:
this.assetProfileForm.controls['scraperConfiguration'].controls[ this.assetProfileForm.controls.scraperConfiguration.controls
'selector' .selector.value,
].value, url: this.assetProfileForm.controls.scraperConfiguration.controls.url
url: this.assetProfileForm.controls['scraperConfiguration'].controls[ .value
'url'
].value
}, },
symbol: this.data.symbol symbol: this.data.symbol
}) })
.pipe( .pipe(
catchError(({ error }) => { catchError(({ error }: HttpErrorResponse) => {
this.notificationService.alert({ this.notificationService.alert({
message: error?.message, message: error?.message,
title: $localize`Error` title: $localize`Error`
@ -723,26 +744,26 @@ export class GfAssetProfileDialogComponent implements OnInit {
' ' + ' ' +
price + price +
' ' + ' ' +
this.assetProfileForm.get('currency').value this.assetProfileForm.controls.currency.value
}); });
}); });
} }
public onToggleIsActive({ checked }: MatCheckboxChange) { protected onToggleIsActive({ checked }: MatCheckboxChange) {
if (checked) { if (checked) {
this.assetProfileForm.get('isActive')?.setValue(true); this.assetProfileForm.controls.isActive.setValue(true);
} else { } else {
this.assetProfileForm.get('isActive')?.setValue(false); this.assetProfileForm.controls.isActive.setValue(false);
} }
if (checked === this.assetProfile.isActive) { if (checked === this.assetProfile.isActive) {
this.assetProfileForm.get('isActive')?.markAsPristine(); this.assetProfileForm.controls.isActive.markAsPristine();
} else { } else {
this.assetProfileForm.get('isActive')?.markAsDirty(); this.assetProfileForm.controls.isActive.markAsDirty();
} }
} }
public onUnsetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) { protected onUnsetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
this.dataService this.dataService
.deleteBenchmark({ dataSource, symbol }) .deleteBenchmark({ dataSource, symbol })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -755,15 +776,15 @@ export class GfAssetProfileDialogComponent implements OnInit {
}); });
} }
public onTriggerSubmitAssetProfileForm() { protected onTriggerSubmitAssetProfileForm() {
if (this.assetProfileForm.valid) { if (this.assetProfileForm.valid) {
this.onSubmitAssetProfileForm(); this.onSubmitAssetProfileForm();
} }
} }
private isNewSymbolValid(control: AbstractControl): ValidationErrors { private isNewSymbolValid(control: AbstractControl): ValidationErrors | null {
const currentAssetProfileIdentifier: AssetProfileIdentifier | undefined = const currentAssetProfileIdentifier: AssetProfileIdentifier | undefined =
control.get('assetProfileIdentifier').value; control.get('assetProfileIdentifier')?.value;
if ( if (
currentAssetProfileIdentifier?.dataSource === this.data?.dataSource && currentAssetProfileIdentifier?.dataSource === this.data?.dataSource &&
@ -773,5 +794,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
equalsPreviousProfileIdentifier: true equalsPreviousProfileIdentifier: true
}; };
} }
return null;
} }
} }

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

@ -419,11 +419,11 @@
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Url</mat-label> <mat-label i18n>Url</mat-label>
<input formControlName="url" matInput type="text" /> <input formControlName="url" matInput type="text" />
@if (assetProfileForm.get('url').value) { @if (assetProfileForm.controls.url.value) {
<gf-entity-logo <gf-entity-logo
class="mr-3" class="mr-3"
matSuffix matSuffix
[url]="assetProfileForm.get('url').value" [url]="assetProfileForm.controls.url.value"
/> />
} }
</mat-form-field> </mat-form-field>

44
apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.ts

@ -1,9 +1,8 @@
import type { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete'; import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { import {
AbstractControl,
FormBuilder,
FormControl, FormControl,
FormGroup, FormGroup,
FormsModule, FormsModule,
@ -15,6 +14,8 @@ import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { CreateWatchlistItemForm } from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' }, host: { class: 'h-100' },
@ -30,44 +31,41 @@ import { MatFormFieldModule } from '@angular/material/form-field';
styleUrls: ['./create-watchlist-item-dialog.component.scss'], styleUrls: ['./create-watchlist-item-dialog.component.scss'],
templateUrl: 'create-watchlist-item-dialog.html' templateUrl: 'create-watchlist-item-dialog.html'
}) })
export class GfCreateWatchlistItemDialogComponent implements OnInit { export class GfCreateWatchlistItemDialogComponent {
public createWatchlistItemForm: FormGroup; protected readonly createWatchlistItemForm: CreateWatchlistItemForm =
new FormGroup(
public constructor(
public readonly dialogRef: MatDialogRef<GfCreateWatchlistItemDialogComponent>,
public readonly formBuilder: FormBuilder
) {}
public ngOnInit() {
this.createWatchlistItemForm = this.formBuilder.group(
{ {
searchSymbol: new FormControl(null, [Validators.required]) searchSymbol: new FormControl<AssetProfileIdentifier | null>(null, [
Validators.required
])
}, },
{ {
validators: this.validator validators: this.validator
} }
); );
}
public onCancel() { private readonly dialogRef =
inject<MatDialogRef<GfCreateWatchlistItemDialogComponent>>(MatDialogRef);
protected onCancel() {
this.dialogRef.close(); this.dialogRef.close();
} }
public onSubmit() { protected onSubmit() {
this.dialogRef.close({ this.dialogRef.close({
dataSource: dataSource:
this.createWatchlistItemForm.get('searchSymbol').value.dataSource, this.createWatchlistItemForm.controls.searchSymbol.value?.dataSource,
symbol: this.createWatchlistItemForm.get('searchSymbol').value.symbol symbol: this.createWatchlistItemForm.controls.searchSymbol.value?.symbol
}); });
} }
private validator(control: AbstractControl): ValidationErrors { private validator(control: CreateWatchlistItemForm): ValidationErrors {
const searchSymbolControl = control.get('searchSymbol'); const searchSymbolControl = control.controls.searchSymbol;
if ( if (
searchSymbolControl.valid && searchSymbolControl.valid &&
searchSymbolControl.value.dataSource && searchSymbolControl.value?.dataSource &&
searchSymbolControl.value.symbol searchSymbolControl.value?.symbol
) { ) {
return { incomplete: false }; return { incomplete: false };
} }

8
apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/interfaces/interfaces.ts

@ -1,4 +1,12 @@
import type { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import type { FormControl, FormGroup } from '@angular/forms';
export interface CreateWatchlistItemDialogParams { export interface CreateWatchlistItemDialogParams {
deviceType: string; deviceType: string;
locale: string; locale: string;
} }
export type CreateWatchlistItemForm = FormGroup<{
searchSymbol: FormControl<AssetProfileIdentifier | null>;
}>;

52
apps/client/src/app/components/home-watchlist/home-watchlist.component.ts

@ -1,5 +1,6 @@
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 { locale as defaultLocale } from '@ghostfolio/common/config';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
Benchmark, Benchmark,
@ -14,8 +15,10 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
computed,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
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';
@ -45,26 +48,29 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/
templateUrl: './home-watchlist.html' templateUrl: './home-watchlist.html'
}) })
export class GfHomeWatchlistComponent implements OnInit { export class GfHomeWatchlistComponent implements OnInit {
public deviceType: string; protected hasImpersonationId: boolean;
public hasImpersonationId: boolean; protected hasPermissionToCreateWatchlistItem: boolean;
public hasPermissionToCreateWatchlistItem: boolean; protected hasPermissionToDeleteWatchlistItem: boolean;
public hasPermissionToDeleteWatchlistItem: boolean; protected user: User;
public user: User; protected watchlist: Benchmark[];
public watchlist: Benchmark[];
protected readonly deviceType = computed(
public constructor( () => this.deviceDetectorService.deviceInfo().deviceType
private changeDetectorRef: ChangeDetectorRef, );
private dataService: DataService,
private destroyRef: DestroyRef, private readonly changeDetectorRef = inject(ChangeDetectorRef);
private deviceService: DeviceDetectorService, private readonly dataService = inject(DataService);
private dialog: MatDialog, private readonly destroyRef = inject(DestroyRef);
private impersonationStorageService: ImpersonationStorageService, private readonly deviceDetectorService = inject(DeviceDetectorService);
private route: ActivatedRoute, private readonly dialog = inject(MatDialog);
private router: Router, private readonly impersonationStorageService = inject(
private userService: UserService ImpersonationStorageService
) { );
this.deviceType = this.deviceService.getDeviceInfo().deviceType; private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -110,7 +116,7 @@ export class GfHomeWatchlistComponent implements OnInit {
this.loadWatchlistData(); this.loadWatchlistData();
} }
public onWatchlistItemDeleted({ protected onWatchlistItemDeleted({
dataSource, dataSource,
symbol symbol
}: AssetProfileIdentifier) { }: AssetProfileIdentifier) {
@ -148,10 +154,10 @@ export class GfHomeWatchlistComponent implements OnInit {
>(GfCreateWatchlistItemDialogComponent, { >(GfCreateWatchlistItemDialogComponent, {
autoFocus: false, autoFocus: false,
data: { data: {
deviceType: this.deviceType, deviceType: this.deviceType(),
locale: this.user?.settings?.locale locale: this.user?.settings?.locale ?? defaultLocale
}, },
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
}); });
dialogRef dialogRef

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

@ -11,7 +11,7 @@
<div class="col-xs-12 col-md-10 offset-md-1"> <div class="col-xs-12 col-md-10 offset-md-1">
<gf-benchmark <gf-benchmark
[benchmarks]="watchlist" [benchmarks]="watchlist"
[deviceType]="deviceType" [deviceType]="deviceType()"
[hasPermissionToDeleteItem]="hasPermissionToDeleteWatchlistItem" [hasPermissionToDeleteItem]="hasPermissionToDeleteWatchlistItem"
[locale]="user?.settings?.locale || undefined" [locale]="user?.settings?.locale || undefined"
[user]="user" [user]="user"

16
apps/client/src/app/pages/faq/overview/faq-overview-page.component.ts

@ -7,11 +7,11 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
OnDestroy DestroyRef
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { Subject, takeUntil } from 'rxjs';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
@ -21,21 +21,20 @@ import { Subject, takeUntil } from 'rxjs';
styleUrls: ['./faq-overview-page.scss'], styleUrls: ['./faq-overview-page.scss'],
templateUrl: './faq-overview-page.html' templateUrl: './faq-overview-page.html'
}) })
export class GfFaqOverviewPageComponent implements OnDestroy { export class GfFaqOverviewPageComponent {
public pricingUrl = `https://ghostfol.io/${document.documentElement.lang}/${publicRoutes.pricing.path}`; public pricingUrl = `https://ghostfol.io/${document.documentElement.lang}/${publicRoutes.pricing.path}`;
public routerLinkFeatures = publicRoutes.features.routerLink; public routerLinkFeatures = publicRoutes.features.routerLink;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private destroyRef: DestroyRef,
private userService: UserService private userService: UserService
) {} ) {}
public ngOnInit() { public ngOnInit() {
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -44,9 +43,4 @@ export class GfFaqOverviewPageComponent implements OnDestroy {
} }
}); });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

2
libs/ui/src/lib/accounts-table/accounts-table.component.ts

@ -64,7 +64,7 @@ export class GfAccountsTableComponent {
public readonly showBalance = input(true); public readonly showBalance = input(true);
public readonly showFooter = input(true); public readonly showFooter = input(true);
public readonly showValue = input(true); public readonly showValue = input(true);
public readonly showValueInBaseCurrency = input(false); public readonly showValueInBaseCurrency = input(true);
public readonly totalBalanceInBaseCurrency = input<number>(); public readonly totalBalanceInBaseCurrency = input<number>();
public readonly totalValueInBaseCurrency = input<number>(); public readonly totalValueInBaseCurrency = input<number>();

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

@ -5,7 +5,7 @@
<mat-label i18n>Type</mat-label> <mat-label i18n>Type</mat-label>
<mat-select multiple [formControl]="typesFilter"> <mat-select multiple [formControl]="typesFilter">
@for ( @for (
activityType of activityTypesTranslationMap | keyvalue; activityType of activityTypesTranslationMap | keyvalue: sortByValue;
track activityType.key track activityType.key
) { ) {
<mat-option [value]="activityType.key"> <mat-option [value]="activityType.key">

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

@ -353,6 +353,13 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
this.activityToUpdate.emit(aActivity); this.activityToUpdate.emit(aActivity);
} }
public sortByValue(
a: { key: ActivityType; value: string },
b: { key: ActivityType; value: string }
) {
return a.value.localeCompare(b.value);
}
public toggleAllRows() { public toggleAllRows() {
if (this.areAllRowsSelected()) { if (this.areAllRowsSelected()) {
this.selectedRows.clear(); this.selectedRows.clear();

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

@ -189,12 +189,16 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
} }
public ngOnInit() { public ngOnInit() {
this.assetClasses = Object.keys(AssetClass).map((assetClass) => { this.assetClasses = Object.keys(AssetClass)
.map((assetClass) => {
return { return {
id: assetClass, id: assetClass,
label: translate(assetClass), label: translate(assetClass),
type: 'ASSET_CLASS' type: 'ASSET_CLASS'
}; } satisfies Filter;
})
.sort((a, b) => {
return a.label.localeCompare(b.label);
}); });
this.searchFormControl.valueChanges this.searchFormControl.valueChanges
@ -435,12 +439,15 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
?.filter(({ isUsed }) => { ?.filter(({ isUsed }) => {
return isUsed; return isUsed;
}) })
.map(({ id, name }) => { ?.map(({ id, name }) => {
return { return {
id, id,
label: translate(name), label: translate(name),
type: 'TAG' type: 'TAG'
}; } satisfies Filter;
})
?.sort((a, b) => {
return a.label.localeCompare(b.label);
}) ?? []; }) ?? [];
} }

16
package-lock.json

@ -93,7 +93,7 @@
"svgmap": "2.19.3", "svgmap": "2.19.3",
"tablemark": "4.1.0", "tablemark": "4.1.0",
"twitter-api-v2": "1.29.0", "twitter-api-v2": "1.29.0",
"yahoo-finance2": "3.13.2", "yahoo-finance2": "3.14.0",
"zone.js": "0.16.1" "zone.js": "0.16.1"
}, },
"devDependencies": { "devDependencies": {
@ -148,7 +148,7 @@
"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.6.4", "nx": "22.6.4",
"prettier": "3.8.1", "prettier": "3.8.2",
"prettier-plugin-organize-attributes": "1.0.0", "prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.19.3", "prisma": "6.19.3",
"react": "18.2.0", "react": "18.2.0",
@ -33003,9 +33003,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.8.1", "version": "3.8.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@ -39587,9 +39587,9 @@
} }
}, },
"node_modules/yahoo-finance2": { "node_modules/yahoo-finance2": {
"version": "3.13.2", "version": "3.14.0",
"resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.13.2.tgz", "resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.14.0.tgz",
"integrity": "sha512-aAOJEjuLClfDxVPRKxjcwFoyzMr8BE/svgUqr5IjnQR+kppYbKO92Wl3SbAGz5DRghy6FiUfqi5FBDSBA/e2jg==", "integrity": "sha512-gsT/tqgeizKtMxbIIWFiFyuhM/6MZE4yEyNLmPekr88AX14JL2HWw0/QNMOR081jVtzTjihqDW0zV7IayH1Wcw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@deno/shim-deno": "~0.18.0", "@deno/shim-deno": "~0.18.0",

4
package.json

@ -138,7 +138,7 @@
"svgmap": "2.19.3", "svgmap": "2.19.3",
"tablemark": "4.1.0", "tablemark": "4.1.0",
"twitter-api-v2": "1.29.0", "twitter-api-v2": "1.29.0",
"yahoo-finance2": "3.13.2", "yahoo-finance2": "3.14.0",
"zone.js": "0.16.1" "zone.js": "0.16.1"
}, },
"devDependencies": { "devDependencies": {
@ -193,7 +193,7 @@
"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.6.4", "nx": "22.6.4",
"prettier": "3.8.1", "prettier": "3.8.2",
"prettier-plugin-organize-attributes": "1.0.0", "prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.19.3", "prisma": "6.19.3",
"react": "18.2.0", "react": "18.2.0",

Loading…
Cancel
Save