Browse Source

Feature: Refactor access filter dialog and improve filter handling

pull/5848/head
Germán Martín 2 months ago
parent
commit
a2bd17692e
  1. 4
      apps/api/src/app/access/access-filter.dto.ts
  2. 1
      apps/api/src/app/access/access-settings.interface.ts
  3. 22
      apps/api/src/app/access/access.controller.ts
  4. 10
      apps/api/src/app/endpoints/public/public.controller.ts
  5. 151
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  6. 74
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  7. 11
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.scss

4
apps/api/src/app/access/access-filter.dto.ts

@ -8,7 +8,7 @@ import {
ValidateNested ValidateNested
} from 'class-validator'; } from 'class-validator';
export class HoldingFilterDto { class HoldingFilterDto {
@IsEnum(DataSource) @IsEnum(DataSource)
dataSource: DataSource; dataSource: DataSource;
@ -29,8 +29,8 @@ export class AccessFilterDto {
@IsArray() @IsArray()
@IsOptional() @IsOptional()
@ValidateNested({ each: true })
@Type(() => HoldingFilterDto) @Type(() => HoldingFilterDto)
@ValidateNested({ each: true })
holdings?: HoldingFilterDto[]; holdings?: HoldingFilterDto[];
@IsArray() @IsArray()

1
apps/api/src/app/access/access-settings.interface.ts

@ -1 +0,0 @@
export type { AccessSettings } from '@ghostfolio/common/interfaces';

22
apps/api/src/app/access/access.controller.ts

@ -19,14 +19,24 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Access as AccessModel } from '@prisma/client'; import { Access as AccessModel, Prisma } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccessSettings } from './access-settings.interface';
import { AccessService } from './access.service'; import { AccessService } from './access.service';
import { CreateAccessDto } from './create-access.dto'; import { CreateAccessDto } from './create-access.dto';
import { UpdateAccessDto } from './update-access.dto'; import { UpdateAccessDto } from './update-access.dto';
interface AccessFilter {
accountIds?: string[];
assetClasses?: string[];
holdings?: { dataSource: string; symbol: string }[];
tagIds?: string[];
}
interface AccessSettings {
filter?: AccessFilter;
}
@Controller('access') @Controller('access')
export class AccessController { export class AccessController {
public constructor( public constructor(
@ -62,7 +72,7 @@ export class AccessController {
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: undefined, : undefined,
permissions: data.permissions, permissions: data.permissions,
settings: settings as any, settings: settings as Prisma.InputJsonValue,
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}); });
} catch { } catch {
@ -116,20 +126,20 @@ export class AccessController {
if (granteeUser) { if (granteeUser) {
return { return {
alias, alias,
grantee: granteeUser?.id,
id, id,
permissions: accessPermissions, permissions: accessPermissions,
settings: settings as AccessSettings, settings: settings as AccessSettings,
grantee: granteeUser?.id,
type: 'PRIVATE' type: 'PRIVATE'
}; };
} }
return { return {
alias, alias,
grantee: 'Public',
id, id,
permissions: accessPermissions, permissions: accessPermissions,
settings: settings as AccessSettings, settings: settings as AccessSettings,
grantee: 'Public',
type: 'PUBLIC' type: 'PUBLIC'
}; };
} }
@ -177,7 +187,7 @@ export class AccessController {
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: { disconnect: true }, : { disconnect: true },
permissions: data.permissions, permissions: data.permissions,
settings: settings as any settings: settings as Prisma.InputJsonValue
}, },
where: { id } where: { id }
}); });

10
apps/api/src/app/endpoints/public/public.controller.ts

@ -74,7 +74,7 @@ export class PublicController {
if (accessFilter) { if (accessFilter) {
// Add account filters // Add account filters
if (accessFilter.accountIds && accessFilter.accountIds.length > 0) { if (accessFilter.accountIds?.length > 0) {
portfolioFilters.push( portfolioFilters.push(
...accessFilter.accountIds.map((accountId) => ({ ...accessFilter.accountIds.map((accountId) => ({
id: accountId, id: accountId,
@ -84,7 +84,7 @@ export class PublicController {
} }
// Add asset class filters // Add asset class filters
if (accessFilter.assetClasses && accessFilter.assetClasses.length > 0) { if (accessFilter.assetClasses?.length > 0) {
portfolioFilters.push( portfolioFilters.push(
...accessFilter.assetClasses.map((assetClass) => ({ ...accessFilter.assetClasses.map((assetClass) => ({
id: assetClass, id: assetClass,
@ -94,7 +94,7 @@ export class PublicController {
} }
// Add tag filters // Add tag filters
if (accessFilter.tagIds && accessFilter.tagIds.length > 0) { if (accessFilter.tagIds?.length > 0) {
portfolioFilters.push( portfolioFilters.push(
...accessFilter.tagIds.map((tagId) => ({ ...accessFilter.tagIds.map((tagId) => ({
id: tagId, id: tagId,
@ -105,7 +105,7 @@ export class PublicController {
// Add holding filters (symbol + dataSource) // Add holding filters (symbol + dataSource)
// Each holding needs both DATA_SOURCE and SYMBOL filters // Each holding needs both DATA_SOURCE and SYMBOL filters
if (accessFilter.holdings && accessFilter.holdings.length > 0) { if (accessFilter.holdings?.length > 0) {
accessFilter.holdings.forEach((holding) => { accessFilter.holdings.forEach((holding) => {
portfolioFilters.push( portfolioFilters.push(
{ {
@ -143,7 +143,6 @@ export class PublicController {
}) })
]); ]);
// Filter out only the base currency cash holdings
const baseCurrency = const baseCurrency =
user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY; user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY;
const filteredHoldings = Object.fromEntries( const filteredHoldings = Object.fromEntries(
@ -193,7 +192,6 @@ export class PublicController {
withExcludedAccountsAndActivities: false withExcludedAccountsAndActivities: false
}); });
// If multiple holdings, filter activities manually
let filteredActivities = activities; let filteredActivities = activities;
if (hasMultipleHoldingFilters && accessFilter.holdings) { if (hasMultipleHoldingFilters && accessFilter.holdings) {
filteredActivities = activities.filter((activity) => { filteredActivities = activities.filter((activity) => {

151
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts

@ -2,6 +2,10 @@ import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { UpdateAccessDto } from '@ghostfolio/api/app/access/update-access.dto'; import { UpdateAccessDto } from '@ghostfolio/api/app/access/update-access.dto';
import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces';
import { AccountWithPlatform } from '@ghostfolio/common/types'; import { AccountWithPlatform } from '@ghostfolio/common/types';
import {
GfPortfolioFilterFormComponent,
PortfolioFilterFormValue
} from '@ghostfolio/ui/portfolio-filter-form';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -42,6 +46,7 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
host: { class: 'h-100' }, host: { class: 'h-100' },
imports: [ imports: [
FormsModule, FormsModule,
GfPortfolioFilterFormComponent,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,
@ -60,7 +65,6 @@ export class GfCreateOrUpdateAccessDialogComponent
public mode: 'create' | 'update'; public mode: 'create' | 'update';
public showFilterPanel = false; public showFilterPanel = false;
// Datos para el filtro
public accounts: AccountWithPlatform[] = []; public accounts: AccountWithPlatform[] = [];
public assetClasses: Filter[] = []; public assetClasses: Filter[] = [];
public holdings: PortfolioPosition[] = []; public holdings: PortfolioPosition[] = [];
@ -69,12 +73,12 @@ export class GfCreateOrUpdateAccessDialogComponent
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
public dialogRef: MatDialogRef<GfCreateOrUpdateAccessDialogComponent>,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) private data: CreateOrUpdateAccessDialogParams, @Inject(MAT_DIALOG_DATA) private data: CreateOrUpdateAccessDialogParams,
private dataService: DataService, private dataService: DataService,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private notificationService: NotificationService private notificationService: NotificationService,
public dialogRef: MatDialogRef<GfCreateOrUpdateAccessDialogComponent>
) { ) {
this.mode = this.data.access?.id ? 'update' : 'create'; this.mode = this.data.access?.id ? 'update' : 'create';
} }
@ -84,10 +88,7 @@ export class GfCreateOrUpdateAccessDialogComponent
this.accessForm = this.formBuilder.group({ this.accessForm = this.formBuilder.group({
alias: [this.data.access.alias], alias: [this.data.access.alias],
filterAccount: [null], filters: [null],
filterAssetClass: [null],
filterHolding: [null],
filterTag: [null],
granteeUserId: [ granteeUserId: [
this.data.access.grantee, this.data.access.grantee,
isPublic isPublic
@ -113,11 +114,7 @@ export class GfCreateOrUpdateAccessDialogComponent
(control: AbstractControl) => Validators.required(control) (control: AbstractControl) => Validators.required(control)
]); ]);
this.showFilterPanel = false; this.showFilterPanel = false;
// Limpiar los filtros this.accessForm.get('filters')?.setValue(null);
this.accessForm.get('filterAccount')?.setValue(null);
this.accessForm.get('filterAssetClass')?.setValue(null);
this.accessForm.get('filterHolding')?.setValue(null);
this.accessForm.get('filterTag')?.setValue(null);
} else { } else {
granteeUserIdControl.clearValidators(); granteeUserIdControl.clearValidators();
granteeUserIdControl.setValue(null); granteeUserIdControl.setValue(null);
@ -131,7 +128,6 @@ export class GfCreateOrUpdateAccessDialogComponent
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
// Si ya es público al iniciar, mostrar el panel y cargar datos
if (isPublic) { if (isPublic) {
this.showFilterPanel = true; this.showFilterPanel = true;
this.loadFilterData(); this.loadFilterData();
@ -163,19 +159,16 @@ export class GfCreateOrUpdateAccessDialogComponent
tagIds?: string[]; tagIds?: string[];
} }
| undefined { | undefined {
const filterAccount = this.accessForm.get('filterAccount')?.value as const filterValue = this.accessForm.get('filters')
| string ?.value as PortfolioFilterFormValue | null;
| null;
const filterAssetClass = this.accessForm.get('filterAssetClass')?.value as if (
| string !filterValue ||
| null; (!filterValue.account &&
const filterHolding = this.accessForm.get('filterHolding')?.value as !filterValue.assetClass &&
| string !filterValue.holding &&
| null; !filterValue.tag)
const filterTag = this.accessForm.get('filterTag')?.value as string | null; ) {
// Solo retornar filtro si hay al menos un campo con valor
if (!filterAccount && !filterAssetClass && !filterHolding && !filterTag) {
return undefined; return undefined;
} }
@ -186,29 +179,25 @@ export class GfCreateOrUpdateAccessDialogComponent
tagIds?: string[]; tagIds?: string[];
} = {}; } = {};
if (filterAccount) { if (filterValue.account) {
filter.accountIds = [filterAccount]; filter.accountIds = [filterValue.account];
} }
if (filterAssetClass) { if (filterValue.assetClass) {
filter.assetClasses = [filterAssetClass]; filter.assetClasses = [filterValue.assetClass];
} }
if (filterHolding) { if (filterValue.holding) {
// Buscar el holding seleccionado para obtener dataSource y symbol filter.holdings = [
const holding = this.holdings.find((h) => h.symbol === filterHolding); {
if (holding) { dataSource: filterValue.holding.dataSource,
filter.holdings = [ symbol: filterValue.holding.symbol
{ }
dataSource: holding.dataSource, ];
symbol: holding.symbol
}
];
}
} }
if (filterTag) { if (filterValue.tag) {
filter.tagIds = [filterTag]; filter.tagIds = [filterValue.tag];
} }
return filter; return filter;
@ -223,15 +212,7 @@ export class GfCreateOrUpdateAccessDialogComponent
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => { .subscribe((response) => {
this.accounts = response.accounts; this.accounts = response.accounts;
this.updateFiltersFormControl(existingFilter);
// Si existe un filtro de cuenta, establecerlo
if (existingFilter?.accountIds?.[0]) {
this.accessForm
.get('filterAccount')
?.setValue(existingFilter.accountIds[0]);
}
this.changeDetectorRef.markForCheck();
}); });
// Cargar holdings y asset classes // Cargar holdings y asset classes
@ -255,19 +236,7 @@ export class GfCreateOrUpdateAccessDialogComponent
type: 'ASSET_CLASS' as const type: 'ASSET_CLASS' as const
})); }));
// Si existe un filtro de asset class, establecerlo this.updateFiltersFormControl(existingFilter);
if (existingFilter?.assetClasses?.[0]) {
this.accessForm
.get('filterAssetClass')
?.setValue(existingFilter.assetClasses[0]);
}
// Si existe un filtro de holding, establecerlo
if (existingFilter?.holdings?.[0]?.symbol) {
this.accessForm
.get('filterHolding')
?.setValue(existingFilter.holdings[0].symbol);
}
} }
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -283,15 +252,57 @@ export class GfCreateOrUpdateAccessDialogComponent
type: 'TAG' as const type: 'TAG' as const
})); }));
// Si existe un filtro de tag, establecerlo this.updateFiltersFormControl(existingFilter);
if (existingFilter?.tagIds?.[0]) {
this.accessForm.get('filterTag')?.setValue(existingFilter.tagIds[0]);
}
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
private updateFiltersFormControl(
existingFilter:
| {
accountIds?: string[];
assetClasses?: string[];
holdings?: { dataSource: string; symbol: string }[];
tagIds?: string[];
}
| undefined
) {
if (!existingFilter) {
return;
}
const filterValue: Partial<PortfolioFilterFormValue> = {};
if (existingFilter.accountIds?.[0] && this.accounts.length > 0) {
filterValue.account = existingFilter.accountIds[0];
}
if (existingFilter.assetClasses?.[0] && this.assetClasses.length > 0) {
filterValue.assetClass = existingFilter.assetClasses[0];
}
if (existingFilter.holdings?.[0] && this.holdings.length > 0) {
const holdingData = existingFilter.holdings[0];
const holding = this.holdings.find(
(h) =>
h.dataSource === holdingData.dataSource &&
h.symbol === holdingData.symbol
);
if (holding) {
filterValue.holding = holding;
}
}
if (existingFilter.tagIds?.[0] && this.tags.length > 0) {
filterValue.tag = existingFilter.tagIds[0];
}
if (Object.keys(filterValue).length > 0) {
this.accessForm.get('filters')?.setValue(filterValue);
this.changeDetectorRef.markForCheck();
}
}
private async createAccess() { private async createAccess() {
// Construir el objeto filter si estamos en modo PUBLIC // Construir el objeto filter si estamos en modo PUBLIC
const filter = this.showFilterPanel ? this.buildFilterObject() : undefined; const filter = this.showFilterPanel ? this.buildFilterObject() : undefined;

74
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html

@ -62,75 +62,19 @@
@if (showFilterPanel) { @if (showFilterPanel) {
<div class="mt-4"> <div class="mt-4">
<h2 class="mb-3" i18n style="font-size: 1.125rem; font-weight: 500"> <h2 class="filter-settings-title" i18n>Filter Settings (Optional)</h2>
Filter Settings (Optional) <p class="filter-settings-description text-muted" i18n>
</h2>
<p class="text-muted mb-3" i18n style="font-size: 0.875rem">
Configure which accounts, holdings, tags, and asset classes will be Configure which accounts, holdings, tags, and asset classes will be
visible in this public access. Leave empty to show all data. visible in this public access. Leave empty to show all data.
</p> </p>
<div class="mb-3"> <gf-portfolio-filter-form
<mat-form-field appearance="outline" class="w-100 without-hint"> formControlName="filters"
<mat-label i18n>Account</mat-label> [accounts]="accounts"
<mat-select formControlName="filterAccount"> [assetClasses]="assetClasses"
<mat-option [value]="null" /> [holdings]="holdings"
@for (account of accounts; track account.id) { [tags]="tags"
<mat-option [value]="account.id"> />
{{ account.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Holding</mat-label>
<mat-select formControlName="filterHolding">
<mat-option [value]="null" />
@for (holding of holdings; track holding.symbol) {
<mat-option [value]="holding.symbol">
<div class="line-height-1 text-truncate">
<span
><b>{{ holding.name }}</b></span
>
<br />
<small class="text-muted"
>{{ holding.symbol }} · {{ holding.currency }}</small
>
</div>
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Tag</mat-label>
<mat-select formControlName="filterTag">
<mat-option [value]="null" />
@for (tag of tags; track tag.id) {
<mat-option [value]="tag.id">{{ tag.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="filterAssetClass">
<mat-option [value]="null" />
@for (assetClass of assetClasses; track assetClass.id) {
<mat-option [value]="assetClass.id">{{
assetClass.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div> </div>
} }
</div> </div>

11
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.scss

@ -10,4 +10,15 @@
transform: rotate(90deg); transform: rotate(90deg);
} }
} }
.filter-settings-title {
font-size: 1.125rem;
font-weight: 500;
margin-bottom: 1rem;
}
.filter-settings-description {
font-size: 0.875rem;
margin-bottom: 1rem;
}
} }

Loading…
Cancel
Save