mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
* Add frontend for watchlist * Update changelog --------- Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>pull/4616/head
committed by
GitHub
17 changed files with 382 additions and 28 deletions
@ -0,0 +1,3 @@ |
|||
:host { |
|||
display: block; |
|||
} |
@ -0,0 +1,92 @@ |
|||
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete'; |
|||
|
|||
import { CommonModule } from '@angular/common'; |
|||
import { |
|||
ChangeDetectionStrategy, |
|||
Component, |
|||
OnDestroy, |
|||
OnInit |
|||
} from '@angular/core'; |
|||
import { |
|||
AbstractControl, |
|||
FormBuilder, |
|||
FormControl, |
|||
FormGroup, |
|||
FormsModule, |
|||
ReactiveFormsModule, |
|||
ValidationErrors, |
|||
Validators |
|||
} from '@angular/forms'; |
|||
import { MatButtonModule } from '@angular/material/button'; |
|||
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; |
|||
import { MatFormFieldModule } from '@angular/material/form-field'; |
|||
import { Subject } from 'rxjs'; |
|||
|
|||
@Component({ |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
host: { class: 'h-100' }, |
|||
imports: [ |
|||
CommonModule, |
|||
FormsModule, |
|||
GfSymbolAutocompleteComponent, |
|||
MatButtonModule, |
|||
MatDialogModule, |
|||
MatFormFieldModule, |
|||
ReactiveFormsModule |
|||
], |
|||
selector: 'gf-create-watchlist-item-dialog', |
|||
styleUrls: ['./create-watchlist-item-dialog.component.scss'], |
|||
templateUrl: 'create-watchlist-item-dialog.html' |
|||
}) |
|||
export class CreateWatchlistItemDialogComponent implements OnInit, OnDestroy { |
|||
public createWatchlistItemForm: FormGroup; |
|||
|
|||
private unsubscribeSubject = new Subject<void>(); |
|||
|
|||
public constructor( |
|||
public readonly dialogRef: MatDialogRef<CreateWatchlistItemDialogComponent>, |
|||
public readonly formBuilder: FormBuilder |
|||
) {} |
|||
|
|||
public ngOnInit() { |
|||
this.createWatchlistItemForm = this.formBuilder.group( |
|||
{ |
|||
searchSymbol: new FormControl(null, [Validators.required]) |
|||
}, |
|||
{ |
|||
validators: this.validator |
|||
} |
|||
); |
|||
} |
|||
|
|||
public onCancel() { |
|||
this.dialogRef.close(); |
|||
} |
|||
|
|||
public onSubmit() { |
|||
this.dialogRef.close({ |
|||
dataSource: |
|||
this.createWatchlistItemForm.get('searchSymbol').value.dataSource, |
|||
symbol: this.createWatchlistItemForm.get('searchSymbol').value.symbol |
|||
}); |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
this.unsubscribeSubject.next(); |
|||
this.unsubscribeSubject.complete(); |
|||
} |
|||
|
|||
private validator(control: AbstractControl): ValidationErrors { |
|||
const searchSymbolControl = control.get('searchSymbol'); |
|||
|
|||
if ( |
|||
searchSymbolControl.valid && |
|||
searchSymbolControl.value.dataSource && |
|||
searchSymbolControl.value.symbol |
|||
) { |
|||
return { incomplete: false }; |
|||
} |
|||
|
|||
return { incomplete: true }; |
|||
} |
|||
} |
@ -0,0 +1,25 @@ |
|||
<form |
|||
class="d-flex flex-column h-100" |
|||
[formGroup]="createWatchlistItemForm" |
|||
(keyup.enter)="createWatchlistItemForm.valid && onSubmit()" |
|||
(ngSubmit)="onSubmit()" |
|||
> |
|||
<h1 i18n mat-dialog-title>Add asset to watchlist</h1> |
|||
<div class="flex-grow-1 py-3" mat-dialog-content> |
|||
<mat-form-field appearance="outline" class="w-100"> |
|||
<mat-label i18n>Name, symbol or ISIN</mat-label> |
|||
<gf-symbol-autocomplete formControlName="searchSymbol" /> |
|||
</mat-form-field> |
|||
</div> |
|||
<div class="d-flex justify-content-end" mat-dialog-actions> |
|||
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button> |
|||
<button |
|||
color="primary" |
|||
mat-flat-button |
|||
type="submit" |
|||
[disabled]="createWatchlistItemForm.hasError('incomplete')" |
|||
> |
|||
<ng-container i18n>Save</ng-container> |
|||
</button> |
|||
</div> |
|||
</form> |
@ -0,0 +1,4 @@ |
|||
export interface CreateWatchlistItemDialogParams { |
|||
deviceType: string; |
|||
locale: string; |
|||
} |
@ -0,0 +1,145 @@ |
|||
import { DataService } from '@ghostfolio/client/services/data.service'; |
|||
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
|||
import { Benchmark, User } from '@ghostfolio/common/interfaces'; |
|||
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
|||
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; |
|||
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; |
|||
|
|||
import { CommonModule } from '@angular/common'; |
|||
import { |
|||
ChangeDetectionStrategy, |
|||
ChangeDetectorRef, |
|||
Component, |
|||
CUSTOM_ELEMENTS_SCHEMA, |
|||
OnDestroy, |
|||
OnInit |
|||
} from '@angular/core'; |
|||
import { MatButtonModule } from '@angular/material/button'; |
|||
import { MatDialog } from '@angular/material/dialog'; |
|||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; |
|||
import { DeviceDetectorService } from 'ngx-device-detector'; |
|||
import { Subject } from 'rxjs'; |
|||
import { takeUntil } from 'rxjs/operators'; |
|||
|
|||
import { CreateWatchlistItemDialogComponent } from './create-watchlist-item-dialog/create-watchlist-item-dialog.component'; |
|||
import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/interfaces/interfaces'; |
|||
|
|||
@Component({ |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
imports: [ |
|||
CommonModule, |
|||
GfBenchmarkComponent, |
|||
GfPremiumIndicatorComponent, |
|||
MatButtonModule, |
|||
RouterModule |
|||
], |
|||
schemas: [CUSTOM_ELEMENTS_SCHEMA], |
|||
selector: 'gf-home-watchlist', |
|||
styleUrls: ['./home-watchlist.scss'], |
|||
templateUrl: './home-watchlist.html' |
|||
}) |
|||
export class HomeWatchlistComponent implements OnDestroy, OnInit { |
|||
public deviceType: string; |
|||
public hasPermissionToCreateWatchlistItem: boolean; |
|||
public user: User; |
|||
public watchlist: Benchmark[]; |
|||
|
|||
private unsubscribeSubject = new Subject<void>(); |
|||
|
|||
public constructor( |
|||
private changeDetectorRef: ChangeDetectorRef, |
|||
private dataService: DataService, |
|||
private deviceService: DeviceDetectorService, |
|||
private dialog: MatDialog, |
|||
private route: ActivatedRoute, |
|||
private router: Router, |
|||
private userService: UserService |
|||
) { |
|||
this.deviceType = this.deviceService.getDeviceInfo().deviceType; |
|||
|
|||
this.route.queryParams |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((params) => { |
|||
if (params['createWatchlistItemDialog']) { |
|||
this.openCreateWatchlistItemDialog(); |
|||
} |
|||
}); |
|||
|
|||
this.userService.stateChanged |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((state) => { |
|||
if (state?.user) { |
|||
this.user = state.user; |
|||
|
|||
this.hasPermissionToCreateWatchlistItem = hasPermission( |
|||
this.user.permissions, |
|||
permissions.createWatchlistItem |
|||
); |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public ngOnInit() { |
|||
this.loadWatchlistData(); |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
this.unsubscribeSubject.next(); |
|||
this.unsubscribeSubject.complete(); |
|||
} |
|||
|
|||
private loadWatchlistData() { |
|||
this.dataService |
|||
.fetchWatchlist() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(({ watchlist }) => { |
|||
this.watchlist = watchlist.map(({ dataSource, symbol }) => ({ |
|||
dataSource, |
|||
symbol, |
|||
marketCondition: null, |
|||
name: symbol, |
|||
performances: null, |
|||
trend50d: 'UNKNOWN', |
|||
trend200d: 'UNKNOWN' |
|||
})); |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
}); |
|||
} |
|||
|
|||
private openCreateWatchlistItemDialog() { |
|||
this.userService |
|||
.get() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((user) => { |
|||
this.user = user; |
|||
|
|||
const dialogRef = this.dialog.open(CreateWatchlistItemDialogComponent, { |
|||
autoFocus: false, |
|||
data: { |
|||
deviceType: this.deviceType, |
|||
locale: this.user?.settings?.locale |
|||
} as CreateWatchlistItemDialogParams, |
|||
width: this.deviceType === 'mobile' ? '100vw' : '50rem' |
|||
}); |
|||
|
|||
dialogRef |
|||
.afterClosed() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(({ dataSource, symbol } = {}) => { |
|||
if (dataSource && symbol) { |
|||
this.dataService |
|||
.postWatchlistItem({ dataSource, symbol }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe({ |
|||
next: () => this.loadWatchlistData() |
|||
}); |
|||
} |
|||
|
|||
this.router.navigate(['.'], { relativeTo: this.route }); |
|||
}); |
|||
}); |
|||
} |
|||
} |
@ -0,0 +1,33 @@ |
|||
<div class="container"> |
|||
<h1 class="d-none d-sm-block h3 mb-4"> |
|||
<span class="align-items-center d-flex justify-content-center"> |
|||
<span i18n>Watchlist</span> |
|||
@if (user?.subscription?.type === 'Basic') { |
|||
<gf-premium-indicator class="ml-1" /> |
|||
} |
|||
</span> |
|||
</h1> |
|||
<div class="mb-3 row"> |
|||
<div class="col-xs-12 col-md-8 offset-md-2"> |
|||
<gf-benchmark |
|||
[benchmarks]="watchlist" |
|||
[deviceType]="deviceType" |
|||
[locale]="user?.settings?.locale || undefined" |
|||
[user]="user" |
|||
/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
@if (hasPermissionToCreateWatchlistItem) { |
|||
<div class="fab-container"> |
|||
<a |
|||
class="align-items-center d-flex justify-content-center" |
|||
color="primary" |
|||
mat-fab |
|||
[queryParams]="{ createWatchlistItemDialog: true }" |
|||
[routerLink]="[]" |
|||
> |
|||
<ion-icon name="add-outline" size="large" /> |
|||
</a> |
|||
</div> |
|||
} |
@ -0,0 +1,3 @@ |
|||
:host { |
|||
display: block; |
|||
} |
@ -0,0 +1,5 @@ |
|||
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; |
|||
|
|||
export interface WatchlistResponse { |
|||
watchlist: AssetProfileIdentifier[]; |
|||
} |
Loading…
Reference in new issue