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