Feature/introduce assistant (#2451)
* Introduce assistant * Update changelog
This commit is contained in:
parent
37ff7acf04
commit
550e646079
@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Added support to search for a holding by `isin`, `name` and `symbol` (experimental)
|
||||||
- Added support for notes in the activities import
|
- Added support for notes in the activities import
|
||||||
- Added support to search in the platform selector of the create or update account dialog
|
- Added support to search in the platform selector of the create or update account dialog
|
||||||
- Added support for a search query in the portfolio position endpoint
|
- Added support for a search query in the portfolio position endpoint
|
||||||
|
@ -1076,7 +1076,8 @@ export class PortfolioService {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
enhancedSymbolProfile.isin?.toLowerCase().startsWith(searchQuery) ||
|
enhancedSymbolProfile.isin?.toLowerCase().startsWith(searchQuery) ||
|
||||||
enhancedSymbolProfile.name?.toLowerCase().startsWith(searchQuery)
|
enhancedSymbolProfile.name?.toLowerCase().startsWith(searchQuery) ||
|
||||||
|
enhancedSymbolProfile.symbol?.toLowerCase().startsWith(searchQuery)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -163,6 +163,13 @@ export class UserService {
|
|||||||
|
|
||||||
let currentPermissions = getPermissions(user.role);
|
let currentPermissions = getPermissions(user.role);
|
||||||
|
|
||||||
|
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
|
||||||
|
currentPermissions = without(
|
||||||
|
currentPermissions,
|
||||||
|
permissions.accessAssistant
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
user.subscription =
|
user.subscription =
|
||||||
this.subscriptionService.getSubscription(Subscription);
|
this.subscriptionService.getSubscription(Subscription);
|
||||||
|
@ -32,6 +32,7 @@
|
|||||||
<gf-header
|
<gf-header
|
||||||
class="position-fixed w-100"
|
class="position-fixed w-100"
|
||||||
[currentRoute]="currentRoute"
|
[currentRoute]="currentRoute"
|
||||||
|
[deviceType]="deviceType"
|
||||||
[hasTabs]="hasTabs"
|
[hasTabs]="hasTabs"
|
||||||
[info]="info"
|
[info]="info"
|
||||||
[pageTitle]="pageTitle"
|
[pageTitle]="pageTitle"
|
||||||
|
@ -110,6 +110,31 @@
|
|||||||
>About</a
|
>About</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
<li *ngIf="hasPermissionToAccessAssistant" class="list-inline-item">
|
||||||
|
<button
|
||||||
|
#assistantTrigger="matMenuTrigger"
|
||||||
|
class="h-100 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
[mat-menu-trigger-for]="assistantMenu"
|
||||||
|
[matMenuTriggerRestoreFocus]="false"
|
||||||
|
(menuOpened)="onOpenAssistant()"
|
||||||
|
>
|
||||||
|
<ion-icon name="search-outline"></ion-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu
|
||||||
|
#assistantMenu="matMenu"
|
||||||
|
class="assistant"
|
||||||
|
xPosition="before"
|
||||||
|
[overlapTrigger]="true"
|
||||||
|
(closed)="assistantElement?.setIsOpen(false)"
|
||||||
|
>
|
||||||
|
<gf-assistant
|
||||||
|
#assistant
|
||||||
|
[deviceType]="deviceType"
|
||||||
|
(closed)="closeAssistant()"
|
||||||
|
/>
|
||||||
|
</mat-menu>
|
||||||
|
</li>
|
||||||
<li class="list-inline-item">
|
<li class="list-inline-item">
|
||||||
<button
|
<button
|
||||||
class="no-min-width px-1"
|
class="no-min-width px-1"
|
||||||
|
@ -2,11 +2,14 @@ import {
|
|||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
|
HostListener,
|
||||||
Input,
|
Input,
|
||||||
OnChanges,
|
OnChanges,
|
||||||
Output
|
Output,
|
||||||
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { MatMenuTrigger } from '@angular/material/menu';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
|
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
@ -18,6 +21,7 @@ import {
|
|||||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
|
||||||
import { EMPTY, Subject } from 'rxjs';
|
import { EMPTY, Subject } from 'rxjs';
|
||||||
import { catchError, takeUntil } from 'rxjs/operators';
|
import { catchError, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -28,7 +32,24 @@ import { catchError, takeUntil } from 'rxjs/operators';
|
|||||||
styleUrls: ['./header.component.scss']
|
styleUrls: ['./header.component.scss']
|
||||||
})
|
})
|
||||||
export class HeaderComponent implements OnChanges {
|
export class HeaderComponent implements OnChanges {
|
||||||
|
@HostListener('window:keydown', ['$event'])
|
||||||
|
openAssistantWithHotKey(event: KeyboardEvent) {
|
||||||
|
if (
|
||||||
|
event.key === '/' &&
|
||||||
|
event.target instanceof Element &&
|
||||||
|
event.target?.nodeName?.toLowerCase() !== 'input' &&
|
||||||
|
event.target?.nodeName?.toLowerCase() !== 'textarea' &&
|
||||||
|
this.hasPermissionToAccessAssistant
|
||||||
|
) {
|
||||||
|
this.assistantElement.setIsOpen(true);
|
||||||
|
this.assistentMenuTriggerElement.openMenu();
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Input() currentRoute: string;
|
@Input() currentRoute: string;
|
||||||
|
@Input() deviceType: string;
|
||||||
@Input() hasTabs: boolean;
|
@Input() hasTabs: boolean;
|
||||||
@Input() info: InfoItem;
|
@Input() info: InfoItem;
|
||||||
@Input() pageTitle: string;
|
@Input() pageTitle: string;
|
||||||
@ -36,9 +57,13 @@ export class HeaderComponent implements OnChanges {
|
|||||||
|
|
||||||
@Output() signOut = new EventEmitter<void>();
|
@Output() signOut = new EventEmitter<void>();
|
||||||
|
|
||||||
|
@ViewChild('assistant') assistantElement: AssistantComponent;
|
||||||
|
@ViewChild('assistantTrigger') assistentMenuTriggerElement: MatMenuTrigger;
|
||||||
|
|
||||||
public hasPermissionForSocialLogin: boolean;
|
public hasPermissionForSocialLogin: boolean;
|
||||||
public hasPermissionForSubscription: boolean;
|
public hasPermissionForSubscription: boolean;
|
||||||
public hasPermissionToAccessAdminControl: boolean;
|
public hasPermissionToAccessAdminControl: boolean;
|
||||||
|
public hasPermissionToAccessAssistant: boolean;
|
||||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||||
public hasPermissionToCreateUser: boolean;
|
public hasPermissionToCreateUser: boolean;
|
||||||
public impersonationId: string;
|
public impersonationId: string;
|
||||||
@ -89,6 +114,11 @@ export class HeaderComponent implements OnChanges {
|
|||||||
permissions.accessAdminControl
|
permissions.accessAdminControl
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.hasPermissionToAccessAssistant = hasPermission(
|
||||||
|
this.user?.permissions,
|
||||||
|
permissions.accessAssistant
|
||||||
|
);
|
||||||
|
|
||||||
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
|
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
|
||||||
this.info?.globalPermissions,
|
this.info?.globalPermissions,
|
||||||
permissions.enableFearAndGreedIndex
|
permissions.enableFearAndGreedIndex
|
||||||
@ -100,6 +130,10 @@ export class HeaderComponent implements OnChanges {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public closeAssistant() {
|
||||||
|
this.assistentMenuTriggerElement?.closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
public impersonateAccount(aId: string) {
|
public impersonateAccount(aId: string) {
|
||||||
if (aId) {
|
if (aId) {
|
||||||
this.impersonationStorageService.setId(aId);
|
this.impersonationStorageService.setId(aId);
|
||||||
@ -118,6 +152,10 @@ export class HeaderComponent implements OnChanges {
|
|||||||
this.isMenuOpen = true;
|
this.isMenuOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onOpenAssistant() {
|
||||||
|
this.assistantElement.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
public onSignOut() {
|
public onSignOut() {
|
||||||
this.signOut.next();
|
this.signOut.next();
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ import { MatMenuModule } from '@angular/material/menu';
|
|||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
|
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
|
||||||
|
import { GfAssistantModule } from '@ghostfolio/ui/assistant';
|
||||||
import { GfLogoModule } from '@ghostfolio/ui/logo';
|
import { GfLogoModule } from '@ghostfolio/ui/logo';
|
||||||
|
|
||||||
import { HeaderComponent } from './header.component';
|
import { HeaderComponent } from './header.component';
|
||||||
@ -14,6 +15,7 @@ import { HeaderComponent } from './header.component';
|
|||||||
exports: [HeaderComponent],
|
exports: [HeaderComponent],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
GfAssistantModule,
|
||||||
GfLogoModule,
|
GfLogoModule,
|
||||||
LoginWithAccessTokenDialogModule,
|
LoginWithAccessTokenDialogModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
|
@ -57,6 +57,7 @@ export class DataService {
|
|||||||
ASSET_CLASS: filtersByAssetClass,
|
ASSET_CLASS: filtersByAssetClass,
|
||||||
ASSET_SUB_CLASS: filtersByAssetSubClass,
|
ASSET_SUB_CLASS: filtersByAssetSubClass,
|
||||||
PRESET_ID: filtersByPresetId,
|
PRESET_ID: filtersByPresetId,
|
||||||
|
SEARCH_QUERY: filtersBySearchQuery,
|
||||||
TAG: filtersByTag
|
TAG: filtersByTag
|
||||||
} = groupBy(filters, (filter) => {
|
} = groupBy(filters, (filter) => {
|
||||||
return filter.type;
|
return filter.type;
|
||||||
@ -99,6 +100,10 @@ export class DataService {
|
|||||||
params = params.append('presetId', filtersByPresetId[0].id);
|
params = params.append('presetId', filtersByPresetId[0].id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filtersBySearchQuery) {
|
||||||
|
params = params.append('query', filtersBySearchQuery[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
if (filtersByTag) {
|
if (filtersByTag) {
|
||||||
params = params.append(
|
params = params.append(
|
||||||
'tags',
|
'tags',
|
||||||
|
@ -214,6 +214,16 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mat-mdc-menu-panel {
|
||||||
|
&.assistant {
|
||||||
|
max-width: unset !important;
|
||||||
|
|
||||||
|
.mat-mdc-menu-content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.is-dark-theme {
|
&.is-dark-theme {
|
||||||
background: var(--dark-background);
|
background: var(--dark-background);
|
||||||
color: rgba(var(--light-primary-text));
|
color: rgba(var(--light-primary-text));
|
||||||
|
@ -3,6 +3,7 @@ import { Role } from '@prisma/client';
|
|||||||
|
|
||||||
export const permissions = {
|
export const permissions = {
|
||||||
accessAdminControl: 'accessAdminControl',
|
accessAdminControl: 'accessAdminControl',
|
||||||
|
accessAssistant: 'accessAssistant',
|
||||||
createAccess: 'createAccess',
|
createAccess: 'createAccess',
|
||||||
createAccount: 'createAccount',
|
createAccount: 'createAccount',
|
||||||
createOrder: 'createOrder',
|
createOrder: 'createOrder',
|
||||||
@ -41,6 +42,7 @@ export function getPermissions(aRole: Role): string[] {
|
|||||||
case 'ADMIN':
|
case 'ADMIN':
|
||||||
return [
|
return [
|
||||||
permissions.accessAdminControl,
|
permissions.accessAdminControl,
|
||||||
|
permissions.accessAssistant,
|
||||||
permissions.createAccess,
|
permissions.createAccess,
|
||||||
permissions.createAccount,
|
permissions.createAccount,
|
||||||
permissions.createOrder,
|
permissions.createOrder,
|
||||||
@ -63,10 +65,11 @@ export function getPermissions(aRole: Role): string[] {
|
|||||||
];
|
];
|
||||||
|
|
||||||
case 'DEMO':
|
case 'DEMO':
|
||||||
return [permissions.createUserAccount];
|
return [permissions.accessAssistant, permissions.createUserAccount];
|
||||||
|
|
||||||
case 'USER':
|
case 'USER':
|
||||||
return [
|
return [
|
||||||
|
permissions.accessAssistant,
|
||||||
permissions.createAccess,
|
permissions.createAccess,
|
||||||
permissions.createAccount,
|
permissions.createAccount,
|
||||||
permissions.createOrder,
|
permissions.createOrder,
|
||||||
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
219
libs/ui/src/lib/assistant/assistant.component.ts
Normal file
219
libs/ui/src/lib/assistant/assistant.component.ts
Normal file
@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
63
libs/ui/src/lib/assistant/assistant.html
Normal file
63
libs/ui/src/lib/assistant/assistant.html
Normal file
@ -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>
|
25
libs/ui/src/lib/assistant/assistant.module.ts
Normal file
25
libs/ui/src/lib/assistant/assistant.module.ts
Normal file
@ -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 {}
|
37
libs/ui/src/lib/assistant/assistant.scss
Normal file
37
libs/ui/src/lib/assistant/assistant.scss
Normal file
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1
libs/ui/src/lib/assistant/index.ts
Normal file
1
libs/ui/src/lib/assistant/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './assistant.module';
|
5
libs/ui/src/lib/assistant/interfaces/interfaces.ts
Normal file
5
libs/ui/src/lib/assistant/interfaces/interfaces.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { Position } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
export interface ISearchResults {
|
||||||
|
holdings: Position[];
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user