parent
37ff7acf04
commit
550e646079
@ -0,0 +1,52 @@
|
||||
import { FocusableOption } from '@angular/cdk/a11y';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
HostBinding,
|
||||
Input,
|
||||
Output,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { Position } from '@ghostfolio/common/interfaces';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: 'gf-assistant-list-item',
|
||||
templateUrl: './assistant-list-item.html',
|
||||
styleUrls: ['./assistant-list-item.scss']
|
||||
})
|
||||
export class AssistantListItemComponent implements FocusableOption {
|
||||
@HostBinding('attr.tabindex') tabindex = -1;
|
||||
@HostBinding('class.has-focus') get getHasFocus() {
|
||||
return this.hasFocus;
|
||||
}
|
||||
|
||||
@Input() holding: Position;
|
||||
|
||||
@Output() clicked = new EventEmitter<void>();
|
||||
|
||||
@ViewChild('link') public linkElement: ElementRef;
|
||||
|
||||
public hasFocus = false;
|
||||
|
||||
public constructor(private changeDetectorRef: ChangeDetectorRef) {}
|
||||
|
||||
public focus() {
|
||||
this.hasFocus = true;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
|
||||
public onClick() {
|
||||
this.clicked.emit();
|
||||
}
|
||||
|
||||
public removeFocus() {
|
||||
this.hasFocus = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
<a
|
||||
#link
|
||||
class="d-block px-2 py-1 text-truncate"
|
||||
[queryParams]="{
|
||||
dataSource: holding?.dataSource,
|
||||
positionDetailDialog: true,
|
||||
symbol: holding?.symbol
|
||||
}"
|
||||
[routerLink]="['/portfolio', 'holdings']"
|
||||
(click)="onClick()"
|
||||
>{{ holding?.name }}</a
|
||||
>
|
@ -0,0 +1,12 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { AssistantListItemComponent } from './assistant-list-item.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [AssistantListItemComponent],
|
||||
exports: [AssistantListItemComponent],
|
||||
imports: [CommonModule, RouterModule]
|
||||
})
|
||||
export class GfAssistantListItemModule {}
|
@ -0,0 +1,19 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
&.has-focus {
|
||||
background-color: rgba(var(--palette-primary-500), 1);
|
||||
|
||||
a {
|
||||
color: rgba(var(--light-primary-text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
&.has-focus {
|
||||
a {
|
||||
color: rgba(var(--dark-primary-text));
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,219 @@
|
||||
import { FocusKeyManager } from '@angular/cdk/a11y';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
QueryList,
|
||||
ViewChild,
|
||||
ViewChildren
|
||||
} from '@angular/core';
|
||||
import { FormControl } from '@angular/forms';
|
||||
import { MatMenuTrigger } from '@angular/material/menu';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { Position } from '@ghostfolio/common/interfaces';
|
||||
import { EMPTY, 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 { ISearchResults } from './interfaces/interfaces';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: 'gf-assistant',
|
||||
templateUrl: './assistant.html',
|
||||
styleUrls: ['./assistant.scss']
|
||||
})
|
||||
export class AssistantComponent implements 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;
|
||||
|
||||
@Output() closed = new EventEmitter<void>();
|
||||
|
||||
@ViewChild('menuTrigger') menuTriggerElement: MatMenuTrigger;
|
||||
@ViewChild('search', { static: true }) searchElement: ElementRef;
|
||||
|
||||
@ViewChildren(AssistantListItemComponent)
|
||||
assistantListItems: QueryList<AssistantListItemComponent>;
|
||||
|
||||
public static readonly SEARCH_RESULTS_DEFAULT_LIMIT = 5;
|
||||
|
||||
public isLoading = false;
|
||||
public isOpen = false;
|
||||
public placeholder = $localize`Find holding...`;
|
||||
public searchFormControl = new FormControl('');
|
||||
public searchResults: ISearchResults = {
|
||||
holdings: []
|
||||
};
|
||||
|
||||
private keyManager: FocusKeyManager<AssistantListItemComponent>;
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService
|
||||
) {}
|
||||
|
||||
public ngOnInit() {
|
||||
this.searchFormControl.valueChanges
|
||||
.pipe(
|
||||
map((searchTerm) => {
|
||||
this.isLoading = true;
|
||||
this.searchResults = {
|
||||
holdings: []
|
||||
};
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
|
||||
return searchTerm;
|
||||
}),
|
||||
debounceTime(300),
|
||||
distinctUntilChanged(),
|
||||
mergeMap(async (searchTerm) => {
|
||||
const result = <ISearchResults>{
|
||||
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 async initialize() {
|
||||
this.isLoading = true;
|
||||
this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap();
|
||||
this.searchResults = {
|
||||
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 onCloseAssistant() {
|
||||
this.setIsOpen(false);
|
||||
|
||||
this.closed.emit();
|
||||
}
|
||||
|
||||
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 holdings: Position[] = [];
|
||||
|
||||
try {
|
||||
holdings = await lastValueFrom(this.searchHolding(aSearchTerm));
|
||||
holdings = holdings.slice(
|
||||
0,
|
||||
AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT
|
||||
);
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
holdings
|
||||
};
|
||||
}
|
||||
|
||||
private searchHolding(aSearchTerm: string) {
|
||||
return this.dataService
|
||||
.fetchPositions({
|
||||
filters: [
|
||||
{
|
||||
id: aSearchTerm,
|
||||
type: 'SEARCH_QUERY'
|
||||
}
|
||||
],
|
||||
range: '1d'
|
||||
})
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
return EMPTY;
|
||||
}),
|
||||
map(({ positions }) => {
|
||||
return positions;
|
||||
}),
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,63 @@
|
||||
<div
|
||||
[style.width]="deviceType === 'mobile' ? '85vw' : '30rem'"
|
||||
(click)="$event.stopPropagation();"
|
||||
(keydown.tab)="$event.stopPropagation()"
|
||||
>
|
||||
<div class="align-items-center d-flex search-container">
|
||||
<ion-icon class="ml-2 mr-0" name="search-outline"></ion-icon>
|
||||
<input
|
||||
#search
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
class="border-0 p-2 w-100"
|
||||
name="search"
|
||||
type="text"
|
||||
[formControl]="searchFormControl"
|
||||
[placeholder]="placeholder"
|
||||
/>
|
||||
<div
|
||||
*ngIf="deviceType !== 'mobile' && !searchFormControl.value"
|
||||
class="hot-key-hint mx-1 px-1"
|
||||
>
|
||||
/
|
||||
</div>
|
||||
<button
|
||||
*ngIf="searchFormControl.value"
|
||||
class="h-100 no-min-width px-3 rounded-0"
|
||||
mat-button
|
||||
(click)="initialize()"
|
||||
>
|
||||
<ion-icon class="m-0" name="close-circle-outline"></ion-icon>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="!searchFormControl.value"
|
||||
class="h-100 no-min-width px-3 rounded-0"
|
||||
mat-button
|
||||
(click)="onCloseAssistant()"
|
||||
>
|
||||
<ion-icon class="m-0" name="close-outline"></ion-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-auto py-3 result-container">
|
||||
<div>
|
||||
<div class="h6 mb-1 px-2" i18n>Holdings</div>
|
||||
<gf-assistant-list-item
|
||||
*ngFor="let holding of searchResults?.holdings"
|
||||
[holding]="holding"
|
||||
(clicked)="onCloseAssistant()"
|
||||
/>
|
||||
<ng-container *ngIf="searchResults?.holdings?.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>
|
@ -0,0 +1,25 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
import { GfAssistantListItemModule } from './assistant-list-item/assistant-list-item.module';
|
||||
import { AssistantComponent } from './assistant.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [AssistantComponent],
|
||||
exports: [AssistantComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfAssistantListItemModule,
|
||||
MatButtonModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class GfAssistantModule {}
|
@ -0,0 +1,37 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.result-container {
|
||||
max-height: 15rem;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
border-bottom: 1px solid rgba(var(--dark-dividers));
|
||||
height: 2.5rem;
|
||||
|
||||
input {
|
||||
background: transparent;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.hot-key-hint {
|
||||
border: 1px solid rgba(var(--dark-dividers));
|
||||
border-radius: 0.25rem;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
.search-container {
|
||||
border-color: rgba(var(--light-dividers));
|
||||
|
||||
input {
|
||||
color: rgba(var(--light-primary-text));
|
||||
}
|
||||
|
||||
.hot-key-hint {
|
||||
border-color: rgba(var(--light-dividers));
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './assistant.module';
|
@ -0,0 +1,5 @@
|
||||
import { Position } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface ISearchResults {
|
||||
holdings: Position[];
|
||||
}
|
Loading…
Reference in new issue