Feature/extend assistant by holding selector (#4031)

* Extend assistant by holding selector

* Update changelog
pull/4034/head^2
Amandee Ellawala 2 weeks ago committed by GitHub
parent 9f72835d58
commit 6057794eb6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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…
Cancel
Save