|
@ -3,6 +3,7 @@ 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 { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; |
|
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; |
|
|
import { Filter, PortfolioPosition, User } from '@ghostfolio/common/interfaces'; |
|
|
import { Filter, PortfolioPosition, User } from '@ghostfolio/common/interfaces'; |
|
|
|
|
|
import { internalRoutes, IRoute } from '@ghostfolio/common/routes'; |
|
|
import { DateRange } from '@ghostfolio/common/types'; |
|
|
import { DateRange } from '@ghostfolio/common/types'; |
|
|
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; |
|
|
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; |
|
|
import { translate } from '@ghostfolio/ui/i18n'; |
|
|
import { translate } from '@ghostfolio/ui/i18n'; |
|
@ -39,17 +40,20 @@ import { MatSelectModule } from '@angular/material/select'; |
|
|
import { RouterModule } from '@angular/router'; |
|
|
import { RouterModule } from '@angular/router'; |
|
|
import { Account, AssetClass, DataSource } from '@prisma/client'; |
|
|
import { Account, AssetClass, DataSource } from '@prisma/client'; |
|
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; |
|
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; |
|
|
import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs'; |
|
|
import { EMPTY, Observable, Subject, merge, of } from 'rxjs'; |
|
|
import { |
|
|
import { |
|
|
catchError, |
|
|
catchError, |
|
|
debounceTime, |
|
|
debounceTime, |
|
|
distinctUntilChanged, |
|
|
distinctUntilChanged, |
|
|
map, |
|
|
map, |
|
|
mergeMap, |
|
|
scan, |
|
|
takeUntil |
|
|
switchMap, |
|
|
|
|
|
takeUntil, |
|
|
|
|
|
tap |
|
|
} from 'rxjs/operators'; |
|
|
} from 'rxjs/operators'; |
|
|
|
|
|
|
|
|
import { GfAssistantListItemComponent } from './assistant-list-item/assistant-list-item.component'; |
|
|
import { GfAssistantListItemComponent } from './assistant-list-item/assistant-list-item.component'; |
|
|
|
|
|
import { SearchMode } from './enums/search-mode'; |
|
|
import { |
|
|
import { |
|
|
IDateRangeOption, |
|
|
IDateRangeOption, |
|
|
ISearchResultItem, |
|
|
ISearchResultItem, |
|
@ -138,13 +142,18 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { |
|
|
tag: new FormControl<string>(undefined) |
|
|
tag: new FormControl<string>(undefined) |
|
|
}); |
|
|
}); |
|
|
public holdings: PortfolioPosition[] = []; |
|
|
public holdings: PortfolioPosition[] = []; |
|
|
public isLoading = false; |
|
|
public isLoading = { |
|
|
|
|
|
assetProfiles: false, |
|
|
|
|
|
holdings: false, |
|
|
|
|
|
quickLinks: false |
|
|
|
|
|
}; |
|
|
public isOpen = false; |
|
|
public isOpen = false; |
|
|
public placeholder = $localize`Find holding...`; |
|
|
public placeholder = $localize`Find holding or page...`; |
|
|
public searchFormControl = new FormControl(''); |
|
|
public searchFormControl = new FormControl(''); |
|
|
public searchResults: ISearchResults = { |
|
|
public searchResults: ISearchResults = { |
|
|
assetProfiles: [], |
|
|
assetProfiles: [], |
|
|
holdings: [] |
|
|
holdings: [], |
|
|
|
|
|
quickLinks: [] |
|
|
}; |
|
|
}; |
|
|
public tags: Filter[] = []; |
|
|
public tags: Filter[] = []; |
|
|
|
|
|
|
|
@ -177,39 +186,139 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { |
|
|
this.searchFormControl.valueChanges |
|
|
this.searchFormControl.valueChanges |
|
|
.pipe( |
|
|
.pipe( |
|
|
map((searchTerm) => { |
|
|
map((searchTerm) => { |
|
|
this.isLoading = true; |
|
|
this.isLoading = { |
|
|
|
|
|
assetProfiles: true, |
|
|
|
|
|
holdings: true, |
|
|
|
|
|
quickLinks: true |
|
|
|
|
|
}; |
|
|
this.searchResults = { |
|
|
this.searchResults = { |
|
|
assetProfiles: [], |
|
|
assetProfiles: [], |
|
|
holdings: [] |
|
|
holdings: [], |
|
|
|
|
|
quickLinks: [] |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
this.changeDetectorRef.markForCheck(); |
|
|
this.changeDetectorRef.markForCheck(); |
|
|
|
|
|
|
|
|
return searchTerm; |
|
|
return searchTerm?.trim(); |
|
|
}), |
|
|
}), |
|
|
debounceTime(300), |
|
|
debounceTime(300), |
|
|
distinctUntilChanged(), |
|
|
distinctUntilChanged(), |
|
|
mergeMap(async (searchTerm) => { |
|
|
switchMap((searchTerm) => { |
|
|
const result = { |
|
|
const results = { |
|
|
assetProfiles: [], |
|
|
assetProfiles: [], |
|
|
holdings: [] |
|
|
holdings: [], |
|
|
|
|
|
quickLinks: [] |
|
|
} as ISearchResults; |
|
|
} as ISearchResults; |
|
|
|
|
|
if (!searchTerm) { |
|
|
|
|
|
return of(results).pipe( |
|
|
|
|
|
tap(() => { |
|
|
|
|
|
this.isLoading = { |
|
|
|
|
|
assetProfiles: false, |
|
|
|
|
|
holdings: false, |
|
|
|
|
|
quickLinks: false |
|
|
|
|
|
}; |
|
|
|
|
|
}) |
|
|
|
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
try { |
|
|
// QuickLinks
|
|
|
if (searchTerm) { |
|
|
const quickLinksData = this.searchQuickLinks(searchTerm); |
|
|
return await this.getSearchResults(searchTerm); |
|
|
const quickLinks$: Observable<Partial<ISearchResults>> = of({ |
|
|
} |
|
|
quickLinks: quickLinksData |
|
|
} catch {} |
|
|
}).pipe( |
|
|
|
|
|
tap(() => { |
|
|
|
|
|
this.isLoading.quickLinks = false; |
|
|
|
|
|
this.changeDetectorRef.markForCheck(); |
|
|
|
|
|
}) |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
return result; |
|
|
// Asset Profiles
|
|
|
|
|
|
const assetProfiles$: Observable<Partial<ISearchResults>> = this |
|
|
|
|
|
.hasPermissionToAccessAdminControl |
|
|
|
|
|
? this.searchAssetProfiles(searchTerm).pipe( |
|
|
|
|
|
map((profiles) => ({ |
|
|
|
|
|
assetProfiles: profiles.slice( |
|
|
|
|
|
0, |
|
|
|
|
|
GfAssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT |
|
|
|
|
|
) |
|
|
|
|
|
})), |
|
|
|
|
|
catchError((error) => { |
|
|
|
|
|
console.error( |
|
|
|
|
|
'Error fetching asset profiles for assistant:', |
|
|
|
|
|
error |
|
|
|
|
|
); |
|
|
|
|
|
return of({ assetProfiles: [] as ISearchResultItem[] }); |
|
|
|
|
|
}), |
|
|
|
|
|
tap(() => { |
|
|
|
|
|
this.isLoading.assetProfiles = false; |
|
|
|
|
|
this.changeDetectorRef.markForCheck(); |
|
|
|
|
|
}) |
|
|
|
|
|
) |
|
|
|
|
|
: of({ assetProfiles: [] as ISearchResultItem[] }).pipe( |
|
|
|
|
|
tap(() => { |
|
|
|
|
|
this.isLoading.assetProfiles = false; |
|
|
|
|
|
this.changeDetectorRef.markForCheck(); |
|
|
|
|
|
}) |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
// Holdings
|
|
|
|
|
|
const holdings$: Observable<Partial<ISearchResults>> = |
|
|
|
|
|
this.searchHoldings(searchTerm).pipe( |
|
|
|
|
|
map((h) => ({ |
|
|
|
|
|
holdings: h.slice( |
|
|
|
|
|
0, |
|
|
|
|
|
GfAssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT |
|
|
|
|
|
) |
|
|
|
|
|
})), |
|
|
|
|
|
catchError((error) => { |
|
|
|
|
|
console.error('Error fetching holdings for assistant:', error); |
|
|
|
|
|
return of({ holdings: [] as ISearchResultItem[] }); |
|
|
|
|
|
}), |
|
|
|
|
|
tap(() => { |
|
|
|
|
|
this.isLoading.holdings = false; |
|
|
|
|
|
this.changeDetectorRef.markForCheck(); |
|
|
|
|
|
}) |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
// 4. Emit initial results first, then the combined results when async operations complete
|
|
|
|
|
|
return merge(quickLinks$, assetProfiles$, holdings$).pipe( |
|
|
|
|
|
scan( |
|
|
|
|
|
(acc: ISearchResults, curr: Partial<ISearchResults>) => ({ |
|
|
|
|
|
...acc, |
|
|
|
|
|
...curr |
|
|
|
|
|
}), |
|
|
|
|
|
{ |
|
|
|
|
|
assetProfiles: [], |
|
|
|
|
|
holdings: [], |
|
|
|
|
|
quickLinks: [] |
|
|
|
|
|
} as ISearchResults |
|
|
|
|
|
) |
|
|
|
|
|
); |
|
|
}), |
|
|
}), |
|
|
takeUntil(this.unsubscribeSubject) |
|
|
takeUntil(this.unsubscribeSubject) |
|
|
) |
|
|
) |
|
|
.subscribe((searchResults) => { |
|
|
.subscribe({ |
|
|
this.searchResults = searchResults; |
|
|
next: (searchResults) => { |
|
|
this.isLoading = false; |
|
|
this.searchResults = searchResults; |
|
|
|
|
|
this.changeDetectorRef.markForCheck(); |
|
|
this.changeDetectorRef.markForCheck(); |
|
|
}, |
|
|
|
|
|
error: (err) => { |
|
|
|
|
|
console.error('Assistant search stream error:', err); |
|
|
|
|
|
this.searchResults = { |
|
|
|
|
|
assetProfiles: [], |
|
|
|
|
|
holdings: [], |
|
|
|
|
|
quickLinks: [] |
|
|
|
|
|
}; |
|
|
|
|
|
this.changeDetectorRef.markForCheck(); |
|
|
|
|
|
}, |
|
|
|
|
|
complete: () => { |
|
|
|
|
|
this.isLoading = { |
|
|
|
|
|
assetProfiles: false, |
|
|
|
|
|
holdings: false, |
|
|
|
|
|
quickLinks: false |
|
|
|
|
|
}; |
|
|
|
|
|
this.changeDetectorRef.markForCheck(); |
|
|
|
|
|
} |
|
|
}); |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
@ -307,11 +416,16 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
public initialize() { |
|
|
public initialize() { |
|
|
this.isLoading = true; |
|
|
this.isLoading = { |
|
|
|
|
|
assetProfiles: true, |
|
|
|
|
|
holdings: true, |
|
|
|
|
|
quickLinks: true |
|
|
|
|
|
}; |
|
|
this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap(); |
|
|
this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap(); |
|
|
this.searchResults = { |
|
|
this.searchResults = { |
|
|
assetProfiles: [], |
|
|
assetProfiles: [], |
|
|
holdings: [] |
|
|
holdings: [], |
|
|
|
|
|
quickLinks: [] |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
for (const item of this.assistantListItems) { |
|
|
for (const item of this.assistantListItems) { |
|
@ -323,7 +437,11 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { |
|
|
this.searchElement?.nativeElement?.focus(); |
|
|
this.searchElement?.nativeElement?.focus(); |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
this.isLoading = false; |
|
|
this.isLoading = { |
|
|
|
|
|
assetProfiles: false, |
|
|
|
|
|
holdings: false, |
|
|
|
|
|
quickLinks: false |
|
|
|
|
|
}; |
|
|
this.setIsOpen(true); |
|
|
this.setIsOpen(true); |
|
|
|
|
|
|
|
|
this.dataService |
|
|
this.dataService |
|
@ -412,36 +530,6 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { |
|
|
}); |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
private async getSearchResults(aSearchTerm: string) { |
|
|
|
|
|
let assetProfiles: ISearchResultItem[] = []; |
|
|
|
|
|
let holdings: ISearchResultItem[] = []; |
|
|
|
|
|
|
|
|
|
|
|
if (this.hasPermissionToAccessAdminControl) { |
|
|
|
|
|
try { |
|
|
|
|
|
assetProfiles = await lastValueFrom( |
|
|
|
|
|
this.searchAssetProfiles(aSearchTerm) |
|
|
|
|
|
); |
|
|
|
|
|
assetProfiles = assetProfiles.slice( |
|
|
|
|
|
0, |
|
|
|
|
|
GfAssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT |
|
|
|
|
|
); |
|
|
|
|
|
} catch {} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
try { |
|
|
|
|
|
holdings = await lastValueFrom(this.searchHoldings(aSearchTerm)); |
|
|
|
|
|
holdings = holdings.slice( |
|
|
|
|
|
0, |
|
|
|
|
|
GfAssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT |
|
|
|
|
|
); |
|
|
|
|
|
} catch {} |
|
|
|
|
|
|
|
|
|
|
|
return { |
|
|
|
|
|
assetProfiles, |
|
|
|
|
|
holdings |
|
|
|
|
|
}; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private searchAssetProfiles( |
|
|
private searchAssetProfiles( |
|
|
aSearchTerm: string |
|
|
aSearchTerm: string |
|
|
): Observable<ISearchResultItem[]> { |
|
|
): Observable<ISearchResultItem[]> { |
|
@ -467,7 +555,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { |
|
|
dataSource, |
|
|
dataSource, |
|
|
name, |
|
|
name, |
|
|
symbol, |
|
|
symbol, |
|
|
assetSubClassString: translate(assetSubClass) |
|
|
assetSubClassString: translate(assetSubClass), |
|
|
|
|
|
mode: SearchMode.ASSET_PROFILE as const |
|
|
}; |
|
|
}; |
|
|
} |
|
|
} |
|
|
); |
|
|
); |
|
@ -499,7 +588,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { |
|
|
dataSource, |
|
|
dataSource, |
|
|
name, |
|
|
name, |
|
|
symbol, |
|
|
symbol, |
|
|
assetSubClassString: translate(assetSubClass) |
|
|
assetSubClassString: translate(assetSubClass), |
|
|
|
|
|
mode: SearchMode.HOLDING as const |
|
|
}; |
|
|
}; |
|
|
} |
|
|
} |
|
|
); |
|
|
); |
|
@ -508,6 +598,37 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { |
|
|
); |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private searchQuickLinks(aSearchTerm: string): ISearchResultItem[] { |
|
|
|
|
|
const term = aSearchTerm.toLowerCase(); |
|
|
|
|
|
|
|
|
|
|
|
const allRoutes = Object.values(internalRoutes) |
|
|
|
|
|
.filter((route) => { |
|
|
|
|
|
return !route.excludeFromAssistant; |
|
|
|
|
|
}) |
|
|
|
|
|
.reduce((acc, route) => { |
|
|
|
|
|
acc.push(route); |
|
|
|
|
|
if (route.subRoutes) { |
|
|
|
|
|
acc.push(...Object.values(route.subRoutes)); |
|
|
|
|
|
} |
|
|
|
|
|
return acc; |
|
|
|
|
|
}, [] as IRoute[]); |
|
|
|
|
|
|
|
|
|
|
|
return allRoutes |
|
|
|
|
|
.filter((route) => { |
|
|
|
|
|
return route.title.toLowerCase().includes(term); |
|
|
|
|
|
}) |
|
|
|
|
|
.map((route) => { |
|
|
|
|
|
return { |
|
|
|
|
|
mode: SearchMode.QUICKLINK as const, |
|
|
|
|
|
name: route.title, |
|
|
|
|
|
routerLink: route.routerLink |
|
|
|
|
|
}; |
|
|
|
|
|
}) |
|
|
|
|
|
.sort((a, b) => { |
|
|
|
|
|
return a.name.localeCompare(b.name); |
|
|
|
|
|
}); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
private setFilterFormValues() { |
|
|
private setFilterFormValues() { |
|
|
const dataSource = this.user?.settings?.[ |
|
|
const dataSource = this.user?.settings?.[ |
|
|
'filters.dataSource' |
|
|
'filters.dataSource' |
|
|