Browse Source

Extend assistant with search for asset profile

pull/2499/head
Thomas 2 years ago
parent
commit
4c0ba5df11
  1. 21
      apps/api/src/app/admin/admin.controller.ts
  2. 2
      apps/api/src/app/admin/admin.module.ts
  3. 20
      apps/api/src/app/admin/admin.service.ts
  4. 9
      apps/api/src/services/api/api.service.ts
  5. 3
      apps/client/src/app/components/header/header.component.html
  6. 1
      libs/common/src/lib/interfaces/admin-market-data.interface.ts
  7. 30
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts
  8. 10
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html
  9. 66
      libs/ui/src/lib/assistant/assistant.component.ts
  10. 26
      libs/ui/src/lib/assistant/assistant.html
  11. 9
      libs/ui/src/lib/assistant/interfaces/interfaces.ts

21
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 { 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 { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { import {
DEFAULT_PAGE_SIZE,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -12,8 +12,7 @@ import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
EnhancedSymbolProfile, EnhancedSymbolProfile
Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { import type {
@ -50,6 +49,7 @@ import { UpdateMarketDataDto } from './update-market-data.dto';
export class AdminController { export class AdminController {
public constructor( public constructor(
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
@ -255,6 +255,7 @@ export class AdminController {
public async getMarketData( public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string, @Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('presetId') presetId?: MarketDataPreset, @Query('presetId') presetId?: MarketDataPreset,
@Query('query') filterBySearchQuery?: string,
@Query('skip') skip?: number, @Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string, @Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder, @Query('sortDirection') sortDirection?: Prisma.SortOrder,
@ -272,16 +273,10 @@ export class AdminController {
); );
} }
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? []; const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses,
const filters: Filter[] = [ filterBySearchQuery
...assetSubClasses.map((assetSubClass) => { });
return <Filter>{
id: assetSubClass,
type: 'ASSET_SUB_CLASS'
};
})
];
return this.adminService.getMarketData({ return this.adminService.getMarketData({
filters, filters,

2
apps/api/src/app/admin/admin.module.ts

@ -1,4 +1,5 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; 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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -15,6 +16,7 @@ import { QueueModule } from './queue/queue.module';
@Module({ @Module({
imports: [ imports: [
ApiModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,

20
apps/api/src/app/admin/admin.service.ts

@ -131,10 +131,14 @@ export class AdminService {
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }]; filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
} }
const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy( const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
filters, filters,
(filter) => { ({ type }) => {
return filter.type; return type;
} }
); );
@ -147,6 +151,14 @@ export class AdminService {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id]; 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) { if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }]; orderBy = [{ [sortColumn]: sortDirection }];
@ -174,6 +186,7 @@ export class AdminService {
comment: true, comment: true,
countries: true, countries: true,
dataSource: true, dataSource: true,
name: true,
Order: { Order: {
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
select: { date: true }, select: { date: true },
@ -195,6 +208,7 @@ export class AdminService {
comment, comment,
countries, countries,
dataSource, dataSource,
name,
Order, Order,
sectors, sectors,
symbol symbol
@ -215,6 +229,7 @@ export class AdminService {
comment, comment,
countriesCount, countriesCount,
dataSource, dataSource,
name,
symbol, symbol,
marketDataItemCount, marketDataItemCount,
sectorsCount, sectorsCount,
@ -341,6 +356,7 @@ export class AdminService {
symbol, symbol,
assetClass: 'CASH', assetClass: 'CASH',
countriesCount: 0, countriesCount: 0,
name: symbol,
sectorsCount: 0 sectorsCount: 0
}; };
}); });

9
apps/api/src/services/api/api.service.ts

@ -8,16 +8,19 @@ export class ApiService {
public buildFiltersFromQueryParams({ public buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByAssetSubClasses,
filterBySearchQuery, filterBySearchQuery,
filterByTags filterByTags
}: { }: {
filterByAccounts?: string; filterByAccounts?: string;
filterByAssetClasses?: string; filterByAssetClasses?: string;
filterByAssetSubClasses?: string;
filterBySearchQuery?: string; filterBySearchQuery?: string;
filterByTags?: string; filterByTags?: string;
}): Filter[] { }): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? []; const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? []; const assetClasses = filterByAssetClasses?.split(',') ?? [];
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const searchQuery = filterBySearchQuery?.toLowerCase(); const searchQuery = filterBySearchQuery?.toLowerCase();
const tagIds = filterByTags?.split(',') ?? []; const tagIds = filterByTags?.split(',') ?? [];
@ -34,6 +37,12 @@ export class ApiService {
type: 'ASSET_CLASS' type: 'ASSET_CLASS'
}; };
}), }),
...assetSubClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_SUB_CLASS'
};
}),
{ {
id: searchQuery, id: searchQuery,
type: 'SEARCH_QUERY' type: 'SEARCH_QUERY'

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

@ -131,6 +131,9 @@
<gf-assistant <gf-assistant
#assistant #assistant
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToAccessAdminControl]="
hasPermissionToAccessAdminControl
"
(closed)="closeAssistant()" (closed)="closeAssistant()"
/> />
</mat-menu> </mat-menu>

1
libs/common/src/lib/interfaces/admin-market-data.interface.ts

@ -12,6 +12,7 @@ export interface AdminMarketDataItem {
dataSource: DataSource; dataSource: DataSource;
date?: Date; date?: Date;
marketDataItemCount: number; marketDataItemCount: number;
name: string;
sectorsCount: number; sectorsCount: number;
symbol: string; symbol: string;
} }

30
libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts

@ -7,10 +7,15 @@ import {
EventEmitter, EventEmitter,
HostBinding, HostBinding,
Input, Input,
OnChanges,
Output, Output,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { Params } from '@angular/router';
import { Position } from '@ghostfolio/common/interfaces'; import { Position } from '@ghostfolio/common/interfaces';
import { SymbolProfile } from '@prisma/client';
import { ISearchResultItem } from '../interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -18,22 +23,43 @@ import { Position } from '@ghostfolio/common/interfaces';
templateUrl: './assistant-list-item.html', templateUrl: './assistant-list-item.html',
styleUrls: ['./assistant-list-item.scss'] styleUrls: ['./assistant-list-item.scss']
}) })
export class AssistantListItemComponent implements FocusableOption { export class AssistantListItemComponent implements FocusableOption, OnChanges {
@HostBinding('attr.tabindex') tabindex = -1; @HostBinding('attr.tabindex') tabindex = -1;
@HostBinding('class.has-focus') get getHasFocus() { @HostBinding('class.has-focus') get getHasFocus() {
return this.hasFocus; return this.hasFocus;
} }
@Input() holding: Position; @Input() item: ISearchResultItem;
@Input() mode: 'assetProfile' | 'holding';
@Output() clicked = new EventEmitter<void>(); @Output() clicked = new EventEmitter<void>();
@ViewChild('link') public linkElement: ElementRef; @ViewChild('link') public linkElement: ElementRef;
public hasFocus = false; public hasFocus = false;
public queryParams: Params;
public routerLink: string[];
public constructor(private changeDetectorRef: ChangeDetectorRef) {} 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() { public focus() {
this.hasFocus = true; this.hasFocus = true;

10
libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html

@ -1,12 +1,8 @@
<a <a
#link #link
class="d-block px-2 py-1 text-truncate" class="d-block px-2 py-1 text-truncate"
[queryParams]="{ [queryParams]="queryParams"
dataSource: holding?.dataSource, [routerLink]="routerLink"
positionDetailDialog: true,
symbol: holding?.symbol
}"
[routerLink]="['/portfolio', 'holdings']"
(click)="onClick()" (click)="onClick()"
>{{ holding?.name }}</a >{{ item?.name }}</a
> >

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

@ -16,9 +16,9 @@ import {
} from '@angular/core'; } from '@angular/core';
import { FormControl } from '@angular/forms'; import { FormControl } from '@angular/forms';
import { MatMenuTrigger } from '@angular/material/menu'; import { MatMenuTrigger } from '@angular/material/menu';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { Position } from '@ghostfolio/common/interfaces'; import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs';
import { EMPTY, Subject, lastValueFrom } from 'rxjs';
import { import {
catchError, catchError,
debounceTime, debounceTime,
@ -29,13 +29,13 @@ import {
} from 'rxjs/operators'; } from 'rxjs/operators';
import { AssistantListItemComponent } from './assistant-list-item/assistant-list-item.component'; import { AssistantListItemComponent } from './assistant-list-item/assistant-list-item.component';
import { ISearchResults } from './interfaces/interfaces'; import { ISearchResultItem, ISearchResults } from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-assistant', selector: 'gf-assistant',
templateUrl: './assistant.html', styleUrls: ['./assistant.scss'],
styleUrls: ['./assistant.scss'] templateUrl: './assistant.html'
}) })
export class AssistantComponent implements OnDestroy, OnInit { export class AssistantComponent implements OnDestroy, OnInit {
@HostListener('document:keydown', ['$event']) onKeydown( @HostListener('document:keydown', ['$event']) onKeydown(
@ -71,6 +71,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
} }
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToAccessAdminControl: boolean;
@Output() closed = new EventEmitter<void>(); @Output() closed = new EventEmitter<void>();
@ -87,6 +88,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
public placeholder = $localize`Find holding...`; public placeholder = $localize`Find holding...`;
public searchFormControl = new FormControl(''); public searchFormControl = new FormControl('');
public searchResults: ISearchResults = { public searchResults: ISearchResults = {
assetProfiles: [],
holdings: [] holdings: []
}; };
@ -94,6 +96,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService private dataService: DataService
) {} ) {}
@ -104,6 +107,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
map((searchTerm) => { map((searchTerm) => {
this.isLoading = true; this.isLoading = true;
this.searchResults = { this.searchResults = {
assetProfiles: [],
holdings: [] holdings: []
}; };
@ -115,6 +119,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
distinctUntilChanged(), distinctUntilChanged(),
mergeMap(async (searchTerm) => { mergeMap(async (searchTerm) => {
const result = <ISearchResults>{ const result = <ISearchResults>{
assetProfiles: [],
holdings: [] holdings: []
}; };
@ -140,6 +145,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
this.isLoading = true; this.isLoading = true;
this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap(); this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap();
this.searchResults = { this.searchResults = {
assetProfiles: [],
holdings: [] holdings: []
}; };
@ -180,10 +186,23 @@ export class AssistantComponent implements OnDestroy, OnInit {
} }
private async getSearchResults(aSearchTerm: string) { private async getSearchResults(aSearchTerm: string) {
let holdings: Position[] = []; let assetProfiles: ISearchResultItem[] = [];
let holdings: ISearchResultItem[] = [];
if (this.hasPermissionToAccessAdminControl) {
try { try {
holdings = await lastValueFrom(this.searchHolding(aSearchTerm)); assetProfiles = await lastValueFrom(
this.searchAssetProfiles(aSearchTerm)
);
assetProfiles = assetProfiles.slice(
0,
AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT
);
} catch {}
}
try {
holdings = await lastValueFrom(this.searchHoldings(aSearchTerm));
holdings = holdings.slice( holdings = holdings.slice(
0, 0,
AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT
@ -191,11 +210,38 @@ export class AssistantComponent implements OnDestroy, OnInit {
} catch {} } catch {}
return { return {
assetProfiles,
holdings holdings
}; };
} }
private searchHolding(aSearchTerm: string) { private searchAssetProfiles(
aSearchTerm: string
): Observable<ISearchResultItem[]> {
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<ISearchResultItem[]> {
return this.dataService return this.dataService
.fetchPositions({ .fetchPositions({
filters: [ filters: [
@ -211,7 +257,9 @@ export class AssistantComponent implements OnDestroy, OnInit {
return EMPTY; return EMPTY;
}), }),
map(({ positions }) => { map(({ positions }) => {
return positions; return positions.map(({ dataSource, name, symbol }) => {
return { dataSource, name, symbol };
});
}), }),
takeUntil(this.unsubscribeSubject) takeUntil(this.unsubscribeSubject)
); );

26
libs/ui/src/lib/assistant/assistant.html

@ -45,8 +45,9 @@
<div> <div>
<div class="h6 mb-1 px-2" i18n>Holdings</div> <div class="h6 mb-1 px-2" i18n>Holdings</div>
<gf-assistant-list-item <gf-assistant-list-item
*ngFor="let holding of searchResults?.holdings" *ngFor="let searchResultItem of searchResults?.holdings"
[holding]="holding" mode="holding"
[item]="searchResultItem"
(clicked)="onCloseAssistant()" (clicked)="onCloseAssistant()"
/> />
<ng-container *ngIf="searchResults?.holdings?.length === 0"> <ng-container *ngIf="searchResults?.holdings?.length === 0">
@ -62,5 +63,26 @@
<div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div> <div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div>
</ng-container> </ng-container>
</div> </div>
<div *ngIf="hasPermissionToAccessAdminControl" class="mt-3">
<div class="h6 mb-1 px-2" i18n>Asset Profiles</div>
<gf-assistant-list-item
*ngFor="let searchResultItem of searchResults?.assetProfiles"
mode="assetProfile"
[item]="searchResultItem"
(clicked)="onCloseAssistant()"
/>
<ng-container *ngIf="searchResults?.assetProfiles?.length === 0">
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
class="mx-2"
[theme]="{
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
<div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div>
</ng-container>
</div>
</div> </div>
</div> </div>

9
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 { export interface ISearchResults {
holdings: Position[]; assetProfiles: ISearchResultItem[];
holdings: ISearchResultItem[];
} }

Loading…
Cancel
Save