From d7f72819de4435377ff693c2bb5bf6ff03549001 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 7 Jan 2024 16:56:25 +0100 Subject: [PATCH] Feature/extend assistant by tag selector (#2838) * Extend assistant by tag selector * Update changelog --- CHANGELOG.md | 1 + .../src/app/user/update-user-setting.dto.ts | 5 +++ .../components/header/header.component.html | 1 + .../app/components/header/header.component.ts | 15 +++++++++ .../components/toggle/toggle.component.html | 2 +- .../app/components/toggle/toggle.component.ts | 6 ++-- .../activities/activities-page.component.ts | 5 ++- .../analysis/analysis-page.component.ts | 28 +++++++++++----- .../portfolio/analysis/analysis-page.html | 4 ++- .../src/app/services/user/user.service.ts | 17 +++++++++- .../lib/interfaces/user-settings.interface.ts | 1 + .../activities-table-lazy.component.ts | 11 +------ .../src/lib/assistant/assistant.component.ts | 32 ++++++++++++++++++- libs/ui/src/lib/assistant/assistant.html | 31 +++++++++++++++++- libs/ui/src/lib/assistant/assistant.module.ts | 2 ++ libs/ui/src/lib/assistant/assistant.scss | 8 +++++ 16 files changed, 142 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d48352f7a..015d3cb08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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_ Pro API key via environment variable (`API_KEY_COINGECKO_PRO`) diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index e510880ed..f618e42b1 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -4,6 +4,7 @@ import type { ViewMode } from '@ghostfolio/common/types'; import { + IsArray, IsBoolean, IsISO8601, IsIn, @@ -37,6 +38,10 @@ export class UpdateUserSettingDto { @IsOptional() emergencyFund?: number; + @IsArray() + @IsOptional() + 'filters.tags'?: string[]; + @IsBoolean() @IsOptional() isExperimentalFeatures?: boolean; diff --git a/apps/client/src/app/components/header/header.component.html b/apps/client/src/app/components/header/header.component.html index f454ab914..8d08533e4 100644 --- a/apps/client/src/app/components/header/header.component.html +++ b/apps/client/src/app/components/header/header.component.html @@ -141,6 +141,7 @@ [user]="user" (closed)="closeAssistant()" (dateRangeChanged)="onDateRangeChange($event)" + (selectedTagChanged)="onSelectedTagChanged($event)" /> diff --git a/apps/client/src/app/components/header/header.component.ts b/apps/client/src/app/components/header/header.component.ts index 5218e2870..19bd7e8fa 100644 --- a/apps/client/src/app/components/header/header.component.ts +++ b/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 { DateRange } from '@ghostfolio/common/types'; import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component'; +import { Tag } from '@prisma/client'; import { EMPTY, Subject } from 'rxjs'; import { catchError, takeUntil } from 'rxjs/operators'; @@ -173,6 +174,20 @@ export class HeaderComponent implements OnChanges { 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() { this.signOut.next(); } diff --git a/apps/client/src/app/components/toggle/toggle.component.html b/apps/client/src/app/components/toggle/toggle.component.html index fd923fdf5..88713760a 100644 --- a/apps/client/src/app/components/toggle/toggle.component.html +++ b/apps/client/src/app/components/toggle/toggle.component.html @@ -1,6 +1,6 @@ >(); - public option = new FormControl(undefined); + public optionFormControl = new FormControl(undefined); public constructor() {} public ngOnInit() {} public ngOnChanges() { - this.option.setValue(this.defaultValue); + this.optionFormControl.setValue(this.defaultValue); } public onValueChange() { - this.change.emit({ value: this.option.value }); + this.change.emit({ value: this.optionFormControl.value }); } } diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts index 5d3468319..d11429d3b 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts +++ b/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 { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; 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 { DataSource, Order as OrderModel } from '@prisma/client'; import { format, parseISO } from 'date-fns'; @@ -111,6 +111,8 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit { if (state?.user) { this.updateUser(state.user); + this.fetchActivities(); + this.changeDetectorRef.markForCheck(); } }); @@ -122,6 +124,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit { if (this.user?.settings?.isExperimentalFeatures === true) { this.dataService .fetchActivities({ + filters: this.userService.getFilters(), skip: this.pageIndex * this.pageSize, sortColumn: this.sortColumn, sortDirection: this.sortDirection, diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts index 51dcee24c..f3072d5bc 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts @@ -225,7 +225,10 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { private fetchDividendsAndInvestments() { this.dataService .fetchDividends({ - filters: this.activeFilters, + filters: + this.activeFilters.length > 0 + ? this.activeFilters + : this.userService.getFilters(), groupBy: this.mode, range: this.user?.settings?.dateRange }) @@ -238,7 +241,10 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { this.dataService .fetchInvestments({ - filters: this.activeFilters, + filters: + this.activeFilters.length > 0 + ? this.activeFilters + : this.userService.getFilters(), groupBy: this.mode, range: this.user?.settings?.dateRange }) @@ -252,16 +258,16 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { ? translate('YEAR') : translate('YEARS') : this.streaks?.currentStreak === 1 - ? translate('MONTH') - : translate('MONTHS'); + ? translate('MONTH') + : translate('MONTHS'); this.unitLongestStreak = this.mode === 'year' ? this.streaks?.longestStreak === 1 ? translate('YEAR') : translate('YEARS') : this.streaks?.longestStreak === 1 - ? translate('MONTH') - : translate('MONTHS'); + ? translate('MONTH') + : translate('MONTHS'); this.changeDetectorRef.markForCheck(); }); @@ -313,7 +319,10 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { this.dataService .fetchPortfolioPerformance({ - filters: this.activeFilters, + filters: + this.activeFilters.length > 0 + ? this.activeFilters + : this.userService.getFilters(), range: this.user?.settings?.dateRange }) .pipe(takeUntil(this.unsubscribeSubject)) @@ -358,7 +367,10 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { this.dataService .fetchPositions({ - filters: this.activeFilters, + filters: + this.activeFilters.length > 0 + ? this.activeFilters + : this.userService.getFilters(), range: this.user?.settings?.dateRange }) .pipe(takeUntil(this.unsubscribeSubject)) diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index 74bb4719a..ae14d205a 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -1,6 +1,7 @@

Analysis

-
+ @if (!user?.settings?.isExperimentalFeatures) { +
+ }
{ } } + public getFilters() { + const user = this.getState().user; + + return user?.settings?.isExperimentalFeatures === true + ? user.settings['filters.tags'] + ? [ + { + id: user.settings['filters.tags'][0], + type: 'TAG' + } + ] + : [] + : []; + } + public remove() { this.setState({ user: null }, UserStoreActions.RemoveUser); } diff --git a/libs/common/src/lib/interfaces/user-settings.interface.ts b/libs/common/src/lib/interfaces/user-settings.interface.ts index d3864ab64..4ac7c4726 100644 --- a/libs/common/src/lib/interfaces/user-settings.interface.ts +++ b/libs/common/src/lib/interfaces/user-settings.interface.ts @@ -7,6 +7,7 @@ export interface UserSettings { colorScheme?: ColorScheme; dateRange?: DateRange; emergencyFund?: number; + 'filters.tags'?: string[]; isExperimentalFeatures?: boolean; isRestrictedView?: boolean; language?: string; diff --git a/libs/ui/src/lib/activities-table-lazy/activities-table-lazy.component.ts b/libs/ui/src/lib/activities-table-lazy/activities-table-lazy.component.ts index 950df149b..0ceb69bef 100644 --- a/libs/ui/src/lib/activities-table-lazy/activities-table-lazy.component.ts +++ b/libs/ui/src/lib/activities-table-lazy/activities-table-lazy.component.ts @@ -75,7 +75,6 @@ export class ActivitiesTableLazyComponent public isLoading = true; public isUUID = isUUID; public routeQueryParams: Subscription; - public searchKeywords: string[] = []; public selectedRows = new SelectionModel(true, []); private unsubscribeSubject = new Subject(); @@ -182,15 +181,7 @@ export class ActivitiesTableLazyComponent } public onExport() { - if (this.searchKeywords.length > 0) { - this.export.emit( - this.dataSource.filteredData.map((activity) => { - return activity.id; - }) - ); - } else { - this.export.emit(); - } + this.export.emit(); } public onExportDraft(aActivityId: string) { diff --git a/libs/ui/src/lib/assistant/assistant.component.ts b/libs/ui/src/lib/assistant/assistant.component.ts index 4cb67dcf5..46c892860 100644 --- a/libs/ui/src/lib/assistant/assistant.component.ts +++ b/libs/ui/src/lib/assistant/assistant.component.ts @@ -7,6 +7,7 @@ import { EventEmitter, HostListener, Input, + OnChanges, OnDestroy, OnInit, Output, @@ -22,6 +23,7 @@ import { DataService } from '@ghostfolio/client/services/data.service'; import { User } from '@ghostfolio/common/interfaces'; import { DateRange } from '@ghostfolio/common/types'; import { translate } from '@ghostfolio/ui/i18n'; +import { Tag } from '@prisma/client'; import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs'; import { catchError, @@ -41,7 +43,7 @@ import { ISearchResultItem, ISearchResults } from './interfaces/interfaces'; styleUrls: ['./assistant.scss'], templateUrl: './assistant.html' }) -export class AssistantComponent implements OnDestroy, OnInit { +export class AssistantComponent implements OnChanges, OnDestroy, OnInit { @HostListener('document:keydown', ['$event']) onKeydown( event: KeyboardEvent ) { @@ -80,6 +82,7 @@ export class AssistantComponent implements OnDestroy, OnInit { @Output() closed = new EventEmitter(); @Output() dateRangeChanged = new EventEmitter(); + @Output() selectedTagChanged = new EventEmitter(); @ViewChild('menuTrigger') menuTriggerElement: MatMenuTrigger; @ViewChild('search', { static: true }) searchElement: ElementRef; @@ -98,6 +101,8 @@ export class AssistantComponent implements OnDestroy, OnInit { assetProfiles: [], holdings: [] }; + public tags: Tag[] = []; + public tagsFormControl = new FormControl(undefined); private keyManager: FocusKeyManager; private unsubscribeSubject = new Subject(); @@ -109,6 +114,15 @@ export class AssistantComponent implements OnDestroy, OnInit { ) {} public ngOnInit() { + const { tags } = this.dataService.fetchInfo(); + + this.tags = tags.map(({ id, name }) => { + return { + id, + name: translate(name) + }; + }); + this.searchFormControl.valueChanges .pipe( 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() { this.isLoading = true; this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap(); @@ -181,6 +201,16 @@ export class AssistantComponent implements OnDestroy, OnInit { 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) { this.isOpen = aIsOpen; } diff --git a/libs/ui/src/lib/assistant/assistant.html b/libs/ui/src/lib/assistant/assistant.html index d85e7e8fb..6b58faea2 100644 --- a/libs/ui/src/lib/assistant/assistant.html +++ b/libs/ui/src/lib/assistant/assistant.html @@ -88,8 +88,14 @@
- +
+ + Tags +
+ + No tag + @for (tag of tags; track tag.id) { + {{ tag.name }} + } + +
+
diff --git a/libs/ui/src/lib/assistant/assistant.module.ts b/libs/ui/src/lib/assistant/assistant.module.ts index abeb3aa75..61b6b3fa2 100644 --- a/libs/ui/src/lib/assistant/assistant.module.ts +++ b/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 { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { MatRadioModule } from '@angular/material/radio'; import { MatTabsModule } from '@angular/material/tabs'; import { RouterModule } from '@angular/router'; import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; @@ -19,6 +20,7 @@ import { AssistantComponent } from './assistant.component'; GfAssistantListItemModule, GfToggleModule, MatButtonModule, + MatRadioModule, MatTabsModule, NgxSkeletonLoaderModule, ReactiveFormsModule, diff --git a/libs/ui/src/lib/assistant/assistant.scss b/libs/ui/src/lib/assistant/assistant.scss index 3339009d4..178d68daa 100644 --- a/libs/ui/src/lib/assistant/assistant.scss +++ b/libs/ui/src/lib/assistant/assistant.scss @@ -1,6 +1,14 @@ :host { display: block; + .filter-container { + ::ng-deep { + label { + margin-bottom: 0; + } + } + } + .result-container { max-height: 15rem; }