Feature/introduce assistant (#2451)

* Introduce assistant

* Update changelog
pull/2454/head
Thomas Kaul 8 months ago committed by GitHub
parent 37ff7acf04
commit 550e646079
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added support to search for a holding by `isin`, `name` and `symbol` (experimental)
- 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 for a search query in the portfolio position endpoint

@ -1076,7 +1076,8 @@ export class PortfolioService {
return (
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);
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
currentPermissions = without(
currentPermissions,
permissions.accessAssistant
);
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription =
this.subscriptionService.getSubscription(Subscription);

@ -32,6 +32,7 @@
<gf-header
class="position-fixed w-100"
[currentRoute]="currentRoute"
[deviceType]="deviceType"
[hasTabs]="hasTabs"
[info]="info"
[pageTitle]="pageTitle"

@ -110,6 +110,31 @@
>About</a
>
</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">
<button
class="no-min-width px-1"

@ -2,11 +2,14 @@ import {
ChangeDetectionStrategy,
Component,
EventEmitter,
HostListener,
Input,
OnChanges,
Output
Output,
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatMenuTrigger } from '@angular/material/menu';
import { Router } from '@angular/router';
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';
@ -18,6 +21,7 @@ import {
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
@ -28,7 +32,24 @@ import { catchError, takeUntil } from 'rxjs/operators';
styleUrls: ['./header.component.scss']
})
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() deviceType: string;
@Input() hasTabs: boolean;
@Input() info: InfoItem;
@Input() pageTitle: string;
@ -36,9 +57,13 @@ export class HeaderComponent implements OnChanges {
@Output() signOut = new EventEmitter<void>();
@ViewChild('assistant') assistantElement: AssistantComponent;
@ViewChild('assistantTrigger') assistentMenuTriggerElement: MatMenuTrigger;
public hasPermissionForSocialLogin: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean;
public hasPermissionToAccessAssistant: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasPermissionToCreateUser: boolean;
public impersonationId: string;
@ -89,6 +114,11 @@ export class HeaderComponent implements OnChanges {
permissions.accessAdminControl
);
this.hasPermissionToAccessAssistant = hasPermission(
this.user?.permissions,
permissions.accessAssistant
);
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.info?.globalPermissions,
permissions.enableFearAndGreedIndex
@ -100,6 +130,10 @@ export class HeaderComponent implements OnChanges {
);
}
public closeAssistant() {
this.assistentMenuTriggerElement?.closeMenu();
}
public impersonateAccount(aId: string) {
if (aId) {
this.impersonationStorageService.setId(aId);
@ -118,6 +152,10 @@ export class HeaderComponent implements OnChanges {
this.isMenuOpen = true;
}
public onOpenAssistant() {
this.assistantElement.initialize();
}
public onSignOut() {
this.signOut.next();
}

@ -5,6 +5,7 @@ import { MatMenuModule } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router';
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 { HeaderComponent } from './header.component';
@ -14,6 +15,7 @@ import { HeaderComponent } from './header.component';
exports: [HeaderComponent],
imports: [
CommonModule,
GfAssistantModule,
GfLogoModule,
LoginWithAccessTokenDialogModule,
MatButtonModule,

@ -57,6 +57,7 @@ export class DataService {
ASSET_CLASS: filtersByAssetClass,
ASSET_SUB_CLASS: filtersByAssetSubClass,
PRESET_ID: filtersByPresetId,
SEARCH_QUERY: filtersBySearchQuery,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
@ -99,6 +100,10 @@ export class DataService {
params = params.append('presetId', filtersByPresetId[0].id);
}
if (filtersBySearchQuery) {
params = params.append('query', filtersBySearchQuery[0].id);
}
if (filtersByTag) {
params = params.append(
'tags',

@ -214,6 +214,16 @@ body {
}
}
.mat-mdc-menu-panel {
&.assistant {
max-width: unset !important;
.mat-mdc-menu-content {
padding: 0;
}
}
}
&.is-dark-theme {
background: var(--dark-background);
color: rgba(var(--light-primary-text));

@ -3,6 +3,7 @@ import { Role } from '@prisma/client';
export const permissions = {
accessAdminControl: 'accessAdminControl',
accessAssistant: 'accessAssistant',
createAccess: 'createAccess',
createAccount: 'createAccount',
createOrder: 'createOrder',
@ -41,6 +42,7 @@ export function getPermissions(aRole: Role): string[] {
case 'ADMIN':
return [
permissions.accessAdminControl,
permissions.accessAssistant,
permissions.createAccess,
permissions.createAccount,
permissions.createOrder,
@ -63,10 +65,11 @@ export function getPermissions(aRole: Role): string[] {
];
case 'DEMO':
return [permissions.createUserAccount];
return [permissions.accessAssistant, permissions.createUserAccount];
case 'USER':
return [
permissions.accessAssistant,
permissions.createAccess,
permissions.createAccount,
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));
}
}
}

@ -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…
Cancel
Save