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 a0744db9f..547413a77 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 @@ -1,5 +1,9 @@ import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; -import { ISearchResultItem } from '@ghostfolio/ui/assistant/interfaces/interfaces'; +import { SearchMode } from '@ghostfolio/ui/assistant/enums/search-mode'; +import { + IAssetSearchResultItem, + ISearchResultItem +} from '@ghostfolio/ui/assistant/interfaces/interfaces'; import { FocusableOption } from '@angular/cdk/a11y'; import { @@ -32,7 +36,6 @@ export class GfAssistantListItemComponent } @Input() item: ISearchResultItem; - @Input() mode: 'assetProfile' | 'holding'; @Output() clicked = new EventEmitter(); @@ -45,23 +48,23 @@ export class GfAssistantListItemComponent public constructor(private changeDetectorRef: ChangeDetectorRef) {} public ngOnChanges() { - const dataSource = this.item?.dataSource; - const symbol = this.item?.symbol; - - if (this.mode === 'assetProfile') { + if (this.item?.mode === SearchMode.ASSET_PROFILE) { this.queryParams = { - dataSource, - symbol, - assetProfileDialog: true + assetProfileDialog: true, + dataSource: this.item?.dataSource, + symbol: this.item?.symbol }; this.routerLink = ['/admin', 'market-data']; - } else if (this.mode === 'holding') { + } else if (this.item?.mode === SearchMode.HOLDING) { this.queryParams = { - dataSource, - symbol, - holdingDetailDialog: true + dataSource: this.item?.dataSource, + holdingDetailDialog: true, + symbol: this.item?.symbol }; this.routerLink = []; + } else if (this.item?.mode === SearchMode.QUICKLINK) { + this.queryParams = {}; + this.routerLink = this.item.routerLink; } } @@ -71,6 +74,14 @@ export class GfAssistantListItemComponent this.changeDetectorRef.markForCheck(); } + public isAssetProfileOrHoldingItem( + item: ISearchResultItem + ): item is IAssetSearchResultItem { + return ( + item.mode === SearchMode.ASSET_PROFILE || item.mode === SearchMode.HOLDING + ); + } + public onClick() { this.clicked.emit(); } 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 46c8a4c24..7721e507f 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 @@ -7,11 +7,13 @@ >{{ item?.name }} -
- {{ item?.symbol | gfSymbol }} · {{ item?.currency }} - @if (item?.assetSubClassString) { - · {{ item.assetSubClassString }} - } - + @if (item && isAssetProfileOrHoldingItem(item)) { +
+ {{ item?.symbol | gfSymbol }} · {{ item?.currency }} + @if (item?.assetSubClassString) { + · {{ item.assetSubClassString }} + } + + } + diff --git a/libs/ui/src/lib/assistant/assistant.component.ts b/libs/ui/src/lib/assistant/assistant.component.ts index 2aac45146..3ed729ac8 100644 --- a/libs/ui/src/lib/assistant/assistant.component.ts +++ b/libs/ui/src/lib/assistant/assistant.component.ts @@ -3,6 +3,7 @@ import { AdminService } from '@ghostfolio/client/services/admin.service'; import { DataService } from '@ghostfolio/client/services/data.service'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { Filter, PortfolioPosition, User } from '@ghostfolio/common/interfaces'; +import { internalRoutes, IRoute } from '@ghostfolio/common/routes'; import { DateRange } from '@ghostfolio/common/types'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { translate } from '@ghostfolio/ui/i18n'; @@ -39,17 +40,20 @@ import { MatSelectModule } from '@angular/material/select'; import { RouterModule } from '@angular/router'; import { Account, AssetClass, DataSource } from '@prisma/client'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; -import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs'; +import { EMPTY, Observable, Subject, merge, of } from 'rxjs'; import { catchError, debounceTime, distinctUntilChanged, map, - mergeMap, - takeUntil + scan, + switchMap, + takeUntil, + tap } from 'rxjs/operators'; import { GfAssistantListItemComponent } from './assistant-list-item/assistant-list-item.component'; +import { SearchMode } from './enums/search-mode'; import { IDateRangeOption, ISearchResultItem, @@ -138,13 +142,18 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { tag: new FormControl(undefined) }); public holdings: PortfolioPosition[] = []; - public isLoading = false; + public isLoading = { + assetProfiles: false, + holdings: false, + quickLinks: false + }; public isOpen = false; - public placeholder = $localize`Find holding...`; + public placeholder = $localize`Find holding or page...`; public searchFormControl = new FormControl(''); public searchResults: ISearchResults = { assetProfiles: [], - holdings: [] + holdings: [], + quickLinks: [] }; public tags: Filter[] = []; @@ -177,39 +186,139 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { this.searchFormControl.valueChanges .pipe( map((searchTerm) => { - this.isLoading = true; + this.isLoading = { + assetProfiles: true, + holdings: true, + quickLinks: true + }; this.searchResults = { assetProfiles: [], - holdings: [] + holdings: [], + quickLinks: [] }; this.changeDetectorRef.markForCheck(); - return searchTerm; + return searchTerm?.trim(); }), debounceTime(300), distinctUntilChanged(), - mergeMap(async (searchTerm) => { - const result = { + switchMap((searchTerm) => { + const results = { assetProfiles: [], - holdings: [] + holdings: [], + quickLinks: [] } as ISearchResults; + if (!searchTerm) { + return of(results).pipe( + tap(() => { + this.isLoading = { + assetProfiles: false, + holdings: false, + quickLinks: false + }; + }) + ); + } - try { - if (searchTerm) { - return await this.getSearchResults(searchTerm); - } - } catch {} + // QuickLinks + const quickLinksData = this.searchQuickLinks(searchTerm); + const quickLinks$: Observable> = of({ + quickLinks: quickLinksData + }).pipe( + tap(() => { + this.isLoading.quickLinks = false; + this.changeDetectorRef.markForCheck(); + }) + ); - return result; + // Asset Profiles + const assetProfiles$: Observable> = 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> = + 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) => ({ + ...acc, + ...curr + }), + { + assetProfiles: [], + holdings: [], + quickLinks: [] + } as ISearchResults + ) + ); }), takeUntil(this.unsubscribeSubject) ) - .subscribe((searchResults) => { - this.searchResults = searchResults; - this.isLoading = false; - - this.changeDetectorRef.markForCheck(); + .subscribe({ + next: (searchResults) => { + this.searchResults = searchResults; + 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() { - this.isLoading = true; + this.isLoading = { + assetProfiles: true, + holdings: true, + quickLinks: true + }; this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap(); this.searchResults = { assetProfiles: [], - holdings: [] + holdings: [], + quickLinks: [] }; for (const item of this.assistantListItems) { @@ -323,7 +437,11 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { this.searchElement?.nativeElement?.focus(); }); - this.isLoading = false; + this.isLoading = { + assetProfiles: false, + holdings: false, + quickLinks: false + }; this.setIsOpen(true); 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( aSearchTerm: string ): Observable { @@ -467,7 +555,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { dataSource, name, 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, name, 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() { const dataSource = this.user?.settings?.[ 'filters.dataSource' diff --git a/libs/ui/src/lib/assistant/assistant.html b/libs/ui/src/lib/assistant/assistant.html index a0e19674a..f885645a3 100644 --- a/libs/ui/src/lib/assistant/assistant.html +++ b/libs/ui/src/lib/assistant/assistant.html @@ -37,19 +37,18 @@ }
Holdings
- @if (isLoading) { + @if (isLoading.holdings) { Asset Profiles
- @if (isLoading) { + @if (isLoading.assetProfiles) { + + } @else { +
No entries...
+ } +
+
+
+
Quick Links
+ + + @if (isLoading.quickLinks) {