import { FocusKeyManager } from '@angular/cdk/a11y'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, HostListener, Input, OnChanges, OnDestroy, OnInit, Output, QueryList, ViewChild, ViewChildren } from '@angular/core'; import { FormBuilder, 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 { Filter, User } from '@ghostfolio/common/interfaces'; import { DateRange } from '@ghostfolio/common/types'; import { translate } from '@ghostfolio/ui/i18n'; import { Account, Tag } from '@prisma/client'; import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs'; import { catchError, debounceTime, distinctUntilChanged, map, mergeMap, takeUntil } from 'rxjs/operators'; import { AssistantListItemComponent } from './assistant-list-item/assistant-list-item.component'; import { ISearchResultItem, ISearchResults } from './interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'gf-assistant', styleUrls: ['./assistant.scss'], templateUrl: './assistant.html' }) export class AssistantComponent implements OnChanges, OnDestroy, OnInit { @HostListener('document:keydown', ['$event']) onKeydown( event: KeyboardEvent ) { if (!this.isOpen) { return; } if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { for (const item of this.assistantListItems) { item.removeFocus(); } this.keyManager.onKeydown(event); const currentAssistantListItem = this.getCurrentAssistantListItem(); if (currentAssistantListItem?.linkElement) { currentAssistantListItem.linkElement.nativeElement?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } else if (event.key === 'Enter') { const currentAssistantListItem = this.getCurrentAssistantListItem(); if (currentAssistantListItem?.linkElement) { currentAssistantListItem.linkElement.nativeElement?.click(); event.stopPropagation(); } } } @Input() deviceType: string; @Input() hasPermissionToAccessAdminControl: boolean; @Input() user: User; @Output() closed = new EventEmitter<void>(); @Output() dateRangeChanged = new EventEmitter<DateRange>(); @Output() filtersChanged = new EventEmitter<Filter[]>(); @ViewChild('menuTrigger') menuTriggerElement: MatMenuTrigger; @ViewChild('search', { static: true }) searchElement: ElementRef; @ViewChildren(AssistantListItemComponent) assistantListItems: QueryList<AssistantListItemComponent>; public static readonly SEARCH_RESULTS_DEFAULT_LIMIT = 5; public accounts: Account[] = []; public dateRangeFormControl = new FormControl<string>(undefined); public readonly dateRangeOptions = [ { label: $localize`Today`, value: '1d' }, { label: $localize`Week to date` + ' (' + $localize`WTD` + ')', value: 'wtd' }, { label: $localize`Month to date` + ' (' + $localize`MTD` + ')', value: 'mtd' }, { label: $localize`Year to date` + ' (' + $localize`YTD` + ')', value: 'ytd' }, { label: $localize`1Y`, value: '1y' }, { label: $localize`5Y`, value: '5y' }, { label: $localize`Max`, value: 'max' } ]; public filterForm = this.formBuilder.group({ account: new FormControl<string>(undefined), tag: new FormControl<string>(undefined) }); public isLoading = false; public isOpen = false; public placeholder = $localize`Find holding...`; public searchFormControl = new FormControl(''); public searchResults: ISearchResults = { assetProfiles: [], holdings: [] }; public tags: Tag[] = []; private keyManager: FocusKeyManager<AssistantListItemComponent>; private unsubscribeSubject = new Subject<void>(); public constructor( private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private formBuilder: FormBuilder ) {} public ngOnInit() { this.accounts = this.user?.accounts; this.tags = this.user?.tags.map(({ id, name }) => { return { id, name: translate(name) }; }); this.filterForm.valueChanges .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ account, tag }) => { this.filtersChanged.emit([ { id: account, type: 'ACCOUNT' }, { id: tag, type: 'TAG' } ]); this.onCloseAssistant(); }); this.searchFormControl.valueChanges .pipe( map((searchTerm) => { this.isLoading = true; this.searchResults = { assetProfiles: [], holdings: [] }; this.changeDetectorRef.markForCheck(); return searchTerm; }), debounceTime(300), distinctUntilChanged(), mergeMap(async (searchTerm) => { const result = <ISearchResults>{ assetProfiles: [], holdings: [] }; try { if (searchTerm) { return await this.getSearchResults(searchTerm); } } catch {} return result; }), takeUntil(this.unsubscribeSubject) ) .subscribe((searchResults) => { this.searchResults = searchResults; this.isLoading = false; this.changeDetectorRef.markForCheck(); }); } public ngOnChanges() { this.dateRangeFormControl.setValue(this.user?.settings?.dateRange ?? null); this.filterForm.setValue( { account: this.user?.settings?.['filters.accounts']?.[0] ?? null, tag: this.user?.settings?.['filters.tags']?.[0] ?? null }, { emitEvent: false } ); } public hasFilter(aFormValue: { [key: string]: string }) { return Object.values(aFormValue).some((value) => { return !!value; }); } public async initialize() { this.isLoading = true; this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap(); this.searchResults = { assetProfiles: [], holdings: [] }; for (const item of this.assistantListItems) { item.removeFocus(); } this.searchFormControl.setValue(''); setTimeout(() => { this.searchElement?.nativeElement?.focus(); }); this.isLoading = false; this.setIsOpen(true); this.changeDetectorRef.markForCheck(); } public onChangeDateRange(dateRangeString: string) { this.dateRangeChanged.emit(dateRangeString as DateRange); } public onCloseAssistant() { this.setIsOpen(false); this.closed.emit(); } public onResetFilters() { this.filtersChanged.emit([ { id: null, type: 'ACCOUNT' }, { id: null, type: 'TAG' } ]); this.onCloseAssistant(); } public setIsOpen(aIsOpen: boolean) { this.isOpen = aIsOpen; } public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); } private getCurrentAssistantListItem() { return this.assistantListItems.find(({ getHasFocus }) => { return getHasFocus; }); } 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, AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT ); } catch {} } try { holdings = await lastValueFrom(this.searchHoldings(aSearchTerm)); holdings = holdings.slice( 0, AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT ); } catch {} return { assetProfiles, holdings }; } 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( ({ assetSubClass, currency, dataSource, name, symbol }) => { return { currency, dataSource, name, symbol, assetSubClassString: translate(assetSubClass) }; } ); }), takeUntil(this.unsubscribeSubject) ); } private searchHoldings(aSearchTerm: string): Observable<ISearchResultItem[]> { return this.dataService .fetchPositions({ filters: [ { id: aSearchTerm, type: 'SEARCH_QUERY' } ], range: '1d' }) .pipe( catchError(() => { return EMPTY; }), map(({ positions }) => { return positions.map( ({ assetSubClass, currency, dataSource, name, symbol }) => { return { currency, dataSource, name, symbol, assetSubClassString: translate(assetSubClass) }; } ); }), takeUntil(this.unsubscribeSubject) ); } }