Browse Source

feat(lib): enable searching quick links

pull/4870/head
KenTandrian 4 weeks ago
parent
commit
eb6cae1c76
  1. 37
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts
  2. 18
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html
  3. 237
      libs/ui/src/lib/assistant/assistant.component.ts
  4. 30
      libs/ui/src/lib/assistant/assistant.html
  5. 5
      libs/ui/src/lib/assistant/enums/search-mode.ts
  6. 16
      libs/ui/src/lib/assistant/interfaces/interfaces.ts

37
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 { 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 { FocusableOption } from '@angular/cdk/a11y';
import { import {
@ -32,7 +36,6 @@ export class GfAssistantListItemComponent
} }
@Input() item: ISearchResultItem; @Input() item: ISearchResultItem;
@Input() mode: 'assetProfile' | 'holding';
@Output() clicked = new EventEmitter<void>(); @Output() clicked = new EventEmitter<void>();
@ -45,23 +48,23 @@ export class GfAssistantListItemComponent
public constructor(private changeDetectorRef: ChangeDetectorRef) {} public constructor(private changeDetectorRef: ChangeDetectorRef) {}
public ngOnChanges() { public ngOnChanges() {
const dataSource = this.item?.dataSource; if (this.item?.mode === SearchMode.ASSET_PROFILE) {
const symbol = this.item?.symbol;
if (this.mode === 'assetProfile') {
this.queryParams = { this.queryParams = {
dataSource, assetProfileDialog: true,
symbol, dataSource: this.item?.dataSource,
assetProfileDialog: true symbol: this.item?.symbol
}; };
this.routerLink = ['/admin', 'market-data']; this.routerLink = ['/admin', 'market-data'];
} else if (this.mode === 'holding') { } else if (this.item?.mode === SearchMode.HOLDING) {
this.queryParams = { this.queryParams = {
dataSource, dataSource: this.item?.dataSource,
symbol, holdingDetailDialog: true,
holdingDetailDialog: true symbol: this.item?.symbol
}; };
this.routerLink = []; 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(); this.changeDetectorRef.markForCheck();
} }
public isAssetProfileOrHoldingItem(
item: ISearchResultItem
): item is IAssetSearchResultItem {
return (
item.mode === SearchMode.ASSET_PROFILE || item.mode === SearchMode.HOLDING
);
}
public onClick() { public onClick() {
this.clicked.emit(); this.clicked.emit();
} }

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

@ -7,11 +7,13 @@
><span ><span
><b>{{ item?.name }}</b></span ><b>{{ item?.name }}</b></span
> >
<br /> @if (item && isAssetProfileOrHoldingItem(item)) {
<small class="text-muted" <br />
>{{ item?.symbol | gfSymbol }} · {{ item?.currency }} <small class="text-muted"
@if (item?.assetSubClassString) { >{{ item?.symbol | gfSymbol }} · {{ item?.currency }}
· {{ item.assetSubClassString }} @if (item?.assetSubClassString) {
} · {{ item.assetSubClassString }}
</small></a }
> </small>
}
</a>

237
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 { 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'

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

@ -37,19 +37,18 @@
} }
</div> </div>
<div <div
*ngIf="isLoading || searchFormControl.value" *ngIf="searchFormControl.value"
class="overflow-auto py-3 result-container" class="overflow-auto py-3 result-container"
> >
<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 searchResultItem of searchResults?.holdings" *ngFor="let searchResultItem of searchResults?.holdings"
mode="holding"
[item]="searchResultItem" [item]="searchResultItem"
(clicked)="onCloseAssistant()" (clicked)="onCloseAssistant()"
/> />
<ng-container *ngIf="searchResults?.holdings?.length === 0"> <ng-container *ngIf="searchResults?.holdings?.length === 0">
@if (isLoading) { @if (isLoading.holdings) {
<ngx-skeleton-loader <ngx-skeleton-loader
animation="pulse" animation="pulse"
class="mx-2" class="mx-2"
@ -67,12 +66,33 @@
<div class="h6 mb-1 px-2" i18n>Asset Profiles</div> <div class="h6 mb-1 px-2" i18n>Asset Profiles</div>
<gf-assistant-list-item <gf-assistant-list-item
*ngFor="let searchResultItem of searchResults?.assetProfiles" *ngFor="let searchResultItem of searchResults?.assetProfiles"
mode="assetProfile"
[item]="searchResultItem" [item]="searchResultItem"
(clicked)="onCloseAssistant()" (clicked)="onCloseAssistant()"
/> />
<ng-container *ngIf="searchResults?.assetProfiles?.length === 0"> <ng-container *ngIf="searchResults?.assetProfiles?.length === 0">
@if (isLoading) { @if (isLoading.assetProfiles) {
<ngx-skeleton-loader
animation="pulse"
class="mx-2"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
} @else {
<div class="px-2 py-1" i18n>No entries...</div>
}
</ng-container>
</div>
<div class="mt-3">
<div class="h6 mb-1 px-2" i18n>Quick Links</div>
<gf-assistant-list-item
*ngFor="let searchResultItem of searchResults?.quickLinks"
[item]="searchResultItem"
(clicked)="onCloseAssistant()"
/>
<ng-container *ngIf="searchResults?.quickLinks?.length === 0">
@if (isLoading.quickLinks) {
<ngx-skeleton-loader <ngx-skeleton-loader
animation="pulse" animation="pulse"
class="mx-2" class="mx-2"

5
libs/ui/src/lib/assistant/enums/search-mode.ts

@ -0,0 +1,5 @@
export enum SearchMode {
ASSET_PROFILE = 'assetProfile',
HOLDING = 'holding',
QUICKLINK = 'quickLink'
}

16
libs/ui/src/lib/assistant/interfaces/interfaces.ts

@ -1,18 +1,32 @@
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { SearchMode } from '../enums/search-mode';
export interface IDateRangeOption { export interface IDateRangeOption {
label: string; label: string;
value: DateRange; value: DateRange;
} }
export interface ISearchResultItem extends AssetProfileIdentifier { export interface IAssetSearchResultItem extends AssetProfileIdentifier {
assetSubClassString: string; assetSubClassString: string;
currency: string; currency: string;
mode: SearchMode.ASSET_PROFILE | SearchMode.HOLDING;
name: string; name: string;
} }
export interface IQuickLinkSearchResultItem {
mode: SearchMode.QUICKLINK;
name: string;
routerLink: string[];
}
export type ISearchResultItem =
| IAssetSearchResultItem
| IQuickLinkSearchResultItem;
export interface ISearchResults { export interface ISearchResults {
assetProfiles: ISearchResultItem[]; assetProfiles: ISearchResultItem[];
holdings: ISearchResultItem[]; holdings: ISearchResultItem[];
quickLinks: ISearchResultItem[];
} }

Loading…
Cancel
Save