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 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));
|
||||
}
|
||||
}
|
||||
}
|
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