mirror of https://github.com/ghostfolio/ghostfolio
Thomas Kaul
1 year ago
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