mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
20 changed files with 541 additions and 3 deletions
@ -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