Feature/refactor filters with interface (#883)
* Refactor filtering with an interface * Filter by accounts * Update changelog
This commit is contained in:
parent
ce6b5fb7cb
commit
16dd8f7652
@ -9,9 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Added support for filtering by accounts on the allocations page
|
||||||
- Added support for private equity
|
- Added support for private equity
|
||||||
- Extended the form to set the asset and asset sub class for (wealth) items
|
- Extended the form to set the asset and asset sub class for (wealth) items
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refactored the filtering (activities table and allocations page)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed the tooltip update in the portfolio proportion chart component
|
- Fixed the tooltip update in the portfolio proportion chart component
|
||||||
|
@ -4,6 +4,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
@ -16,6 +17,7 @@ import {
|
|||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import { endOfToday, isAfter } from 'date-fns';
|
import { endOfToday, isAfter } from 'date-fns';
|
||||||
|
import { groupBy } from 'lodash';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { Activity } from './interfaces/activities.interface';
|
import { Activity } from './interfaces/activities.interface';
|
||||||
@ -166,31 +168,44 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getOrders({
|
public async getOrders({
|
||||||
|
filters,
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
tags,
|
|
||||||
types,
|
types,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
|
filters?: Filter[];
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
tags?: string[];
|
|
||||||
types?: TypeOfOrder[];
|
types?: TypeOfOrder[];
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<Activity[]> {
|
}): Promise<Activity[]> {
|
||||||
const where: Prisma.OrderWhereInput = { userId };
|
const where: Prisma.OrderWhereInput = { userId };
|
||||||
|
|
||||||
|
const { account: filtersByAccount, tag: filtersByTag } = groupBy(
|
||||||
|
filters,
|
||||||
|
(filter) => {
|
||||||
|
return filter.type;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filtersByAccount?.length > 0) {
|
||||||
|
where.accountId = {
|
||||||
|
in: filtersByAccount.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (includeDrafts === false) {
|
if (includeDrafts === false) {
|
||||||
where.isDraft = false;
|
where.isDraft = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tags?.length > 0) {
|
if (filtersByTag?.length > 0) {
|
||||||
where.tags = {
|
where.tags = {
|
||||||
some: {
|
some: {
|
||||||
OR: tags.map((tag) => {
|
OR: filtersByTag.map(({ id }) => {
|
||||||
return {
|
return { id };
|
||||||
name: tag
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -11,6 +11,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
|||||||
import { baseCurrency } from '@ghostfolio/common/config';
|
import { baseCurrency } from '@ghostfolio/common/config';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
|
Filter,
|
||||||
PortfolioChart,
|
PortfolioChart,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioInvestments,
|
PortfolioInvestments,
|
||||||
@ -19,7 +20,7 @@ import {
|
|||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary
|
PortfolioSummary
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@ -105,17 +106,36 @@ export class PortfolioController {
|
|||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getDetails(
|
public async getDetails(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('tags') tags?: string
|
@Query('range') range?: DateRange,
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
|
|
||||||
|
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||||
|
const tagIds = filterByTags?.split(',') ?? [];
|
||||||
|
|
||||||
|
const filters: Filter[] = [
|
||||||
|
...accountIds.map((accountId) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: accountId,
|
||||||
|
type: 'account'
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
...tagIds.map((tagId) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: tagId,
|
||||||
|
type: 'tag'
|
||||||
|
};
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
const { accounts, holdings, hasErrors } =
|
const { accounts, holdings, hasErrors } =
|
||||||
await this.portfolioService.getDetails(
|
await this.portfolioService.getDetails(
|
||||||
impersonationId,
|
impersonationId,
|
||||||
this.request.user.id,
|
this.request.user.id,
|
||||||
range,
|
range,
|
||||||
tags?.split(',')
|
filters
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||||
@ -163,7 +183,7 @@ export class PortfolioController {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
hasError,
|
hasError,
|
||||||
accounts: tags ? {} : accounts,
|
accounts: filters ? {} : accounts,
|
||||||
holdings: isBasicUser ? {} : holdings
|
holdings: isBasicUser ? {} : holdings
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -29,6 +29,7 @@ import {
|
|||||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
Accounts,
|
Accounts,
|
||||||
|
Filter,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioPerformanceResponse,
|
PortfolioPerformanceResponse,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
@ -309,7 +310,7 @@ export class PortfolioService {
|
|||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aUserId: string,
|
aUserId: string,
|
||||||
aDateRange: DateRange = 'max',
|
aDateRange: DateRange = 'max',
|
||||||
tags?: string[]
|
aFilters?: Filter[]
|
||||||
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||||
const userId = await this.getUserId(aImpersonationId, aUserId);
|
const userId = await this.getUserId(aImpersonationId, aUserId);
|
||||||
const user = await this.userService.user({ id: userId });
|
const user = await this.userService.user({ id: userId });
|
||||||
@ -324,8 +325,8 @@ export class PortfolioService {
|
|||||||
|
|
||||||
const { orders, portfolioOrders, transactionPoints } =
|
const { orders, portfolioOrders, transactionPoints } =
|
||||||
await this.getTransactionPoints({
|
await this.getTransactionPoints({
|
||||||
tags,
|
userId,
|
||||||
userId
|
filters: aFilters
|
||||||
});
|
});
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
@ -448,7 +449,7 @@ export class PortfolioService {
|
|||||||
value: totalValue
|
value: totalValue
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tags === undefined) {
|
if (aFilters === undefined) {
|
||||||
for (const symbol of Object.keys(cashPositions)) {
|
for (const symbol of Object.keys(cashPositions)) {
|
||||||
holdings[symbol] = cashPositions[symbol];
|
holdings[symbol] = cashPositions[symbol];
|
||||||
}
|
}
|
||||||
@ -1195,12 +1196,12 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getTransactionPoints({
|
private async getTransactionPoints({
|
||||||
|
filters,
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
tags,
|
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
|
filters?: Filter[];
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
tags?: string[];
|
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
transactionPoints: TransactionPoint[];
|
transactionPoints: TransactionPoint[];
|
||||||
@ -1210,8 +1211,8 @@ export class PortfolioService {
|
|||||||
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||||
|
|
||||||
const orders = await this.orderService.getOrders({
|
const orders = await this.orderService.getOrders({
|
||||||
|
filters,
|
||||||
includeDrafts,
|
includeDrafts,
|
||||||
tags,
|
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId,
|
userId,
|
||||||
types: ['BUY', 'SELL']
|
types: ['BUY', 'SELL']
|
||||||
|
@ -8,6 +8,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
|
|||||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
import { prettifySymbol } from '@ghostfolio/common/helper';
|
import { prettifySymbol } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
|
Filter,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioPosition,
|
PortfolioPosition,
|
||||||
UniqueAsset,
|
UniqueAsset,
|
||||||
@ -32,6 +33,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
|||||||
value: number;
|
value: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
public allFilters: Filter[];
|
||||||
public continents: {
|
public continents: {
|
||||||
[code: string]: { name: string; value: number };
|
[code: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
@ -39,7 +41,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
|||||||
[code: string]: { name: string; value: number };
|
[code: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public filters$ = new Subject<string[]>();
|
public filters$ = new Subject<Filter[]>();
|
||||||
public hasImpersonationId: boolean;
|
public hasImpersonationId: boolean;
|
||||||
public isLoading = false;
|
public isLoading = false;
|
||||||
public markets: {
|
public markets: {
|
||||||
@ -76,7 +78,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
|||||||
value: number;
|
value: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
public tags: string[] = [];
|
|
||||||
|
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
@ -127,10 +128,10 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
|||||||
this.filters$
|
this.filters$
|
||||||
.pipe(
|
.pipe(
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
switchMap((tags) => {
|
switchMap((filters) => {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
|
||||||
return this.dataService.fetchPortfolioDetails({ tags });
|
return this.dataService.fetchPortfolioDetails({ filters });
|
||||||
}),
|
}),
|
||||||
takeUntil(this.unsubscribeSubject)
|
takeUntil(this.unsubscribeSubject)
|
||||||
)
|
)
|
||||||
@ -150,10 +151,26 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
|||||||
if (state?.user) {
|
if (state?.user) {
|
||||||
this.user = state.user;
|
this.user = state.user;
|
||||||
|
|
||||||
this.tags = this.user.tags.map((tag) => {
|
const accountFilters: Filter[] = this.user.accounts.map(
|
||||||
return tag.name;
|
({ id, name }) => {
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
label: name,
|
||||||
|
type: 'account'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const tagFilters: Filter[] = this.user.tags.map(({ id, name }) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: name,
|
||||||
|
type: 'tag'
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.allFilters = [...accountFilters, ...tagFilters];
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -3,9 +3,8 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="d-flex justify-content-center mb-3" i18n>Allocations</h3>
|
<h3 class="d-flex justify-content-center mb-3" i18n>Allocations</h3>
|
||||||
<gf-activities-filter
|
<gf-activities-filter
|
||||||
[allFilters]="tags"
|
[allFilters]="allFilters"
|
||||||
[isLoading]="isLoading"
|
[isLoading]="isLoading"
|
||||||
[ngClass]="{ 'd-none': tags.length <= 0 }"
|
|
||||||
[placeholder]="placeholder"
|
[placeholder]="placeholder"
|
||||||
(valueChanged)="filters$.next($event)"
|
(valueChanged)="filters$.next($event)"
|
||||||
></gf-activities-filter>
|
></gf-activities-filter>
|
||||||
|
@ -19,6 +19,7 @@ import {
|
|||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
Export,
|
Export,
|
||||||
|
Filter,
|
||||||
InfoItem,
|
InfoItem,
|
||||||
PortfolioChart,
|
PortfolioChart,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
@ -33,7 +34,7 @@ import { permissions } from '@ghostfolio/common/permissions';
|
|||||||
import { DateRange } from '@ghostfolio/common/types';
|
import { DateRange } from '@ghostfolio/common/types';
|
||||||
import { DataSource, Order as OrderModel } from '@prisma/client';
|
import { DataSource, Order as OrderModel } from '@prisma/client';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
import { cloneDeep } from 'lodash';
|
import { cloneDeep, groupBy } from 'lodash';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -182,11 +183,38 @@ export class DataService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public fetchPortfolioDetails({ tags }: { tags?: string[] }) {
|
public fetchPortfolioDetails({ filters }: { filters?: Filter[] }) {
|
||||||
let params = new HttpParams();
|
let params = new HttpParams();
|
||||||
|
|
||||||
if (tags?.length > 0) {
|
if (filters?.length > 0) {
|
||||||
params = params.append('tags', tags.join(','));
|
const { account: filtersByAccount, tag: filtersByTag } = groupBy(
|
||||||
|
filters,
|
||||||
|
(filter) => {
|
||||||
|
return filter.type;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filtersByAccount) {
|
||||||
|
params = params.append(
|
||||||
|
'accounts',
|
||||||
|
filtersByAccount
|
||||||
|
.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
})
|
||||||
|
.join(',')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtersByTag) {
|
||||||
|
params = params.append(
|
||||||
|
'tags',
|
||||||
|
filtersByTag
|
||||||
|
.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
})
|
||||||
|
.join(',')
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.http.get<PortfolioDetails>('/api/v1/portfolio/details', {
|
return this.http.get<PortfolioDetails>('/api/v1/portfolio/details', {
|
||||||
|
5
libs/common/src/lib/interfaces/filter.interface.ts
Normal file
5
libs/common/src/lib/interfaces/filter.interface.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export interface Filter {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
type: 'account' | 'tag';
|
||||||
|
}
|
@ -8,6 +8,7 @@ import {
|
|||||||
} from './admin-market-data.interface';
|
} from './admin-market-data.interface';
|
||||||
import { Coupon } from './coupon.interface';
|
import { Coupon } from './coupon.interface';
|
||||||
import { Export } from './export.interface';
|
import { Export } from './export.interface';
|
||||||
|
import { Filter } from './filter.interface';
|
||||||
import { InfoItem } from './info-item.interface';
|
import { InfoItem } from './info-item.interface';
|
||||||
import { PortfolioChart } from './portfolio-chart.interface';
|
import { PortfolioChart } from './portfolio-chart.interface';
|
||||||
import { PortfolioDetails } from './portfolio-details.interface';
|
import { PortfolioDetails } from './portfolio-details.interface';
|
||||||
@ -38,6 +39,7 @@ export {
|
|||||||
AdminMarketDataItem,
|
AdminMarketDataItem,
|
||||||
Coupon,
|
Coupon,
|
||||||
Export,
|
Export,
|
||||||
|
Filter,
|
||||||
InfoItem,
|
InfoItem,
|
||||||
PortfolioChart,
|
PortfolioChart,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
|
@ -2,13 +2,13 @@
|
|||||||
<ion-icon class="mr-1" matPrefix name="search-outline"></ion-icon>
|
<ion-icon class="mr-1" matPrefix name="search-outline"></ion-icon>
|
||||||
<mat-chip-list #chipList aria-label="Search keywords">
|
<mat-chip-list #chipList aria-label="Search keywords">
|
||||||
<mat-chip
|
<mat-chip
|
||||||
*ngFor="let searchKeyword of searchKeywords"
|
*ngFor="let filter of selectedFilters"
|
||||||
class="mx-1 my-0 px-2 py-0"
|
class="mx-1 my-0 px-2 py-0"
|
||||||
matChipRemove
|
matChipRemove
|
||||||
[removable]="true"
|
[removable]="true"
|
||||||
(removed)="removeKeyword(searchKeyword)"
|
(removed)="onRemoveFilter(filter)"
|
||||||
>
|
>
|
||||||
{{ searchKeyword | gfSymbol }}
|
{{ filter.label | gfSymbol }}
|
||||||
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
|
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
|
||||||
</mat-chip>
|
</mat-chip>
|
||||||
<input
|
<input
|
||||||
@ -19,15 +19,15 @@
|
|||||||
[matChipInputFor]="chipList"
|
[matChipInputFor]="chipList"
|
||||||
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
|
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
|
||||||
[placeholder]="placeholder"
|
[placeholder]="placeholder"
|
||||||
(matChipInputTokenEnd)="addKeyword($event)"
|
(matChipInputTokenEnd)="onAddFilter($event)"
|
||||||
/>
|
/>
|
||||||
</mat-chip-list>
|
</mat-chip-list>
|
||||||
<mat-autocomplete
|
<mat-autocomplete
|
||||||
#autocomplete="matAutocomplete"
|
#autocomplete="matAutocomplete"
|
||||||
(optionSelected)="keywordSelected($event)"
|
(optionSelected)="onSelectFilter($event)"
|
||||||
>
|
>
|
||||||
<mat-option *ngFor="let filter of filters | async" [value]="filter">
|
<mat-option *ngFor="let filter of filters | async" [value]="filter">
|
||||||
{{ filter | gfSymbol }}
|
{{ filter.label | gfSymbol }}
|
||||||
</mat-option>
|
</mat-option>
|
||||||
</mat-autocomplete>
|
</mat-autocomplete>
|
||||||
<mat-spinner
|
<mat-spinner
|
||||||
|
@ -17,6 +17,7 @@ import {
|
|||||||
MatAutocompleteSelectedEvent
|
MatAutocompleteSelectedEvent
|
||||||
} from '@angular/material/autocomplete';
|
} from '@angular/material/autocomplete';
|
||||||
import { MatChipInputEvent } from '@angular/material/chips';
|
import { MatChipInputEvent } from '@angular/material/chips';
|
||||||
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
import { BehaviorSubject, Observable, Subject } from 'rxjs';
|
import { BehaviorSubject, Observable, Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -27,19 +28,19 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
templateUrl: './activities-filter.component.html'
|
templateUrl: './activities-filter.component.html'
|
||||||
})
|
})
|
||||||
export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
||||||
@Input() allFilters: string[];
|
@Input() allFilters: Filter[];
|
||||||
@Input() isLoading: boolean;
|
@Input() isLoading: boolean;
|
||||||
@Input() placeholder: string;
|
@Input() placeholder: string;
|
||||||
|
|
||||||
@Output() valueChanged = new EventEmitter<string[]>();
|
@Output() valueChanged = new EventEmitter<Filter[]>();
|
||||||
|
|
||||||
@ViewChild('autocomplete') matAutocomplete: MatAutocomplete;
|
@ViewChild('autocomplete') matAutocomplete: MatAutocomplete;
|
||||||
@ViewChild('searchInput') searchInput: ElementRef<HTMLInputElement>;
|
@ViewChild('searchInput') searchInput: ElementRef<HTMLInputElement>;
|
||||||
|
|
||||||
public filters$: Subject<string[]> = new BehaviorSubject([]);
|
public filters$: Subject<Filter[]> = new BehaviorSubject([]);
|
||||||
public filters: Observable<string[]> = this.filters$.asObservable();
|
public filters: Observable<Filter[]> = this.filters$.asObservable();
|
||||||
public searchControl = new FormControl();
|
public searchControl = new FormControl();
|
||||||
public searchKeywords: string[] = [];
|
public selectedFilters: Filter[] = [];
|
||||||
public separatorKeysCodes: number[] = [ENTER, COMMA];
|
public separatorKeysCodes: number[] = [ENTER, COMMA];
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
@ -47,16 +48,23 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
|||||||
public constructor() {
|
public constructor() {
|
||||||
this.searchControl.valueChanges
|
this.searchControl.valueChanges
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((keyword) => {
|
.subscribe((currentFilter: string) => {
|
||||||
if (keyword) {
|
if (currentFilter) {
|
||||||
const filterValue = keyword.toLowerCase();
|
|
||||||
this.filters$.next(
|
this.filters$.next(
|
||||||
this.allFilters.filter(
|
this.allFilters
|
||||||
(filter) => filter.toLowerCase().indexOf(filterValue) === 0
|
.filter((filter) => {
|
||||||
)
|
// Filter selected filters
|
||||||
|
return !this.selectedFilters.some((selectedFilter) => {
|
||||||
|
return selectedFilter.id === filter.id;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.filter((filter) => {
|
||||||
|
return filter.label
|
||||||
|
.toLowerCase()
|
||||||
|
.startsWith(currentFilter?.toLowerCase());
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label))
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
this.filters$.next(this.allFilters);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -67,9 +75,8 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public addKeyword({ input, value }: MatChipInputEvent): void {
|
public onAddFilter({ input, value }: MatChipInputEvent): void {
|
||||||
if (value?.trim()) {
|
if (value?.trim()) {
|
||||||
this.searchKeywords.push(value.trim());
|
|
||||||
this.updateFilter();
|
this.updateFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,31 +88,39 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
|||||||
this.searchControl.setValue(null);
|
this.searchControl.setValue(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public keywordSelected(event: MatAutocompleteSelectedEvent): void {
|
public onRemoveFilter(aFilter: Filter): void {
|
||||||
this.searchKeywords.push(event.option.viewValue);
|
this.selectedFilters = this.selectedFilters.filter((filter) => {
|
||||||
|
return filter.id !== aFilter.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onSelectFilter(event: MatAutocompleteSelectedEvent): void {
|
||||||
|
this.selectedFilters.push(event.option.value);
|
||||||
this.updateFilter();
|
this.updateFilter();
|
||||||
this.searchInput.nativeElement.value = '';
|
this.searchInput.nativeElement.value = '';
|
||||||
this.searchControl.setValue(null);
|
this.searchControl.setValue(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeKeyword(keyword: string): void {
|
|
||||||
const index = this.searchKeywords.indexOf(keyword);
|
|
||||||
|
|
||||||
if (index >= 0) {
|
|
||||||
this.searchKeywords.splice(index, 1);
|
|
||||||
this.updateFilter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateFilter() {
|
private updateFilter() {
|
||||||
this.filters$.next(this.allFilters);
|
this.filters$.next(
|
||||||
|
this.allFilters
|
||||||
|
.filter((filter) => {
|
||||||
|
// Filter selected filters
|
||||||
|
return !this.selectedFilters.some((selectedFilter) => {
|
||||||
|
return selectedFilter.id === filter.id;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label))
|
||||||
|
);
|
||||||
|
|
||||||
// Emit an array with a new reference
|
// Emit an array with a new reference
|
||||||
this.valueChanged.emit([...this.searchKeywords]);
|
this.valueChanged.emit([...this.selectedFilters]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
<gf-activities-filter
|
<gf-activities-filter
|
||||||
[allFilters]="allFilters"
|
[allFilters]="allFilters"
|
||||||
|
[isLoading]="isLoading"
|
||||||
[ngClass]="{ 'd-none': !hasPermissionToFilter }"
|
[ngClass]="{ 'd-none': !hasPermissionToFilter }"
|
||||||
[placeholder]="placeholder"
|
[placeholder]="placeholder"
|
||||||
(valueChanged)="updateFilter($event)"
|
(valueChanged)="filters$.next($event)"
|
||||||
></gf-activities-filter>
|
></gf-activities-filter>
|
||||||
|
|
||||||
<div class="activities">
|
<div class="activities">
|
||||||
|
@ -14,13 +14,13 @@ import { MatTableDataSource } from '@angular/material/table';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import { isUUID } from 'class-validator';
|
import { isUUID } from 'class-validator';
|
||||||
import { endOfToday, format, isAfter } from 'date-fns';
|
import { endOfToday, format, isAfter } from 'date-fns';
|
||||||
import { isNumber } from 'lodash';
|
import { isNumber } from 'lodash';
|
||||||
import { Subject, Subscription } from 'rxjs';
|
import { distinctUntilChanged, Subject, Subscription, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
const SEARCH_PLACEHOLDER = 'Search for account, currency, symbol or type...';
|
const SEARCH_PLACEHOLDER = 'Search for account, currency, symbol or type...';
|
||||||
const SEARCH_STRING_SEPARATOR = ',';
|
const SEARCH_STRING_SEPARATOR = ',';
|
||||||
@ -53,11 +53,12 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
|||||||
|
|
||||||
@ViewChild(MatSort) sort: MatSort;
|
@ViewChild(MatSort) sort: MatSort;
|
||||||
|
|
||||||
public allFilters: string[];
|
public allFilters: Filter[];
|
||||||
public dataSource: MatTableDataSource<Activity> = new MatTableDataSource();
|
public dataSource: MatTableDataSource<Activity> = new MatTableDataSource();
|
||||||
public defaultDateFormat: string;
|
public defaultDateFormat: string;
|
||||||
public displayedColumns = [];
|
public displayedColumns = [];
|
||||||
public endOfToday = endOfToday();
|
public endOfToday = endOfToday();
|
||||||
|
public filters$ = new Subject<Filter[]>();
|
||||||
public hasDrafts = false;
|
public hasDrafts = false;
|
||||||
public isAfter = isAfter;
|
public isAfter = isAfter;
|
||||||
public isLoading = true;
|
public isLoading = true;
|
||||||
@ -71,7 +72,13 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
|||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(private router: Router) {}
|
public constructor(private router: Router) {
|
||||||
|
this.filters$
|
||||||
|
.pipe(distinctUntilChanged(), takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((filters) => {
|
||||||
|
this.updateFilters(filters);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public ngOnChanges() {
|
public ngOnChanges() {
|
||||||
this.displayedColumns = [
|
this.displayedColumns = [
|
||||||
@ -95,11 +102,15 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isLoading = true;
|
|
||||||
|
|
||||||
this.defaultDateFormat = getDateFormatString(this.locale);
|
this.defaultDateFormat = getDateFormatString(this.locale);
|
||||||
|
|
||||||
if (this.activities) {
|
if (this.activities) {
|
||||||
|
this.allFilters = this.getSearchableFieldValues(this.activities).map(
|
||||||
|
(label) => {
|
||||||
|
return { label, id: label, type: 'tag' };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
this.dataSource = new MatTableDataSource(this.activities);
|
this.dataSource = new MatTableDataSource(this.activities);
|
||||||
this.dataSource.filterPredicate = (data, filter) => {
|
this.dataSource.filterPredicate = (data, filter) => {
|
||||||
const dataString = this.getFilterableValues(data)
|
const dataString = this.getFilterableValues(data)
|
||||||
@ -113,8 +124,8 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
|||||||
return contains;
|
return contains;
|
||||||
};
|
};
|
||||||
this.dataSource.sort = this.sort;
|
this.dataSource.sort = this.sort;
|
||||||
this.updateFilter();
|
|
||||||
this.isLoading = false;
|
this.updateFilters();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,30 +183,6 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
|||||||
this.activityToUpdate.emit(aActivity);
|
this.activityToUpdate.emit(aActivity);
|
||||||
}
|
}
|
||||||
|
|
||||||
public updateFilter(filters: string[] = []) {
|
|
||||||
this.dataSource.filter = filters.join(SEARCH_STRING_SEPARATOR);
|
|
||||||
const lowercaseSearchKeywords = filters.map((keyword) =>
|
|
||||||
keyword.trim().toLowerCase()
|
|
||||||
);
|
|
||||||
|
|
||||||
this.placeholder =
|
|
||||||
lowercaseSearchKeywords.length <= 0 ? SEARCH_PLACEHOLDER : '';
|
|
||||||
|
|
||||||
this.searchKeywords = filters;
|
|
||||||
|
|
||||||
this.allFilters = this.getSearchableFieldValues(this.activities).filter(
|
|
||||||
(item) => {
|
|
||||||
return !lowercaseSearchKeywords.includes(item.trim().toLowerCase());
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.hasDrafts = this.dataSource.data.some((activity) => {
|
|
||||||
return activity.isDraft === true;
|
|
||||||
});
|
|
||||||
this.totalFees = this.getTotalFees();
|
|
||||||
this.totalValue = this.getTotalValue();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
@ -280,4 +267,32 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
|||||||
|
|
||||||
return totalValue.toNumber();
|
return totalValue.toNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updateFilters(filters: Filter[] = []) {
|
||||||
|
this.isLoading = true;
|
||||||
|
|
||||||
|
this.dataSource.filter = filters
|
||||||
|
.map((filter) => {
|
||||||
|
return filter.label;
|
||||||
|
})
|
||||||
|
.join(SEARCH_STRING_SEPARATOR);
|
||||||
|
const lowercaseSearchKeywords = filters.map((filter) => {
|
||||||
|
return filter.label.trim().toLowerCase();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.placeholder =
|
||||||
|
lowercaseSearchKeywords.length <= 0 ? SEARCH_PLACEHOLDER : '';
|
||||||
|
|
||||||
|
this.searchKeywords = filters.map((filter) => {
|
||||||
|
return filter.label;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.hasDrafts = this.dataSource.filteredData.some((activity) => {
|
||||||
|
return activity.isDraft === true;
|
||||||
|
});
|
||||||
|
this.totalFees = this.getTotalFees();
|
||||||
|
this.totalValue = this.getTotalValue();
|
||||||
|
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user