From 4c0ba5df112bf82a6258a9cdec21bcd5d3506d80 Mon Sep 17 00:00:00 2001 From: Thomas <4159106+dtslvr@users.noreply.github.com> Date: Sun, 15 Oct 2023 21:37:06 +0200 Subject: [PATCH] Extend assistant with search for asset profile --- apps/api/src/app/admin/admin.controller.ts | 21 +++--- apps/api/src/app/admin/admin.module.ts | 2 + apps/api/src/app/admin/admin.service.ts | 20 +++++- apps/api/src/services/api/api.service.ts | 9 +++ .../components/header/header.component.html | 3 + .../interfaces/admin-market-data.interface.ts | 1 + .../assistant-list-item.component.ts | 30 ++++++++- .../assistant-list-item.html | 10 +-- .../src/lib/assistant/assistant.component.ts | 66 ++++++++++++++++--- libs/ui/src/lib/assistant/assistant.html | 26 +++++++- .../lib/assistant/interfaces/interfaces.ts | 9 ++- 11 files changed, 160 insertions(+), 37 deletions(-) diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 2d6022221..30270d0c1 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -1,9 +1,9 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { - DEFAULT_PAGE_SIZE, GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS_OPTIONS } from '@ghostfolio/common/config'; @@ -12,8 +12,7 @@ import { AdminData, AdminMarketData, AdminMarketDataDetails, - EnhancedSymbolProfile, - Filter + EnhancedSymbolProfile } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import type { @@ -50,6 +49,7 @@ import { UpdateMarketDataDto } from './update-market-data.dto'; export class AdminController { public constructor( private readonly adminService: AdminService, + private readonly apiService: ApiService, private readonly dataGatheringService: DataGatheringService, private readonly marketDataService: MarketDataService, @Inject(REQUEST) private readonly request: RequestWithUser @@ -255,6 +255,7 @@ export class AdminController { public async getMarketData( @Query('assetSubClasses') filterByAssetSubClasses?: string, @Query('presetId') presetId?: MarketDataPreset, + @Query('query') filterBySearchQuery?: string, @Query('skip') skip?: number, @Query('sortColumn') sortColumn?: string, @Query('sortDirection') sortDirection?: Prisma.SortOrder, @@ -272,16 +273,10 @@ export class AdminController { ); } - const assetSubClasses = filterByAssetSubClasses?.split(',') ?? []; - - const filters: Filter[] = [ - ...assetSubClasses.map((assetSubClass) => { - return { - id: assetSubClass, - type: 'ASSET_SUB_CLASS' - }; - }) - ]; + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAssetSubClasses, + filterBySearchQuery + }); return this.adminService.getMarketData({ filters, diff --git a/apps/api/src/app/admin/admin.module.ts b/apps/api/src/app/admin/admin.module.ts index 500af69db..079af87fa 100644 --- a/apps/api/src/app/admin/admin.module.ts +++ b/apps/api/src/app/admin/admin.module.ts @@ -1,4 +1,5 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; +import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; @@ -15,6 +16,7 @@ import { QueueModule } from './queue/queue.module'; @Module({ imports: [ + ApiModule, ConfigurationModule, DataGatheringModule, DataProviderModule, diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index dd9e3f9ce..c92c82de2 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -131,10 +131,14 @@ export class AdminService { filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }]; } + const searchQuery = filters.find(({ type }) => { + return type === 'SEARCH_QUERY'; + })?.id; + const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy( filters, - (filter) => { - return filter.type; + ({ type }) => { + return type; } ); @@ -147,6 +151,14 @@ export class AdminService { where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id]; } + if (searchQuery) { + where.OR = [ + { isin: { mode: 'insensitive', startsWith: searchQuery } }, + { name: { mode: 'insensitive', startsWith: searchQuery } }, + { symbol: { mode: 'insensitive', startsWith: searchQuery } } + ]; + } + if (sortColumn) { orderBy = [{ [sortColumn]: sortDirection }]; @@ -174,6 +186,7 @@ export class AdminService { comment: true, countries: true, dataSource: true, + name: true, Order: { orderBy: [{ date: 'asc' }], select: { date: true }, @@ -195,6 +208,7 @@ export class AdminService { comment, countries, dataSource, + name, Order, sectors, symbol @@ -215,6 +229,7 @@ export class AdminService { comment, countriesCount, dataSource, + name, symbol, marketDataItemCount, sectorsCount, @@ -341,6 +356,7 @@ export class AdminService { symbol, assetClass: 'CASH', countriesCount: 0, + name: symbol, sectorsCount: 0 }; }); diff --git a/apps/api/src/services/api/api.service.ts b/apps/api/src/services/api/api.service.ts index 204aa030e..8ef0df7b3 100644 --- a/apps/api/src/services/api/api.service.ts +++ b/apps/api/src/services/api/api.service.ts @@ -8,16 +8,19 @@ export class ApiService { public buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, + filterByAssetSubClasses, filterBySearchQuery, filterByTags }: { filterByAccounts?: string; filterByAssetClasses?: string; + filterByAssetSubClasses?: string; filterBySearchQuery?: string; filterByTags?: string; }): Filter[] { const accountIds = filterByAccounts?.split(',') ?? []; const assetClasses = filterByAssetClasses?.split(',') ?? []; + const assetSubClasses = filterByAssetSubClasses?.split(',') ?? []; const searchQuery = filterBySearchQuery?.toLowerCase(); const tagIds = filterByTags?.split(',') ?? []; @@ -34,6 +37,12 @@ export class ApiService { type: 'ASSET_CLASS' }; }), + ...assetSubClasses.map((assetClass) => { + return { + id: assetClass, + type: 'ASSET_SUB_CLASS' + }; + }), { id: searchQuery, type: 'SEARCH_QUERY' diff --git a/apps/client/src/app/components/header/header.component.html b/apps/client/src/app/components/header/header.component.html index 45986df95..4d606f591 100644 --- a/apps/client/src/app/components/header/header.component.html +++ b/apps/client/src/app/components/header/header.component.html @@ -131,6 +131,9 @@ diff --git a/libs/common/src/lib/interfaces/admin-market-data.interface.ts b/libs/common/src/lib/interfaces/admin-market-data.interface.ts index d53562a23..111e605de 100644 --- a/libs/common/src/lib/interfaces/admin-market-data.interface.ts +++ b/libs/common/src/lib/interfaces/admin-market-data.interface.ts @@ -12,6 +12,7 @@ export interface AdminMarketDataItem { dataSource: DataSource; date?: Date; marketDataItemCount: number; + name: string; sectorsCount: number; symbol: string; } diff --git a/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts index 53a72206d..7e3c07d46 100644 --- a/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts +++ b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts @@ -7,10 +7,15 @@ import { EventEmitter, HostBinding, Input, + OnChanges, Output, ViewChild } from '@angular/core'; +import { Params } from '@angular/router'; import { Position } from '@ghostfolio/common/interfaces'; +import { SymbolProfile } from '@prisma/client'; + +import { ISearchResultItem } from '../interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -18,22 +23,43 @@ import { Position } from '@ghostfolio/common/interfaces'; templateUrl: './assistant-list-item.html', styleUrls: ['./assistant-list-item.scss'] }) -export class AssistantListItemComponent implements FocusableOption { +export class AssistantListItemComponent implements FocusableOption, OnChanges { @HostBinding('attr.tabindex') tabindex = -1; @HostBinding('class.has-focus') get getHasFocus() { return this.hasFocus; } - @Input() holding: Position; + @Input() item: ISearchResultItem; + @Input() mode: 'assetProfile' | 'holding'; @Output() clicked = new EventEmitter(); @ViewChild('link') public linkElement: ElementRef; public hasFocus = false; + public queryParams: Params; + public routerLink: string[]; public constructor(private changeDetectorRef: ChangeDetectorRef) {} + public ngOnChanges() { + if (this.mode === 'assetProfile') { + this.queryParams = { + assetProfileDialog: true, + dataSource: this.item?.dataSource, + symbol: this.item?.symbol + }; + this.routerLink = ['/admin', 'market-data']; + } else if (this.mode === 'holding') { + this.queryParams = { + dataSource: this.item?.dataSource, + positionDetailDialog: true, + symbol: this.item?.symbol + }; + this.routerLink = ['/portfolio', 'holdings']; + } + } + public focus() { this.hasFocus = true; diff --git a/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html index 5e078241d..b909f402a 100644 --- a/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html +++ b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html @@ -1,12 +1,8 @@ {{ holding?.name }}{{ item?.name }} diff --git a/libs/ui/src/lib/assistant/assistant.component.ts b/libs/ui/src/lib/assistant/assistant.component.ts index 30db8f2e0..857ab3172 100644 --- a/libs/ui/src/lib/assistant/assistant.component.ts +++ b/libs/ui/src/lib/assistant/assistant.component.ts @@ -16,9 +16,9 @@ import { } from '@angular/core'; import { FormControl } from '@angular/forms'; import { MatMenuTrigger } from '@angular/material/menu'; +import { AdminService } from '@ghostfolio/client/services/admin.service'; import { DataService } from '@ghostfolio/client/services/data.service'; -import { Position } from '@ghostfolio/common/interfaces'; -import { EMPTY, Subject, lastValueFrom } from 'rxjs'; +import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs'; import { catchError, debounceTime, @@ -29,13 +29,13 @@ import { } from 'rxjs/operators'; import { AssistantListItemComponent } from './assistant-list-item/assistant-list-item.component'; -import { ISearchResults } from './interfaces/interfaces'; +import { ISearchResultItem, ISearchResults } from './interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'gf-assistant', - templateUrl: './assistant.html', - styleUrls: ['./assistant.scss'] + styleUrls: ['./assistant.scss'], + templateUrl: './assistant.html' }) export class AssistantComponent implements OnDestroy, OnInit { @HostListener('document:keydown', ['$event']) onKeydown( @@ -71,6 +71,7 @@ export class AssistantComponent implements OnDestroy, OnInit { } @Input() deviceType: string; + @Input() hasPermissionToAccessAdminControl: boolean; @Output() closed = new EventEmitter(); @@ -87,6 +88,7 @@ export class AssistantComponent implements OnDestroy, OnInit { public placeholder = $localize`Find holding...`; public searchFormControl = new FormControl(''); public searchResults: ISearchResults = { + assetProfiles: [], holdings: [] }; @@ -94,6 +96,7 @@ export class AssistantComponent implements OnDestroy, OnInit { private unsubscribeSubject = new Subject(); public constructor( + private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, private dataService: DataService ) {} @@ -104,6 +107,7 @@ export class AssistantComponent implements OnDestroy, OnInit { map((searchTerm) => { this.isLoading = true; this.searchResults = { + assetProfiles: [], holdings: [] }; @@ -115,6 +119,7 @@ export class AssistantComponent implements OnDestroy, OnInit { distinctUntilChanged(), mergeMap(async (searchTerm) => { const result = { + assetProfiles: [], holdings: [] }; @@ -140,6 +145,7 @@ export class AssistantComponent implements OnDestroy, OnInit { this.isLoading = true; this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap(); this.searchResults = { + assetProfiles: [], holdings: [] }; @@ -180,10 +186,23 @@ export class AssistantComponent implements OnDestroy, OnInit { } private async getSearchResults(aSearchTerm: string) { - let holdings: Position[] = []; + let assetProfiles: ISearchResultItem[] = []; + let holdings: ISearchResultItem[] = []; + + if (this.hasPermissionToAccessAdminControl) { + try { + assetProfiles = await lastValueFrom( + this.searchAssetProfiles(aSearchTerm) + ); + assetProfiles = assetProfiles.slice( + 0, + AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT + ); + } catch {} + } try { - holdings = await lastValueFrom(this.searchHolding(aSearchTerm)); + holdings = await lastValueFrom(this.searchHoldings(aSearchTerm)); holdings = holdings.slice( 0, AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT @@ -191,11 +210,38 @@ export class AssistantComponent implements OnDestroy, OnInit { } catch {} return { + assetProfiles, holdings }; } - private searchHolding(aSearchTerm: string) { + private searchAssetProfiles( + aSearchTerm: string + ): Observable { + return this.adminService + .fetchAdminMarketData({ + filters: [ + { + id: aSearchTerm, + type: 'SEARCH_QUERY' + } + ], + take: AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT + }) + .pipe( + catchError(() => { + return EMPTY; + }), + map(({ marketData }) => { + return marketData.map(({ dataSource, name, symbol }) => { + return { dataSource, name, symbol }; + }); + }), + takeUntil(this.unsubscribeSubject) + ); + } + + private searchHoldings(aSearchTerm: string): Observable { return this.dataService .fetchPositions({ filters: [ @@ -211,7 +257,9 @@ export class AssistantComponent implements OnDestroy, OnInit { return EMPTY; }), map(({ positions }) => { - return positions; + return positions.map(({ dataSource, name, symbol }) => { + return { dataSource, name, symbol }; + }); }), takeUntil(this.unsubscribeSubject) ); diff --git a/libs/ui/src/lib/assistant/assistant.html b/libs/ui/src/lib/assistant/assistant.html index c5db29658..0644c945e 100644 --- a/libs/ui/src/lib/assistant/assistant.html +++ b/libs/ui/src/lib/assistant/assistant.html @@ -45,8 +45,9 @@
Holdings
@@ -62,5 +63,26 @@
No entries...
+
+
Asset Profiles
+ + + +
No entries...
+
+
diff --git a/libs/ui/src/lib/assistant/interfaces/interfaces.ts b/libs/ui/src/lib/assistant/interfaces/interfaces.ts index 922091fb5..ae3831af6 100644 --- a/libs/ui/src/lib/assistant/interfaces/interfaces.ts +++ b/libs/ui/src/lib/assistant/interfaces/interfaces.ts @@ -1,5 +1,10 @@ -import { Position } from '@ghostfolio/common/interfaces'; +import { UniqueAsset } from '@ghostfolio/common/interfaces'; + +export interface ISearchResultItem extends UniqueAsset { + name: string; +} export interface ISearchResults { - holdings: Position[]; + assetProfiles: ISearchResultItem[]; + holdings: ISearchResultItem[]; }