Browse Source

Merge 859314d4b6 into 7628e40f4b

pull/3507/merge
Usiel Riedl 3 days ago
committed by GitHub
parent
commit
9cc96ab640
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 26
      apps/api/src/app/order/order.service.ts
  2. 23
      apps/api/src/app/portfolio/portfolio.controller.ts
  3. 49
      apps/api/src/app/portfolio/portfolio.service.ts
  4. 15
      apps/client/src/app/services/data.service.ts
  5. 19
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html
  6. 4
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.scss
  7. 50
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts

26
apps/api/src/app/order/order.service.ts

@ -353,6 +353,24 @@ export class OrderService {
]; ];
const where: Prisma.OrderWhereInput = { userId }; 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) { if (endDate || startDate) {
where.AND = []; where.AND = [];
@ -381,10 +399,6 @@ export class OrderService {
return type === 'SYMBOL'; return type === 'SYMBOL';
})?.id; })?.id;
const searchQuery = filters?.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
if (filtersByAccount?.length > 0) { if (filtersByAccount?.length > 0) {
where.accountId = { where.accountId = {
in: filtersByAccount.map(({ id }) => { in: filtersByAccount.map(({ id }) => {
@ -398,7 +412,7 @@ export class OrderService {
} }
if (filtersByAssetClass?.length > 0) { if (filtersByAssetClass?.length > 0) {
where.SymbolProfile = { symbolProfileConditions.push({
OR: [ OR: [
{ {
AND: [ AND: [
@ -423,7 +437,7 @@ export class OrderService {
} }
} }
] ]
}; });
} }
if (filterByDataSource && filterBySymbol) { if (filterByDataSource && filterBySymbol) {

23
apps/api/src/app/portfolio/portfolio.controller.ts

@ -18,6 +18,7 @@ import {
UNKNOWN_KEY UNKNOWN_KEY
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
LookupItem,
PortfolioDetails, PortfolioDetails,
PortfolioDividends, PortfolioDividends,
PortfolioHoldingResponse, PortfolioHoldingResponse,
@ -428,6 +429,28 @@ export class PortfolioController {
return { holdings }; 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') @Get('investments')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)

49
apps/api/src/app/portfolio/portfolio.service.ts

@ -35,13 +35,19 @@ import {
TAG_ID_EMERGENCY_FUND, TAG_ID_EMERGENCY_FUND,
UNKNOWN_KEY UNKNOWN_KEY
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper'; import {
DATE_FORMAT,
getAssetProfileIdentifier,
getSum,
parseDate
} from '@ghostfolio/common/helper';
import { import {
AccountsResponse, AccountsResponse,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter, Filter,
HistoricalDataItem, HistoricalDataItem,
InvestmentItem, InvestmentItem,
LookupItem,
PortfolioDetails, PortfolioDetails,
PortfolioHoldingResponse, PortfolioHoldingResponse,
PortfolioInvestments, PortfolioInvestments,
@ -85,7 +91,7 @@ import {
parseISO, parseISO,
set set
} from 'date-fns'; } from 'date-fns';
import { isEmpty } from 'lodash'; import { isEmpty, uniqBy } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; 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<LookupItem[]> {
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({ public async getDetails({
dateRange = 'max', dateRange = 'max',
filters, filters,

15
apps/client/src/app/services/data.service.ts

@ -36,6 +36,7 @@ import {
Filter, Filter,
ImportResponse, ImportResponse,
InfoItem, InfoItem,
LookupItem,
LookupResponse, LookupResponse,
MarketDataDetailsResponse, MarketDataDetailsResponse,
MarketDataOfMarketsResponse, 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({ public fetchPortfolioHoldings({
filters, filters,
range range

19
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html

@ -11,8 +11,10 @@
[displayWith]="displayFn" [displayWith]="displayFn"
(optionSelected)="onUpdateSymbol($event)" (optionSelected)="onUpdateSymbol($event)"
> >
@if (!isLoading) { @if (
@for (lookupItem of lookupItems; track lookupItem) { (!isLoadingRemote || !isLoadingLocal) && filteredLookupItems.length > 0
) {
@for (lookupItem of filteredLookupItems; track lookupItem) {
<mat-option <mat-option
class="line-height-1" class="line-height-1"
[disabled]="lookupItem.dataProviderInfo.isPremium" [disabled]="lookupItem.dataProviderInfo.isPremium"
@ -41,9 +43,20 @@
> >
} }
} }
@if (isLoadingRemote || isLoadingLocal) {
<mat-option class="line-height-1" [disabled]="true">
<span class="align-items-center d-flex line-height-1">
<mat-spinner
class="mat-more-symbols-progress-spinner"
[diameter]="20"
/>
Loading more symbols ...
</span>
</mat-option>
}
} }
</mat-autocomplete> </mat-autocomplete>
@if (isLoading) { @if (isLoadingRemote || isLoadingLocal) {
<mat-spinner class="position-absolute" [diameter]="20" /> <mat-spinner class="position-absolute" [diameter]="20" />
} }

4
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.scss

@ -6,3 +6,7 @@
top: calc(50% - 10px); top: calc(50% - 10px);
} }
} }
.mat-more-symbols-progress-spinner {
margin-right: 0.5rem;
}

50
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts

@ -36,11 +36,12 @@ import {
import { MatInput, MatInputModule } from '@angular/material/input'; import { MatInput, MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { isString } from 'lodash'; import { isString } from 'lodash';
import { Subject, tap } from 'rxjs'; import { combineLatest, Subject, tap } from 'rxjs';
import { import {
debounceTime, debounceTime,
distinctUntilChanged, distinctUntilChanged,
filter, filter,
startWith,
switchMap, switchMap,
takeUntil takeUntil
} from 'rxjs/operators'; } from 'rxjs/operators';
@ -79,7 +80,8 @@ export class GfSymbolAutocompleteComponent
implements OnChanges, OnDestroy, OnInit implements OnChanges, OnDestroy, OnInit
{ {
@Input() public defaultLookupItems: LookupItem[] = []; @Input() public defaultLookupItems: LookupItem[] = [];
@Input() public isLoading = false; @Input() public isLoadingLocal = false;
@Input() public isLoadingRemote = false;
@ViewChild('symbolAutocomplete') public symbolAutocomplete: MatAutocomplete; @ViewChild('symbolAutocomplete') public symbolAutocomplete: MatAutocomplete;
@ -88,8 +90,8 @@ export class GfSymbolAutocompleteComponent
@ViewChild(MatInput) private input: MatInput; @ViewChild(MatInput) private input: MatInput;
public control = new FormControl(); public control = new FormControl();
public lookupItems: (LookupItem & { assetSubClassString: string })[] = []; public filteredLookupItems: (LookupItem & { assetSubClassString: string })[] =
[];
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
@ -129,29 +131,53 @@ export class GfSymbolAutocompleteComponent
return isString(query); return isString(query);
}), }),
tap(() => { tap(() => {
this.isLoading = true; this.isLoadingRemote = true;
this.isLoadingLocal = true;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}), }),
debounceTime(400), debounceTime(400),
distinctUntilChanged(), distinctUntilChanged(),
takeUntil(this.unsubscribeSubject), takeUntil(this.unsubscribeSubject),
switchMap((query: string) => { switchMap((query: string) =>
return this.dataService.fetchSymbols({ combineLatest([
this.dataService
.fetchPortfolioLookup({
query
})
.pipe(startWith(undefined)),
this.dataService
.fetchSymbols({
query, query,
includeIndices: this.includeIndices includeIndices: this.includeIndices
});
}) })
.pipe(startWith(undefined))
])
)
) )
.subscribe((filteredLookupItems) => { .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 { return {
...lookupItem, ...lookupItem,
assetSubClassString: translate(lookupItem.assetSubClass) assetSubClassString: translate(lookupItem.assetSubClass)
}; };
}); });
this.isLoading = false; if (localItems !== undefined) {
this.isLoadingLocal = false;
}
if (remoteItems !== undefined) {
this.isLoadingRemote = false;
}
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -176,7 +202,7 @@ export class GfSymbolAutocompleteComponent
} }
public isValueInOptions(value: string) { public isValueInOptions(value: string) {
return this.lookupItems.some((item) => { return this.filteredLookupItems.some((item) => {
return item.symbol === value; return item.symbol === value;
}); });
} }
@ -209,7 +235,7 @@ export class GfSymbolAutocompleteComponent
} }
private showDefaultOptions() { private showDefaultOptions() {
this.lookupItems = this.defaultLookupItems.map((lookupItem) => { this.filteredLookupItems = this.defaultLookupItems.map((lookupItem) => {
return { return {
...lookupItem, ...lookupItem,
assetSubClassString: translate(lookupItem.assetSubClass) assetSubClassString: translate(lookupItem.assetSubClass)

Loading…
Cancel
Save