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