Browse Source

add symbol-autocomplete-comp, change assetProfileForm coverage, refactor endpoint

pull/4469/head
tobikugel 4 weeks ago
committed by Thomas Kaul
parent
commit
a437a1c722
  1. 11
      apps/api/src/app/admin/admin.controller.ts
  2. 125
      apps/api/src/app/admin/admin.service.ts
  3. 10
      apps/api/src/app/admin/update-asset-profile.dto.ts
  4. 22
      apps/api/src/services/market-data/market-data.service.ts
  5. 89
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  6. 20
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  7. 498
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  8. 2
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts

11
apps/api/src/app/admin/admin.controller.ts

@ -338,11 +338,12 @@ export class AdminController {
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<EnhancedSymbolProfile> {
return this.adminService.patchAssetProfileData({
...assetProfileData,
dataSource,
symbol
});
return this.adminService.patchAssetProfileData(
{ dataSource, symbol },
{
...assetProfileData
}
);
}
@HasPermission(permissions.accessAdminControl)

125
apps/api/src/app/admin/admin.service.ts

@ -463,61 +463,102 @@ export class AdminService {
return { count, users };
}
public async patchAssetProfileData({
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
holdings,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
url
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
const symbolProfileOverrides = {
assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass,
name: name as string,
url: url as string
};
const updatedSymbolProfile: AssetProfileIdentifier &
Prisma.SymbolProfileUpdateInput = {
public async patchAssetProfileData(
assetProfileIdentifier: AssetProfileIdentifier,
{
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
holdings,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, name, url }
: {
SymbolProfileOverrides: {
upsert: {
create: symbolProfileOverrides,
update: symbolProfileOverrides
}
}
})
};
url
}: Prisma.SymbolProfileUpdateInput
) {
if (
symbol &&
dataSource &&
assetProfileIdentifier.symbol !== symbol &&
assetProfileIdentifier.dataSource !== dataSource
) {
await this.symbolProfileService.updateAssetProfileIdentifier(
assetProfileIdentifier,
{
dataSource: dataSource as DataSource, // TODO change
symbol: symbol as string
}
);
await this.marketDataService.updateAssetProfileIdentifier(
assetProfileIdentifier,
{
dataSource: dataSource as DataSource,
symbol: symbol as string
}
);
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[
{
dataSource: dataSource as DataSource,
symbol: symbol as string
}
]
);
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{
return symbolProfile;
} else {
const symbolProfileOverrides = {
assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass,
name: name as string,
url: url as string
};
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = {
comment,
countries,
currency,
dataSource,
symbol
}
]);
holdings,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, name, url }
: {
SymbolProfileOverrides: {
upsert: {
create: symbolProfileOverrides,
update: symbolProfileOverrides
}
}
})
};
await this.symbolProfileService.updateSymbolProfile(
assetProfileIdentifier,
updatedSymbolProfile
);
return symbolProfile;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[
{
dataSource: dataSource as DataSource,
symbol: symbol as string
}
]
);
return symbolProfile;
}
}
public async putSetting(key: string, value: string) {

10
apps/api/src/app/admin/update-asset-profile.dto.ts

@ -1,6 +1,6 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
import {
IsArray,
IsEnum,
@ -35,6 +35,14 @@ export class UpdateAssetProfileDto {
@IsOptional()
name?: string;
@IsEnum(DataSource, { each: true })
@IsOptional()
dataSource?: DataSource;
@IsString()
@IsOptional()
symbol?: string;
@IsObject()
@IsOptional()
scraperConfiguration?: Prisma.InputJsonObject;

22
apps/api/src/services/market-data/market-data.service.ts

@ -110,6 +110,28 @@ export class MarketDataService {
});
}
public async updateAssetProfileIdentifier(
oldAssetProfileIdentifier: AssetProfileIdentifier,
newAssetProfileIdentifier: AssetProfileIdentifier
) {
return this.prismaService.marketData.updateMany({
data: {
dataSource: newAssetProfileIdentifier.dataSource,
symbol: newAssetProfileIdentifier.symbol
},
where: {
AND: [
{
dataSource: oldAssetProfileIdentifier.dataSource
},
{
symbol: oldAssetProfileIdentifier.symbol
}
]
}
});
}
public async updateMarketData(params: {
data: {
state: MarketDataState;

89
apps/api/src/services/symbol-profile/symbol-profile.service.ts

@ -126,23 +126,78 @@ export class SymbolProfileService {
});
}
public updateSymbolProfile({
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
holdings,
isActive,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
SymbolProfileOverrides,
url
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
public async getSymbolProfilesByUserSubscription({
withUserSubscription = false
}: {
withUserSubscription?: boolean;
}) {
return this.prismaService.symbolProfile.findMany({
include: {
Order: {
include: {
User: true
}
}
},
orderBy: [{ symbol: 'asc' }],
where: {
Order: withUserSubscription
? {
some: {
User: {
Subscription: { some: { expiresAt: { gt: new Date() } } }
}
}
}
: {
every: {
User: {
Subscription: { none: { expiresAt: { gt: new Date() } } }
}
}
}
}
});
}
public updateAssetProfileIdentifier(
oldAssetProfileIdentifier: AssetProfileIdentifier,
newAssetProfileIdentifier: AssetProfileIdentifier
) {
return this.prismaService.symbolProfile.update({
data: {
dataSource: newAssetProfileIdentifier.dataSource,
symbol: newAssetProfileIdentifier.symbol
},
where: {
dataSource_symbol: {
dataSource: oldAssetProfileIdentifier.dataSource,
symbol: oldAssetProfileIdentifier.symbol
}
}
});
}
public updateSymbolProfile(
{ dataSource, symbol }: AssetProfileIdentifier,
{
assetClass,
assetSubClass,
comment,
countries,
currency,
//dataSource,
holdings,
isActive,
name,
scraperConfiguration,
sectors,
//symbol,
symbolMapping,
SymbolProfileOverrides,
url
}: Prisma.SymbolProfileUpdateInput
) {
return this.prismaService.symbolProfile.update({
data: {
assetClass,

20
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -55,6 +55,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
return { id: assetSubClass, label: translate(assetSubClass) };
});
public assetProfile: AdminMarketDataDetails['assetProfile'];
public assetProfileIdentifierForm;
public assetProfileForm = this.formBuilder.group({
assetClass: new FormControl<AssetClass>(undefined),
assetSubClass: new FormControl<AssetSubClass>(undefined),
@ -86,6 +87,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
public historicalDataItems: LineChartItem[];
public isBenchmark = false;
public isEditSymbolMode = false;
public marketDataItems: MarketData[] = [];
public modeValues = [
{
@ -269,7 +271,23 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
});
}
public async onSubmit() {
public onSetEditSymboleMode() {
this.isEditSymbolMode = true;
this.assetProfileForm.disable();
this.changeDetectorRef.markForCheck();
}
public onCancelEditSymboleMode() {
this.isEditSymbolMode = false;
this.assetProfileForm.enable();
this.changeDetectorRef.markForCheck();
}
public async onSubmitAssetProfileForm() {
let countries = [];
let scraperConfiguration = {};
let sectors = [];

498
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -1,9 +1,4 @@
<form
class="d-flex flex-column h-100"
[formGroup]="assetProfileForm"
(keyup.enter)="assetProfileForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<div class="d-flex flex-column h-100">
<div class="d-flex mb-3">
<h1 class="flex-grow-1 m-0" mat-dialog-title>
{{ assetProfile?.name ?? data.symbol }}
@ -91,21 +86,55 @@
/>
<div class="row">
<div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="assetProfile?.symbol"
>Symbol</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[value]="
assetProfile?.dataProviderInfo?.name ?? assetProfile?.dataSource
"
>Data Source</gf-value
>
</div>
@if (isEditSymbolMode) {
<div class="col-12 mb-3">
<mat-form-field appearance="outline">
<mat-label i18n>Name, symbol or ISIN</mat-label>
<gf-symbol-autocomplete
formControlName="searchSymbol"
[includeIndices]="true"
/>
</mat-form-field>
<button class="mx-1 no-min-width px-2" mat-button type="button">
Apply
</button>
<button
class="mx-1 no-min-width px-2"
mat-button
type="button"
(click)="onCancelEditSymboleMode()"
>
Cancel
</button>
</div>
} @else {
<div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="assetProfile?.symbol"
>Symbol</gf-value
>
</div>
<div class="col-4 mb-3">
<gf-value
i18n
size="medium"
[value]="
assetProfile?.dataProviderInfo?.name ?? assetProfile?.dataSource
"
>Data Source</gf-value
>
</div>
<div class="col-1 mb-3">
<button
class="mx-1 no-min-width px-2"
mat-button
type="button"
[disabled]="assetProfileForm.dirty"
(click)="onSetEditSymboleMode()"
>
<ion-icon name="create-outline"></ion-icon>
</button>
</div>
}
<div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="assetProfile?.currency"
>Currency</gf-value
@ -202,230 +231,255 @@
}
}
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Name</mat-label>
<input formControlName="name" matInput type="text" />
</mat-form-field>
</div>
@if (assetProfile?.dataSource === 'MANUAL') {
<form
[formGroup]="assetProfileForm"
(keyup.enter)="assetProfileForm.valid && onSubmitAssetProfileForm()"
(ngSubmit)="onSubmitAssetProfileForm()"
>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Currency</mat-label>
<gf-currency-selector
formControlName="currency"
[currencies]="currencies"
/>
<mat-label i18n>Name</mat-label>
<input formControlName="name" matInput type="text" />
</mat-form-field>
</div>
}
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass">
<mat-option [value]="null" />
@for (assetClass of assetClasses; track assetClass) {
<mat-option [value]="assetClass.id">{{
assetClass.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Sub Class</mat-label>
<mat-select formControlName="assetSubClass">
<mat-option [value]="null" />
@for (assetSubClass of assetSubClasses; track assetSubClass) {
<mat-option [value]="assetSubClass.id">{{
assetSubClass.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="d-flex my-3">
<div class="w-50">
<mat-checkbox
color="primary"
i18n
[checked]="isBenchmark"
(change)="
isBenchmark
? onUnsetBenchmark({
dataSource: data.dataSource,
symbol: data.symbol
})
: onSetBenchmark({
dataSource: data.dataSource,
symbol: data.symbol
})
"
>Benchmark</mat-checkbox
>
@if (assetProfile?.dataSource === 'MANUAL') {
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Currency</mat-label>
<gf-currency-selector
formControlName="currency"
[currencies]="currencies"
/>
</mat-form-field>
</div>
}
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass">
<mat-option [value]="null" />
@for (assetClass of assetClasses; track assetClass) {
<mat-option [value]="assetClass.id">{{
assetClass.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Symbol Mapping</mat-label>
<textarea
cdkTextareaAutosize
formControlName="symbolMapping"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
@if (assetProfile?.dataSource === 'MANUAL') {
<div class="mb-3">
<mat-accordion class="my-3">
<mat-expansion-panel
class="shadow-none"
[expanded]="
assetProfileForm.controls.scraperConfiguration.controls.selector
.value !== '' &&
assetProfileForm.controls.scraperConfiguration.controls.url
.value !== ''
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Sub Class</mat-label>
<mat-select formControlName="assetSubClass">
<mat-option [value]="null" />
@for (assetSubClass of assetSubClasses; track assetSubClass) {
<mat-option [value]="assetSubClass.id">{{
assetSubClass.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="d-flex my-3">
<div class="w-50">
<mat-checkbox
color="primary"
i18n
[checked]="isBenchmark"
[disabled]="isEditSymbolMode"
(change)="
isBenchmark
? onUnsetBenchmark({
dataSource: data.dataSource,
symbol: data.symbol
})
: onSetBenchmark({
dataSource: data.dataSource,
symbol: data.symbol
})
"
(closed)="scraperConfiguationIsExpanded.set(false)"
(opened)="scraperConfiguationIsExpanded.set(true)"
>Benchmark</mat-checkbox
>
<mat-expansion-panel-header class="p-0">
<mat-panel-title i18n>Scraper Configuration</mat-panel-title>
</mat-expansion-panel-header>
<div formGroupName="scraperConfiguration">
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Default Market Price</mat-label>
<input
formControlName="defaultMarketPrice"
matInput
type="number"
/>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>HTTP Request Headers</mat-label>
<textarea
cdkTextareaAutosize
formControlName="headers"
matInput
type="text"
[matAutocomplete]="auto"
></textarea>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Locale</mat-label>
<input formControlName="locale" matInput type="text" />
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Mode</mat-label>
<mat-select formControlName="mode">
@for (modeValue of modeValues; track modeValue) {
<mat-option [value]="modeValue.value">{{
modeValue.viewValue
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label>
<ng-container i18n>Selector</ng-container>*
</mat-label>
<textarea
cdkTextareaAutosize
formControlName="selector"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label>
<ng-container i18n>Url</ng-container>*
</mat-label>
<input formControlName="url" matInput type="text" />
</mat-form-field>
</div>
<div class="my-3 text-right">
<button
color="accent"
mat-flat-button
type="button"
[disabled]="
assetProfileForm.controls.scraperConfiguration.controls
.selector.value === '' ||
assetProfileForm.controls.scraperConfiguration.controls.url
.value === ''
"
(click)="onTestMarketData()"
>
<ng-container i18n>Test</ng-container>
</button>
</div>
</div>
</mat-expansion-panel>
</mat-accordion>
</div>
</div>
}
@if (assetProfile?.dataSource === 'MANUAL') {
<div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Sectors</mat-label>
<mat-label i18n>Symbol Mapping</mat-label>
<textarea
cdkTextareaAutosize
formControlName="sectors"
formControlName="symbolMapping"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
@if (assetProfile?.dataSource === 'MANUAL') {
<div class="mb-3">
<mat-accordion class="my-3">
<mat-expansion-panel
class="shadow-none"
[expanded]="
assetProfileForm.controls.scraperConfiguration.controls.selector
.value !== '' &&
assetProfileForm.controls.scraperConfiguration.controls.url
.value !== ''
"
(closed)="scraperConfiguationIsExpanded.set(false)"
(opened)="scraperConfiguationIsExpanded.set(true)"
>
<mat-expansion-panel-header class="p-0">
<mat-panel-title i18n>Scraper Configuration</mat-panel-title>
</mat-expansion-panel-header>
<div formGroupName="scraperConfiguration">
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>Default Market Price</mat-label>
<input
formControlName="defaultMarketPrice"
matInput
type="number"
/>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>HTTP Request Headers</mat-label>
<textarea
cdkTextareaAutosize
formControlName="headers"
matInput
type="text"
[matAutocomplete]="auto"
></textarea>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>Locale</mat-label>
<input formControlName="locale" matInput type="text" />
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>Mode</mat-label>
<mat-select formControlName="mode">
@for (modeValue of modeValues; track modeValue) {
<mat-option [value]="modeValue.value">{{
modeValue.viewValue
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label>
<ng-container i18n>Selector</ng-container>*
</mat-label>
<textarea
cdkTextareaAutosize
formControlName="selector"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label>
<ng-container i18n>Url</ng-container>*
</mat-label>
<input formControlName="url" matInput type="text" />
</mat-form-field>
</div>
<div class="my-3 text-right">
<button
color="accent"
mat-flat-button
type="button"
[disabled]="
assetProfileForm.controls.scraperConfiguration.controls
.selector.value === '' ||
assetProfileForm.controls.scraperConfiguration.controls
.url.value === ''
"
(click)="onTestMarketData()"
>
<ng-container i18n>Test</ng-container>
</button>
</div>
</div>
</mat-expansion-panel>
</mat-accordion>
</div>
}
@if (assetProfile?.dataSource === 'MANUAL') {
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Sectors</mat-label>
<textarea
cdkTextareaAutosize
formControlName="sectors"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Countries</mat-label>
<textarea
cdkTextareaAutosize
formControlName="countries"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
}
<div>
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Url</mat-label>
<input formControlName="url" matInput type="text" />
@if (assetProfileForm.get('url').value) {
<gf-asset-profile-icon
class="mr-3"
matSuffix
[url]="assetProfileForm.get('url').value"
/>
}
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Countries</mat-label>
<mat-label i18n>Note</mat-label>
<textarea
cdkAutosizeMinRows="2"
cdkTextareaAutosize
formControlName="countries"
formControlName="comment"
matInput
type="text"
(keyup.enter)="$event.stopPropagation()"
></textarea>
</mat-form-field>
</div>
}
<div>
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Url</mat-label>
<input formControlName="url" matInput type="text" />
@if (assetProfileForm.get('url').value) {
<gf-asset-profile-icon
class="mr-3"
matSuffix
[url]="assetProfileForm.get('url').value"
/>
}
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Note</mat-label>
<textarea
cdkAutosizeMinRows="2"
cdkTextareaAutosize
formControlName="comment"
matInput
(keyup.enter)="$event.stopPropagation()"
></textarea>
</mat-form-field>
</div>
</form>
</div>
<div class="d-flex justify-content-end" mat-dialog-actions>
@ -439,4 +493,4 @@
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>
</div>

2
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts

@ -4,6 +4,7 @@ import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { TextFieldModule } from '@angular/cdk/text-field';
@ -30,6 +31,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
GfCurrencySelectorComponent,
GfHistoricalMarketDataEditorComponent,
GfLineChartComponent,
GfSymbolAutocompleteComponent,
GfPortfolioProportionChartComponent,
GfValueComponent,
MatButtonModule,

Loading…
Cancel
Save