From b16edb845c06222fec8ea768a75395701d1d88e7 Mon Sep 17 00:00:00 2001 From: Ryan Chou <88779759+ryanchou1994@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:25:14 +0000 Subject: [PATCH] Fix closed holdings in portfolio filters Request active and closed holdings for the assistant filter so a saved selection survives closing a position and can still be reset. Cover the selection and reset flows with component tests, and use type-only imports for interfaces reached by the new isolated-module test compilation. --- .../assistant-list-item.component.ts | 2 +- .../lib/assistant/assistant.component.spec.ts | 201 ++++++++++++++++++ .../src/lib/assistant/assistant.component.ts | 10 +- 3 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 libs/ui/src/lib/assistant/assistant.component.spec.ts diff --git a/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts index aabda169f9..80df248fbf 100644 --- a/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts +++ b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts @@ -16,7 +16,7 @@ import { import { Params, RouterModule } from '@angular/router'; import { SearchMode } from '../enums/search-mode'; -import { +import type { AssetSearchResultItem, SearchResultItem } from '../interfaces/interfaces'; diff --git a/libs/ui/src/lib/assistant/assistant.component.spec.ts b/libs/ui/src/lib/assistant/assistant.component.spec.ts new file mode 100644 index 0000000000..676883c53f --- /dev/null +++ b/libs/ui/src/lib/assistant/assistant.component.spec.ts @@ -0,0 +1,201 @@ +import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; +import { DataService } from '@ghostfolio/ui/services'; + +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import '@angular/localize/init'; +import { MatButtonHarness } from '@angular/material/button/testing'; +import { MatSelectHarness } from '@angular/material/select/testing'; +import { provideRouter } from '@angular/router'; +import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; +import { of } from 'rxjs'; + +import { GfAssistantComponent } from './assistant.component'; + +jest.mock('@ionic/angular/standalone', () => { + const { Component } = + jest.requireActual('@angular/core'); + + return { + IonIcon: Component({ + selector: 'ion-icon', + template: '', + inputs: ['name'] + })(class {}) + }; +}); + +describe('GfAssistantComponent holding filters', () => { + let fixture: ComponentFixture; + let loader: HarnessLoader; + let holdings: PortfolioPosition[]; + let filtersChanged: jest.Mock; + + beforeEach(async () => { + holdings = [ + { + name: 'Active holding', + quantity: 1, + assetSubClass: AssetSubClass.STOCK + }, + { + name: 'Closed holding', + quantity: 0, + assetSubClass: AssetSubClass.STOCK + }, + { name: 'Cash holding', quantity: 1, assetSubClass: AssetSubClass.CASH } + ].map(({ name, quantity, assetSubClass }) => { + return { + assetProfile: { + assetClass: AssetClass.EQUITY, + assetSubClass, + currency: 'USD', + dataSource: DataSource.MANUAL, + name, + symbol: name + }, + quantity + } as PortfolioPosition; + }); + + await TestBed.configureTestingModule({ + imports: [GfAssistantComponent], + providers: [ + provideRouter([]), + { + provide: DataService, + useValue: { + fetchPortfolioHoldings: ({ + filters = [] + }: { filters?: Filter[] } = {}) => { + const activeOnly = filters.some(({ id, type }) => { + return type === 'HOLDING_TYPE' && id === 'ACTIVE'; + }); + + return of({ + holdings: holdings.filter(({ quantity }) => { + return !activeOnly || quantity > 0; + }) + }); + } + } + } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(GfAssistantComponent); + fixture.componentRef.setInput('hasPermissionToChangeFilters', true); + fixture.componentRef.setInput('user', { + accounts: [], + settings: {}, + tags: [] + }); + filtersChanged = jest.fn(); + fixture.componentRef.instance['filtersChanged'].subscribe(filtersChanged); + fixture.autoDetectChanges(); + loader = TestbedHarnessEnvironment.loader(fixture); + await fixture.whenStable(); + }); + + it('should offer active and closed holdings while excluding cash', async () => { + fixture.componentInstance.initialize(); + const holdingSelect = await loader.getHarness( + MatSelectHarness.with({ selector: '[formControlName="holding"]' }) + ); + await holdingSelect.open(); + + expect( + await Promise.all( + (await holdingSelect.getOptions()).map((option) => option.getText()) + ) + ).toEqual([ + '', + expect.stringMatching(/^Active holding/), + expect.stringMatching(/^Closed holding/) + ]); + + await holdingSelect.clickOptions({ text: /Closed holding/ }); + const applyButton = await loader.getHarness( + MatButtonHarness.with({ text: 'Apply Filters' }) + ); + await applyButton.click(); + + expect(filtersChanged).toHaveBeenCalledWith([ + { id: '', type: 'ACCOUNT' }, + { id: '', type: 'ASSET_CLASS' }, + { id: DataSource.MANUAL, type: 'DATA_SOURCE' }, + { id: 'Closed holding', type: 'SYMBOL' }, + { id: '', type: 'TAG' } + ]); + }); + + it('should retain a selected holding after it closes and allow resetting its filters', async () => { + fixture.componentRef.setInput('user', { + accounts: [], + settings: { + 'filters.dataSource': DataSource.MANUAL, + 'filters.symbol': 'Active holding' + }, + tags: [] + }); + await fixture.whenStable(); + fixture.componentInstance.initialize(); + const holdingSelect = await loader.getHarness( + MatSelectHarness.with({ selector: '[formControlName="holding"]' }) + ); + expect(await holdingSelect.getValueText()).toBe('Active holding'); + fixture.componentInstance.onCloseAssistant(); + + holdings[0].quantity = 0; + fixture.componentInstance.initialize(); + + expect(await holdingSelect.getValueText()).toBe('Active holding'); + const resetButton = await loader.getHarness( + MatButtonHarness.with({ text: 'Reset Filters' }) + ); + expect(await resetButton.isDisabled()).toBe(false); + await resetButton.click(); + + expect(filtersChanged).toHaveBeenCalledWith([ + { type: 'ACCOUNT', id: '' }, + { type: 'ASSET_CLASS', id: '' }, + { type: 'DATA_SOURCE', id: '' }, + { type: 'SYMBOL', id: '' }, + { type: 'TAG', id: '' } + ]); + expect(fixture.componentInstance.isOpen).toBe(false); + }); + + it('should keep Reset Filters disabled when no filter is selected', async () => { + fixture.componentInstance.initialize(); + const resetButton = await loader.getHarness( + MatButtonHarness.with({ text: 'Reset Filters' }) + ); + + expect(await resetButton.isDisabled()).toBe(true); + }); + + it('should keep filters disabled without permission to change them', async () => { + fixture.componentRef.setInput('hasPermissionToChangeFilters', false); + fixture.componentRef.setInput('user', { + accounts: [], + settings: { + 'filters.dataSource': DataSource.MANUAL, + 'filters.symbol': 'Closed holding' + }, + tags: [] + }); + await fixture.whenStable(); + fixture.componentInstance.initialize(); + const holdingSelect = await loader.getHarness( + MatSelectHarness.with({ selector: '[formControlName="holding"]' }) + ); + const resetButton = await loader.getHarness( + MatButtonHarness.with({ text: 'Reset Filters' }) + ); + + expect(await holdingSelect.isDisabled()).toBe(true); + expect(await resetButton.isDisabled()).toBe(true); + }); +}); diff --git a/libs/ui/src/lib/assistant/assistant.component.ts b/libs/ui/src/lib/assistant/assistant.component.ts index 6babc10398..8cc62b970b 100644 --- a/libs/ui/src/lib/assistant/assistant.component.ts +++ b/libs/ui/src/lib/assistant/assistant.component.ts @@ -1,5 +1,9 @@ import { SEARCH_QUERY_MAXIMUM_LENGTH } from '@ghostfolio/common/config'; -import { Filter, PortfolioPosition, User } from '@ghostfolio/common/interfaces'; +import type { + Filter, + PortfolioPosition, + User +} from '@ghostfolio/common/interfaces'; import { InternalRoute } from '@ghostfolio/common/routes/interfaces/internal-route.interface'; import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { AccountWithPlatform, DateRange } from '@ghostfolio/common/types'; @@ -479,9 +483,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { this.setIsOpen(true); this.dataService - .fetchPortfolioHoldings({ - filters: [{ id: 'ACTIVE', type: 'HOLDING_TYPE' }] - }) + .fetchPortfolioHoldings() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ holdings }) => { this.holdings = getHoldingsForFilter(holdings);