diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index 21fa0d076..62bda2b5b 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -353,6 +353,24 @@ export class OrderService { ]; const where: Prisma.OrderWhereInput = { userId }; + const symbolProfileConditions: Prisma.SymbolProfileWhereInput[] = []; + where.SymbolProfile = { + AND: symbolProfileConditions + }; + const searchQuery = filters?.find(({ type }) => { + return type === 'SEARCH_QUERY'; + })?.id; + if (searchQuery) { + symbolProfileConditions.push({ + OR: [ + { id: { mode: 'insensitive', contains: searchQuery } }, + { isin: { mode: 'insensitive', contains: searchQuery } }, + { name: { mode: 'insensitive', contains: searchQuery } }, + { symbol: { mode: 'insensitive', contains: searchQuery } } + ] + }); + } + if (endDate || startDate) { where.AND = []; @@ -381,10 +399,6 @@ export class OrderService { return type === 'SYMBOL'; })?.id; - const searchQuery = filters?.find(({ type }) => { - return type === 'SEARCH_QUERY'; - })?.id; - if (filtersByAccount?.length > 0) { where.accountId = { in: filtersByAccount.map(({ id }) => { @@ -398,7 +412,7 @@ export class OrderService { } if (filtersByAssetClass?.length > 0) { - where.SymbolProfile = { + symbolProfileConditions.push({ OR: [ { AND: [ @@ -423,7 +437,7 @@ export class OrderService { } } ] - }; + }); } if (filterByDataSource && filterBySymbol) { diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 2c71a4668..8567f3bfb 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -18,6 +18,7 @@ import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { + LookupItem, PortfolioDetails, PortfolioDividends, PortfolioHoldingResponse, @@ -428,6 +429,28 @@ export class PortfolioController { return { holdings }; } + @Get('lookup') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async lookupSymbol( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Query('accounts') filterByAccounts?: string, + @Query('query') filterBySearchQuery?: string + ): Promise<{ items: LookupItem[] }> { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterBySearchQuery + }); + + return { + items: await this.portfolioService.getLookup({ + filters, + impersonationId, + userId: this.request.user.id + }) + }; + } + @Get('investments') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(TransformDataSourceInRequestInterceptor) diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 4d09edf8b..8e0a98437 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -35,13 +35,19 @@ import { TAG_ID_EMERGENCY_FUND, UNKNOWN_KEY } from '@ghostfolio/common/config'; -import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper'; +import { + DATE_FORMAT, + getAssetProfileIdentifier, + getSum, + parseDate +} from '@ghostfolio/common/helper'; import { AccountsResponse, EnhancedSymbolProfile, Filter, HistoricalDataItem, InvestmentItem, + LookupItem, PortfolioDetails, PortfolioHoldingResponse, PortfolioInvestments, @@ -85,7 +91,7 @@ import { parseISO, set } from 'date-fns'; -import { isEmpty } from 'lodash'; +import { isEmpty, uniqBy } from 'lodash'; import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; @@ -387,6 +393,45 @@ export class PortfolioService { }; } + public async getLookup({ + filters, + impersonationId, + userId + }: { + dateRange?: DateRange; + filters?: Filter[]; + impersonationId: string; + userId: string; + }): Promise { + userId = await this.getUserId(impersonationId, userId); + const user = await this.userService.user({ id: userId }); + const userCurrency = this.getUserCurrency(user); + + const { activities } = await this.orderService.getOrders({ + filters, + userCurrency, + userId + }); + + return uniqBy(activities, (activity) => { + return getAssetProfileIdentifier({ + dataSource: activity.SymbolProfile.dataSource, + symbol: activity.SymbolProfile.symbol + }); + }).map((activity) => ({ + assetClass: activity.SymbolProfile.assetClass, + assetSubClass: activity.SymbolProfile.assetSubClass, + currency: activity.SymbolProfile.currency, + dataProviderInfo: { + isPremium: false, + name: 'Ghostfolio API' + }, + dataSource: activity.SymbolProfile.dataSource, + name: activity.SymbolProfile.name, + symbol: activity.SymbolProfile.symbol + })); + } + public async getDetails({ dateRange = 'max', filters, diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 820ad5e3c..537aacdd6 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -36,6 +36,7 @@ import { Filter, ImportResponse, InfoItem, + LookupItem, LookupResponse, MarketDataDetailsResponse, MarketDataOfMarketsResponse, @@ -602,6 +603,20 @@ export class DataService { ); } + public fetchPortfolioLookup({ query }: { query: string }) { + const params = new HttpParams().set('query', query); + + return this.http + .get<{ items: LookupItem[] }>('/api/v1/portfolio/lookup', { + params + }) + .pipe( + map((response) => { + return response.items; + }) + ); + } + public fetchPortfolioHoldings({ filters, range diff --git a/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html index 456cd9940..47265dbc6 100644 --- a/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html +++ b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html @@ -11,8 +11,10 @@ [displayWith]="displayFn" (optionSelected)="onUpdateSymbol($event)" > - @if (!isLoading) { - @for (lookupItem of lookupItems; track lookupItem) { + @if ( + (!isLoadingRemote || !isLoadingLocal) && filteredLookupItems.length > 0 + ) { + @for (lookupItem of filteredLookupItems; track lookupItem) { } } + @if (isLoadingRemote || isLoadingLocal) { + + + + Loading more symbols ... + + + } } -@if (isLoading) { +@if (isLoadingRemote || isLoadingLocal) { } diff --git a/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.scss b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.scss index 71c06f26e..c9a1a35f9 100644 --- a/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.scss +++ b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.scss @@ -6,3 +6,7 @@ top: calc(50% - 10px); } } + +.mat-more-symbols-progress-spinner { + margin-right: 0.5rem; +} diff --git a/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts index 4fb974c54..6a502fa47 100644 --- a/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts +++ b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts @@ -36,11 +36,12 @@ import { import { MatInput, MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { isString } from 'lodash'; -import { Subject, tap } from 'rxjs'; +import { combineLatest, Subject, tap } from 'rxjs'; import { debounceTime, distinctUntilChanged, filter, + startWith, switchMap, takeUntil } from 'rxjs/operators'; @@ -79,7 +80,8 @@ export class GfSymbolAutocompleteComponent implements OnChanges, OnDestroy, OnInit { @Input() public defaultLookupItems: LookupItem[] = []; - @Input() public isLoading = false; + @Input() public isLoadingLocal = false; + @Input() public isLoadingRemote = false; @ViewChild('symbolAutocomplete') public symbolAutocomplete: MatAutocomplete; @@ -88,8 +90,8 @@ export class GfSymbolAutocompleteComponent @ViewChild(MatInput) private input: MatInput; public control = new FormControl(); - public lookupItems: (LookupItem & { assetSubClassString: string })[] = []; - + public filteredLookupItems: (LookupItem & { assetSubClassString: string })[] = + []; private unsubscribeSubject = new Subject(); public constructor( @@ -129,29 +131,53 @@ export class GfSymbolAutocompleteComponent return isString(query); }), tap(() => { - this.isLoading = true; + this.isLoadingRemote = true; + this.isLoadingLocal = true; this.changeDetectorRef.markForCheck(); }), debounceTime(400), distinctUntilChanged(), takeUntil(this.unsubscribeSubject), - switchMap((query: string) => { - return this.dataService.fetchSymbols({ - query, - includeIndices: this.includeIndices - }); - }) + switchMap((query: string) => + combineLatest([ + this.dataService + .fetchPortfolioLookup({ + query + }) + .pipe(startWith(undefined)), + this.dataService + .fetchSymbols({ + query, + includeIndices: this.includeIndices + }) + .pipe(startWith(undefined)) + ]) + ) ) .subscribe((filteredLookupItems) => { - this.lookupItems = filteredLookupItems.map((lookupItem) => { + const [localItems, remoteItems]: [LookupItem[], LookupItem[]] = + filteredLookupItems; + const uniqueItems = [ + ...new Map( + (localItems ?? []) + .concat(remoteItems ?? []) + .map((item) => [item.symbol, item]) + ).values() + ]; + this.filteredLookupItems = uniqueItems.map((lookupItem) => { return { ...lookupItem, assetSubClassString: translate(lookupItem.assetSubClass) }; }); - this.isLoading = false; + if (localItems !== undefined) { + this.isLoadingLocal = false; + } + if (remoteItems !== undefined) { + this.isLoadingRemote = false; + } this.changeDetectorRef.markForCheck(); }); @@ -176,7 +202,7 @@ export class GfSymbolAutocompleteComponent } public isValueInOptions(value: string) { - return this.lookupItems.some((item) => { + return this.filteredLookupItems.some((item) => { return item.symbol === value; }); } @@ -209,7 +235,7 @@ export class GfSymbolAutocompleteComponent } private showDefaultOptions() { - this.lookupItems = this.defaultLookupItems.map((lookupItem) => { + this.filteredLookupItems = this.defaultLookupItems.map((lookupItem) => { return { ...lookupItem, assetSubClassString: translate(lookupItem.assetSubClass)