Feature/extend assistant by holding selector (#4031)
* Extend assistant by holding selector * Update changelog
This commit is contained in:
parent
9f72835d58
commit
6057794eb6
@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended the assistant by a holding selector
|
||||
|
||||
## 2.122.0 - 2024-11-07
|
||||
|
||||
### Changed
|
||||
|
@ -74,12 +74,15 @@ export class PortfolioController {
|
||||
@Get('details')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getDetails(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('symbol') filterBySymbol?: string,
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('withMarkets') withMarketsParam = 'false'
|
||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||
@ -95,6 +98,8 @@ export class PortfolioController {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
@ -289,17 +294,22 @@ export class PortfolioController {
|
||||
|
||||
@Get('dividends')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getDividends(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('groupBy') groupBy?: GroupBy,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('symbol') filterBySymbol?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioDividends> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
@ -356,21 +366,26 @@ export class PortfolioController {
|
||||
@Get('holdings')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getHoldings(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('holdingType') filterByHoldingType?: string,
|
||||
@Query('query') filterBySearchQuery?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('symbol') filterBySymbol?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioHoldingsResponse> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterByHoldingType,
|
||||
filterBySearchQuery,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
@ -386,17 +401,22 @@ export class PortfolioController {
|
||||
|
||||
@Get('investments')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getInvestments(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('groupBy') groupBy?: GroupBy,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('symbol') filterBySymbol?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioInvestments> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
@ -451,13 +471,16 @@ export class PortfolioController {
|
||||
@Get('performance')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(PerformanceLoggingInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
@Version('2')
|
||||
public async getPerformanceV2(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('symbol') filterBySymbol?: string,
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
@ -466,6 +489,8 @@ export class PortfolioController {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
|
@ -64,6 +64,14 @@ export class UpdateUserSettingDto {
|
||||
@IsOptional()
|
||||
'filters.assetClasses'?: string[];
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
'filters.dataSource'?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
'filters.symbol'?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
'filters.tags'?: string[];
|
||||
|
@ -175,17 +175,17 @@ export class HeaderComponent implements OnChanges {
|
||||
const userSetting: UpdateUserSettingDto = {};
|
||||
|
||||
for (const filter of filters) {
|
||||
let filtersType: string;
|
||||
|
||||
if (filter.type === 'ACCOUNT') {
|
||||
filtersType = 'accounts';
|
||||
userSetting['filters.accounts'] = filter.id ? [filter.id] : null;
|
||||
} else if (filter.type === 'ASSET_CLASS') {
|
||||
filtersType = 'assetClasses';
|
||||
userSetting['filters.assetClasses'] = filter.id ? [filter.id] : null;
|
||||
} else if (filter.type === 'DATA_SOURCE') {
|
||||
userSetting['filters.dataSource'] = filter.id ? filter.id : null;
|
||||
} else if (filter.type === 'SYMBOL') {
|
||||
userSetting['filters.symbol'] = filter.id ? filter.id : null;
|
||||
} else if (filter.type === 'TAG') {
|
||||
filtersType = 'tags';
|
||||
userSetting['filters.tags'] = filter.id ? [filter.id] : null;
|
||||
}
|
||||
|
||||
userSetting[`filters.${filtersType}`] = filter.id ? [filter.id] : null;
|
||||
}
|
||||
|
||||
this.dataService
|
||||
|
@ -532,7 +532,7 @@ export class DataService {
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
range?: DateRange;
|
||||
}) {
|
||||
} = {}) {
|
||||
let params = this.buildFiltersAsQueryParams({ filters });
|
||||
|
||||
if (range) {
|
||||
|
@ -65,6 +65,20 @@ export class UserService extends ObservableStore<UserStoreState> {
|
||||
});
|
||||
}
|
||||
|
||||
if (user?.settings['filters.dataSource']) {
|
||||
filters.push({
|
||||
id: user.settings['filters.dataSource'],
|
||||
type: 'DATA_SOURCE'
|
||||
});
|
||||
}
|
||||
|
||||
if (user?.settings['filters.symbol']) {
|
||||
filters.push({
|
||||
id: user.settings['filters.symbol'],
|
||||
type: 'SYMBOL'
|
||||
});
|
||||
}
|
||||
|
||||
if (user?.settings['filters.tags']) {
|
||||
filters.push({
|
||||
id: user.settings['filters.tags'][0],
|
||||
|
@ -14,6 +14,8 @@ export interface UserSettings {
|
||||
dateRange?: DateRange;
|
||||
emergencyFund?: number;
|
||||
'filters.accounts'?: string[];
|
||||
'filters.dataSource'?: string;
|
||||
'filters.symbol'?: string;
|
||||
'filters.tags'?: string[];
|
||||
holdingsViewMode?: HoldingsViewMode;
|
||||
isExperimentalFeatures?: boolean;
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
|
||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { Filter, User } from '@ghostfolio/common/interfaces';
|
||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||
import { Filter, PortfolioPosition, User } from '@ghostfolio/common/interfaces';
|
||||
import { DateRange } from '@ghostfolio/common/types';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
|
||||
@ -35,7 +37,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatMenuTrigger } from '@angular/material/menu';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { Account, AssetClass } from '@prisma/client';
|
||||
import { Account, AssetClass, DataSource } from '@prisma/client';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs';
|
||||
import {
|
||||
@ -61,6 +63,7 @@ import {
|
||||
FormsModule,
|
||||
GfAssetProfileIconComponent,
|
||||
GfAssistantListItemComponent,
|
||||
GfSymbolModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
@ -132,8 +135,10 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
public filterForm = this.formBuilder.group({
|
||||
account: new FormControl<string>(undefined),
|
||||
assetClass: new FormControl<string>(undefined),
|
||||
holding: new FormControl<PortfolioPosition>(undefined),
|
||||
tag: new FormControl<string>(undefined)
|
||||
});
|
||||
public holdings: PortfolioPosition[] = [];
|
||||
public isLoading = false;
|
||||
public isOpen = false;
|
||||
public placeholder = $localize`Find holding...`;
|
||||
@ -144,7 +149,13 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
};
|
||||
public tags: Filter[] = [];
|
||||
|
||||
private filterTypes: Filter['type'][] = ['ACCOUNT', 'ASSET_CLASS', 'TAG'];
|
||||
private filterTypes: Filter['type'][] = [
|
||||
'ACCOUNT',
|
||||
'ASSET_CLASS',
|
||||
'DATA_SOURCE',
|
||||
'SYMBOL',
|
||||
'TAG'
|
||||
];
|
||||
private keyManager: FocusKeyManager<GfAssistantListItemComponent>;
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
@ -156,6 +167,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
) {}
|
||||
|
||||
public ngOnInit() {
|
||||
this.initializeFilterForm();
|
||||
|
||||
this.assetClasses = Object.keys(AssetClass).map((assetClass) => {
|
||||
return {
|
||||
id: assetClass,
|
||||
@ -263,16 +276,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
this.filterForm.enable({ emitEvent: false });
|
||||
}
|
||||
|
||||
this.filterForm.setValue(
|
||||
{
|
||||
account: this.user?.settings?.['filters.accounts']?.[0] ?? null,
|
||||
assetClass: this.user?.settings?.['filters.assetClasses']?.[0] ?? null,
|
||||
tag: this.user?.settings?.['filters.tags']?.[0] ?? null
|
||||
},
|
||||
{
|
||||
emitEvent: false
|
||||
}
|
||||
);
|
||||
this.initializeFilterForm();
|
||||
|
||||
this.tags =
|
||||
this.user?.tags
|
||||
@ -298,6 +302,19 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public holdingComparisonFunction(
|
||||
option: PortfolioPosition,
|
||||
value: PortfolioPosition
|
||||
): boolean {
|
||||
if (value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
getAssetProfileIdentifier(option) === getAssetProfileIdentifier(value)
|
||||
);
|
||||
}
|
||||
|
||||
public async initialize() {
|
||||
this.isLoading = true;
|
||||
this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap();
|
||||
@ -331,6 +348,14 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
id: this.filterForm.get('assetClass').value,
|
||||
type: 'ASSET_CLASS'
|
||||
},
|
||||
{
|
||||
id: this.filterForm.get('holding').value?.dataSource,
|
||||
type: 'DATA_SOURCE'
|
||||
},
|
||||
{
|
||||
id: this.filterForm.get('holding').value?.symbol,
|
||||
type: 'SYMBOL'
|
||||
},
|
||||
{
|
||||
id: this.filterForm.get('tag').value,
|
||||
type: 'TAG'
|
||||
@ -473,4 +498,47 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
);
|
||||
}
|
||||
|
||||
private initializeFilterForm() {
|
||||
this.dataService
|
||||
.fetchPortfolioHoldings()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ holdings }) => {
|
||||
this.holdings = holdings
|
||||
.filter(({ assetSubClass }) => {
|
||||
return !['CASH'].includes(assetSubClass);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return a.name?.localeCompare(b.name);
|
||||
});
|
||||
this.setFilterFormValues();
|
||||
});
|
||||
}
|
||||
|
||||
private setFilterFormValues() {
|
||||
const dataSource = this.user?.settings?.[
|
||||
'filters.dataSource'
|
||||
] as DataSource;
|
||||
const symbol = this.user?.settings?.['filters.symbol'];
|
||||
const selectedHolding = this.holdings.find((holding) => {
|
||||
return (
|
||||
getAssetProfileIdentifier({
|
||||
dataSource: holding.dataSource,
|
||||
symbol: holding.symbol
|
||||
}) === getAssetProfileIdentifier({ dataSource, symbol })
|
||||
);
|
||||
});
|
||||
|
||||
this.filterForm.setValue(
|
||||
{
|
||||
account: this.user?.settings?.['filters.accounts']?.[0] ?? null,
|
||||
assetClass: this.user?.settings?.['filters.assetClasses']?.[0] ?? null,
|
||||
holding: selectedHolding ?? null,
|
||||
tag: this.user?.settings?.['filters.tags']?.[0] ?? null
|
||||
},
|
||||
{
|
||||
emitEvent: false
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -122,6 +122,34 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||
<mat-label i18n>Holding</mat-label>
|
||||
<mat-select
|
||||
formControlName="holding"
|
||||
[compareWith]="holdingComparisonFunction"
|
||||
>
|
||||
<mat-select-trigger>{{
|
||||
filterForm.get('holding')?.value?.name
|
||||
}}</mat-select-trigger>
|
||||
<mat-option [value]="null" />
|
||||
@for (holding of holdings; track holding.name) {
|
||||
<mat-option [value]="holding">
|
||||
<div class="line-height-1 text-truncate">
|
||||
<span
|
||||
><b>{{ holding.name }}</b></span
|
||||
>
|
||||
<br />
|
||||
<small class="text-muted"
|
||||
>{{ holding.symbol | gfSymbol }} ·
|
||||
{{ holding.currency }}</small
|
||||
>
|
||||
</div>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||
<mat-label i18n>Tags</mat-label>
|
||||
|
Loading…
x
Reference in New Issue
Block a user