Browse Source

Feature/extend assistant by tag selector (#2838)

* Extend assistant by tag selector

* Update changelog
pull/2841/head
Thomas Kaul 1 year ago
committed by GitHub
parent
commit
d7f72819de
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      CHANGELOG.md
  2. 5
      apps/api/src/app/user/update-user-setting.dto.ts
  3. 1
      apps/client/src/app/components/header/header.component.html
  4. 15
      apps/client/src/app/components/header/header.component.ts
  5. 2
      apps/client/src/app/components/toggle/toggle.component.html
  6. 6
      apps/client/src/app/components/toggle/toggle.component.ts
  7. 5
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  8. 28
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  9. 4
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  10. 17
      apps/client/src/app/services/user/user.service.ts
  11. 1
      libs/common/src/lib/interfaces/user-settings.interface.ts
  12. 11
      libs/ui/src/lib/activities-table-lazy/activities-table-lazy.component.ts
  13. 32
      libs/ui/src/lib/assistant/assistant.component.ts
  14. 31
      libs/ui/src/lib/assistant/assistant.html
  15. 2
      libs/ui/src/lib/assistant/assistant.module.ts
  16. 8
      libs/ui/src/lib/assistant/assistant.scss

1
CHANGELOG.md

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Extended the assistant by a tag selector (experimental)
- Added support to set a _CoinGecko_ Demo API key via environment variable (`API_KEY_COINGECKO_DEMO`) - Added support to set a _CoinGecko_ Demo API key via environment variable (`API_KEY_COINGECKO_DEMO`)
- Added support to set a _CoinGecko_ Pro API key via environment variable (`API_KEY_COINGECKO_PRO`) - Added support to set a _CoinGecko_ Pro API key via environment variable (`API_KEY_COINGECKO_PRO`)

5
apps/api/src/app/user/update-user-setting.dto.ts

@ -4,6 +4,7 @@ import type {
ViewMode ViewMode
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { import {
IsArray,
IsBoolean, IsBoolean,
IsISO8601, IsISO8601,
IsIn, IsIn,
@ -37,6 +38,10 @@ export class UpdateUserSettingDto {
@IsOptional() @IsOptional()
emergencyFund?: number; emergencyFund?: number;
@IsArray()
@IsOptional()
'filters.tags'?: string[];
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isExperimentalFeatures?: boolean; isExperimentalFeatures?: boolean;

1
apps/client/src/app/components/header/header.component.html

@ -141,6 +141,7 @@
[user]="user" [user]="user"
(closed)="closeAssistant()" (closed)="closeAssistant()"
(dateRangeChanged)="onDateRangeChange($event)" (dateRangeChanged)="onDateRangeChange($event)"
(selectedTagChanged)="onSelectedTagChanged($event)"
/> />
</mat-menu> </mat-menu>
</li> </li>

15
apps/client/src/app/components/header/header.component.ts

@ -24,6 +24,7 @@ import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component'; import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
import { Tag } from '@prisma/client';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError, takeUntil } from 'rxjs/operators';
@ -173,6 +174,20 @@ export class HeaderComponent implements OnChanges {
this.assistantElement.initialize(); this.assistantElement.initialize();
} }
public onSelectedTagChanged(tag: Tag) {
this.dataService
.putUserSetting({ 'filters.tags': tag ? [tag.id] : null })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
});
}
public onSignOut() { public onSignOut() {
this.signOut.next(); this.signOut.next();
} }

2
apps/client/src/app/components/toggle/toggle.component.html

@ -1,6 +1,6 @@
<mat-radio-group <mat-radio-group
class="d-block text-nowrap" class="d-block text-nowrap"
[formControl]="option" [formControl]="optionFormControl"
(change)="onValueChange()" (change)="onValueChange()"
> >
<mat-radio-button <mat-radio-button

6
apps/client/src/app/components/toggle/toggle.component.ts

@ -31,17 +31,17 @@ export class ToggleComponent implements OnChanges, OnInit {
@Output() change = new EventEmitter<Pick<ToggleOption, 'value'>>(); @Output() change = new EventEmitter<Pick<ToggleOption, 'value'>>();
public option = new FormControl<string>(undefined); public optionFormControl = new FormControl<string>(undefined);
public constructor() {} public constructor() {}
public ngOnInit() {} public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
this.option.setValue(this.defaultValue); this.optionFormControl.setValue(this.defaultValue);
} }
public onValueChange() { public onValueChange() {
this.change.emit({ value: this.option.value }); this.change.emit({ value: this.optionFormControl.value });
} }
} }

5
apps/client/src/app/pages/portfolio/activities/activities-page.component.ts

@ -15,7 +15,7 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/imperso
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { downloadAsFile } from '@ghostfolio/common/helper'; import { downloadAsFile } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces'; import { Filter, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DataSource, Order as OrderModel } from '@prisma/client'; import { DataSource, Order as OrderModel } from '@prisma/client';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
@ -111,6 +111,8 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.updateUser(state.user); this.updateUser(state.user);
this.fetchActivities();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
@ -122,6 +124,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
if (this.user?.settings?.isExperimentalFeatures === true) { if (this.user?.settings?.isExperimentalFeatures === true) {
this.dataService this.dataService
.fetchActivities({ .fetchActivities({
filters: this.userService.getFilters(),
skip: this.pageIndex * this.pageSize, skip: this.pageIndex * this.pageSize,
sortColumn: this.sortColumn, sortColumn: this.sortColumn,
sortDirection: this.sortDirection, sortDirection: this.sortDirection,

28
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -225,7 +225,10 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
private fetchDividendsAndInvestments() { private fetchDividendsAndInvestments() {
this.dataService this.dataService
.fetchDividends({ .fetchDividends({
filters: this.activeFilters, filters:
this.activeFilters.length > 0
? this.activeFilters
: this.userService.getFilters(),
groupBy: this.mode, groupBy: this.mode,
range: this.user?.settings?.dateRange range: this.user?.settings?.dateRange
}) })
@ -238,7 +241,10 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchInvestments({ .fetchInvestments({
filters: this.activeFilters, filters:
this.activeFilters.length > 0
? this.activeFilters
: this.userService.getFilters(),
groupBy: this.mode, groupBy: this.mode,
range: this.user?.settings?.dateRange range: this.user?.settings?.dateRange
}) })
@ -252,16 +258,16 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
? translate('YEAR') ? translate('YEAR')
: translate('YEARS') : translate('YEARS')
: this.streaks?.currentStreak === 1 : this.streaks?.currentStreak === 1
? translate('MONTH') ? translate('MONTH')
: translate('MONTHS'); : translate('MONTHS');
this.unitLongestStreak = this.unitLongestStreak =
this.mode === 'year' this.mode === 'year'
? this.streaks?.longestStreak === 1 ? this.streaks?.longestStreak === 1
? translate('YEAR') ? translate('YEAR')
: translate('YEARS') : translate('YEARS')
: this.streaks?.longestStreak === 1 : this.streaks?.longestStreak === 1
? translate('MONTH') ? translate('MONTH')
: translate('MONTHS'); : translate('MONTHS');
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -313,7 +319,10 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchPortfolioPerformance({ .fetchPortfolioPerformance({
filters: this.activeFilters, filters:
this.activeFilters.length > 0
? this.activeFilters
: this.userService.getFilters(),
range: this.user?.settings?.dateRange range: this.user?.settings?.dateRange
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -358,7 +367,10 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchPositions({ .fetchPositions({
filters: this.activeFilters, filters:
this.activeFilters.length > 0
? this.activeFilters
: this.userService.getFilters(),
range: this.user?.settings?.dateRange range: this.user?.settings?.dateRange
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))

4
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -1,6 +1,7 @@
<div class="container"> <div class="container">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Analysis</h1> <h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Analysis</h1>
<div *ngIf="!user?.settings?.isExperimentalFeatures" class="my-4 text-center"> @if (!user?.settings?.isExperimentalFeatures) {
<div class="my-4 text-center">
<gf-toggle <gf-toggle
[defaultValue]="user?.settings?.dateRange" [defaultValue]="user?.settings?.dateRange"
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart" [isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
@ -14,6 +15,7 @@
[placeholder]="placeholder" [placeholder]="placeholder"
(valueChanged)="filters$.next($event)" (valueChanged)="filters$.next($event)"
></gf-activities-filter> ></gf-activities-filter>
}
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col-lg"> <div class="col-lg">
<gf-benchmark-comparator <gf-benchmark-comparator

17
apps/client/src/app/services/user/user.service.ts

@ -4,7 +4,7 @@ import { MatDialog } from '@angular/material/dialog';
import { ObservableStore } from '@codewithdan/observable-store'; import { ObservableStore } from '@codewithdan/observable-store';
import { SubscriptionInterstitialDialogParams } from '@ghostfolio/client/components/subscription-interstitial-dialog/interfaces/interfaces'; import { SubscriptionInterstitialDialogParams } from '@ghostfolio/client/components/subscription-interstitial-dialog/interfaces/interfaces';
import { SubscriptionInterstitialDialog } from '@ghostfolio/client/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component'; import { SubscriptionInterstitialDialog } from '@ghostfolio/client/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component';
import { User } from '@ghostfolio/common/interfaces'; import { Filter, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -46,6 +46,21 @@ export class UserService extends ObservableStore<UserStoreState> {
} }
} }
public getFilters() {
const user = this.getState().user;
return user?.settings?.isExperimentalFeatures === true
? user.settings['filters.tags']
? <Filter[]>[
{
id: user.settings['filters.tags'][0],
type: 'TAG'
}
]
: []
: [];
}
public remove() { public remove() {
this.setState({ user: null }, UserStoreActions.RemoveUser); this.setState({ user: null }, UserStoreActions.RemoveUser);
} }

1
libs/common/src/lib/interfaces/user-settings.interface.ts

@ -7,6 +7,7 @@ export interface UserSettings {
colorScheme?: ColorScheme; colorScheme?: ColorScheme;
dateRange?: DateRange; dateRange?: DateRange;
emergencyFund?: number; emergencyFund?: number;
'filters.tags'?: string[];
isExperimentalFeatures?: boolean; isExperimentalFeatures?: boolean;
isRestrictedView?: boolean; isRestrictedView?: boolean;
language?: string; language?: string;

11
libs/ui/src/lib/activities-table-lazy/activities-table-lazy.component.ts

@ -75,7 +75,6 @@ export class ActivitiesTableLazyComponent
public isLoading = true; public isLoading = true;
public isUUID = isUUID; public isUUID = isUUID;
public routeQueryParams: Subscription; public routeQueryParams: Subscription;
public searchKeywords: string[] = [];
public selectedRows = new SelectionModel<Activity>(true, []); public selectedRows = new SelectionModel<Activity>(true, []);
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -182,15 +181,7 @@ export class ActivitiesTableLazyComponent
} }
public onExport() { public onExport() {
if (this.searchKeywords.length > 0) { this.export.emit();
this.export.emit(
this.dataSource.filteredData.map((activity) => {
return activity.id;
})
);
} else {
this.export.emit();
}
} }
public onExportDraft(aActivityId: string) { public onExportDraft(aActivityId: string) {

32
libs/ui/src/lib/assistant/assistant.component.ts

@ -7,6 +7,7 @@ import {
EventEmitter, EventEmitter,
HostListener, HostListener,
Input, Input,
OnChanges,
OnDestroy, OnDestroy,
OnInit, OnInit,
Output, Output,
@ -22,6 +23,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { Tag } from '@prisma/client';
import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs'; import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs';
import { import {
catchError, catchError,
@ -41,7 +43,7 @@ import { ISearchResultItem, ISearchResults } from './interfaces/interfaces';
styleUrls: ['./assistant.scss'], styleUrls: ['./assistant.scss'],
templateUrl: './assistant.html' templateUrl: './assistant.html'
}) })
export class AssistantComponent implements OnDestroy, OnInit { export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
@HostListener('document:keydown', ['$event']) onKeydown( @HostListener('document:keydown', ['$event']) onKeydown(
event: KeyboardEvent event: KeyboardEvent
) { ) {
@ -80,6 +82,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
@Output() closed = new EventEmitter<void>(); @Output() closed = new EventEmitter<void>();
@Output() dateRangeChanged = new EventEmitter<DateRange>(); @Output() dateRangeChanged = new EventEmitter<DateRange>();
@Output() selectedTagChanged = new EventEmitter<Tag>();
@ViewChild('menuTrigger') menuTriggerElement: MatMenuTrigger; @ViewChild('menuTrigger') menuTriggerElement: MatMenuTrigger;
@ViewChild('search', { static: true }) searchElement: ElementRef; @ViewChild('search', { static: true }) searchElement: ElementRef;
@ -98,6 +101,8 @@ export class AssistantComponent implements OnDestroy, OnInit {
assetProfiles: [], assetProfiles: [],
holdings: [] holdings: []
}; };
public tags: Tag[] = [];
public tagsFormControl = new FormControl<string>(undefined);
private keyManager: FocusKeyManager<AssistantListItemComponent>; private keyManager: FocusKeyManager<AssistantListItemComponent>;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -109,6 +114,15 @@ export class AssistantComponent implements OnDestroy, OnInit {
) {} ) {}
public ngOnInit() { public ngOnInit() {
const { tags } = this.dataService.fetchInfo();
this.tags = tags.map(({ id, name }) => {
return {
id,
name: translate(name)
};
});
this.searchFormControl.valueChanges this.searchFormControl.valueChanges
.pipe( .pipe(
map((searchTerm) => { map((searchTerm) => {
@ -148,6 +162,12 @@ export class AssistantComponent implements OnDestroy, OnInit {
}); });
} }
public ngOnChanges() {
this.tagsFormControl.setValue(
this.user?.settings?.['filters.tags']?.[0] ?? null
);
}
public async initialize() { public async initialize() {
this.isLoading = true; this.isLoading = true;
this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap(); this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap();
@ -181,6 +201,16 @@ export class AssistantComponent implements OnDestroy, OnInit {
this.closed.emit(); this.closed.emit();
} }
public onTagChange() {
const selectedTag = this.tags.find(({ id }) => {
return id === this.tagsFormControl.value;
});
this.selectedTagChanged.emit(selectedTag);
this.onCloseAssistant();
}
public setIsOpen(aIsOpen: boolean) { public setIsOpen(aIsOpen: boolean) {
this.isOpen = aIsOpen; this.isOpen = aIsOpen;
} }

31
libs/ui/src/lib/assistant/assistant.html

@ -88,8 +88,14 @@
</div> </div>
<div <div
*ngIf="!(isLoading || searchFormControl.value) && user?.settings?.isExperimentalFeatures" *ngIf="!(isLoading || searchFormControl.value) && user?.settings?.isExperimentalFeatures"
class="filter-container"
> >
<mat-tab-group mat-align-tabs="start" [mat-stretch-tabs]="false"> <mat-tab-group
animationDuration="0"
mat-align-tabs="start"
[mat-stretch-tabs]="false"
(click)="$event.stopPropagation();"
>
<mat-tab> <mat-tab>
<ng-template mat-tab-label <ng-template mat-tab-label
><ion-icon class="mr-2" name="calendar-clear-outline" /><span i18n ><ion-icon class="mr-2" name="calendar-clear-outline" /><span i18n
@ -104,5 +110,28 @@
></gf-toggle> ></gf-toggle>
</div> </div>
</mat-tab> </mat-tab>
<mat-tab>
<ng-template mat-tab-label
><ion-icon class="mr-2" name="pricetag-outline" /><span i18n
>Tags</span
></ng-template
>
<div class="p-3">
<mat-radio-group
color="primary"
[formControl]="tagsFormControl"
(change)="onTagChange()"
>
<mat-radio-button class="d-flex flex-column" i18n [value]="null"
>No tag</mat-radio-button
>
@for (tag of tags; track tag.id) {
<mat-radio-button class="d-flex flex-column" [value]="tag.id"
>{{ tag.name }}</mat-radio-button
>
}
</mat-radio-group>
</div>
</mat-tab>
</mat-tab-group> </mat-tab-group>
</div> </div>

2
libs/ui/src/lib/assistant/assistant.module.ts

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatRadioModule } from '@angular/material/radio';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
@ -19,6 +20,7 @@ import { AssistantComponent } from './assistant.component';
GfAssistantListItemModule, GfAssistantListItemModule,
GfToggleModule, GfToggleModule,
MatButtonModule, MatButtonModule,
MatRadioModule,
MatTabsModule, MatTabsModule,
NgxSkeletonLoaderModule, NgxSkeletonLoaderModule,
ReactiveFormsModule, ReactiveFormsModule,

8
libs/ui/src/lib/assistant/assistant.scss

@ -1,6 +1,14 @@
:host { :host {
display: block; display: block;
.filter-container {
::ng-deep {
label {
margin-bottom: 0;
}
}
}
.result-container { .result-container {
max-height: 15rem; max-height: 15rem;
} }

Loading…
Cancel
Save