diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 9f15bfd840f..5026f5e2799 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -6123,6 +6123,12 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "items": { + "message": "Items" + }, + "searchResults": { + "message": "Search results" + }, "resizeSideNavigation": { "message": "Resize side navigation" }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html index 20871b4b134..f0a6b0d6000 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html @@ -107,20 +107,32 @@ @if (vaultState === null) { @if (!(loading$ | async)) { - - - + + @if (hasSearchText$ | async) { + + } @else { + + + + + } } } diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts index d7824f3df58..a322fbc53dd 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts @@ -44,6 +44,7 @@ import { VaultPopupAutofillService } from "../../services/vault-popup-autofill.s import { VaultPopupCopyButtonsService } from "../../services/vault-popup-copy-buttons.service"; import { VaultPopupItemsService } from "../../services/vault-popup-items.service"; import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.service"; +import { VaultPopupLoadingService } from "../../services/vault-popup-loading.service"; import { VaultPopupScrollPositionService } from "../../services/vault-popup-scroll-position.service"; import { AtRiskPasswordCalloutComponent } from "../at-risk-callout/at-risk-password-callout.component"; @@ -174,15 +175,21 @@ describe("VaultV2Component", () => { showDeactivatedOrg$: new BehaviorSubject(false), favoriteCiphers$: new BehaviorSubject([]), remainingCiphers$: new BehaviorSubject([]), + filteredCiphers$: new BehaviorSubject([]), cipherCount$: new BehaviorSubject(0), - loading$: new BehaviorSubject(true), + hasSearchText$: new BehaviorSubject(false), } as Partial; - const filtersSvc = { + const filtersSvc: any = { allFilters$: new Subject(), filters$: new BehaviorSubject({}), filterVisibilityState$: new BehaviorSubject({}), - } as Partial; + numberOfAppliedFilters$: new BehaviorSubject(0), + }; + + const loadingSvc: any = { + loading$: new BehaviorSubject(false), + }; const activeAccount$ = new BehaviorSubject({ id: "user-1" }); @@ -240,6 +247,7 @@ describe("VaultV2Component", () => { provideNoopAnimations(), { provide: VaultPopupItemsService, useValue: itemsSvc }, { provide: VaultPopupListFiltersService, useValue: filtersSvc }, + { provide: VaultPopupLoadingService, useValue: loadingSvc }, { provide: VaultPopupScrollPositionService, useValue: scrollSvc }, { provide: AccountService, @@ -366,18 +374,18 @@ describe("VaultV2Component", () => { }); it("loading$ is true when items loading or filters missing; false when both ready", () => { - const itemsLoading$ = itemsSvc.loading$ as unknown as BehaviorSubject; + const vaultLoading$ = loadingSvc.loading$ as unknown as BehaviorSubject; const allFilters$ = filtersSvc.allFilters$ as unknown as Subject; const readySubject$ = component["readySubject"] as unknown as BehaviorSubject; const values: boolean[] = []; getObs(component, "loading$").subscribe((v) => values.push(!!v)); - itemsLoading$.next(true); + vaultLoading$.next(true); allFilters$.next({}); - itemsLoading$.next(false); + vaultLoading$.next(false); readySubject$.next(true); @@ -389,7 +397,7 @@ describe("VaultV2Component", () => { const component = fixture.componentInstance; const readySubject$ = component["readySubject"] as unknown as BehaviorSubject; - const itemsLoading$ = itemsSvc.loading$ as unknown as BehaviorSubject; + const vaultLoading$ = loadingSvc.loading$ as unknown as BehaviorSubject; const allFilters$ = filtersSvc.allFilters$ as unknown as Subject; fixture.detectChanges(); @@ -400,7 +408,7 @@ describe("VaultV2Component", () => { ) as HTMLElement; // Unblock loading - itemsLoading$.next(false); + vaultLoading$.next(false); readySubject$.next(true); allFilters$.next({}); tick(); @@ -607,6 +615,127 @@ describe("VaultV2Component", () => { expect(spotlights.length).toBe(0); })); + it("does not render app-autofill-vault-list-items or favorites item container when hasSearchText$ is true", () => { + itemsSvc.hasSearchText$.next(true); + + const fixture = TestBed.createComponent(VaultV2Component); + component = fixture.componentInstance; + + const readySubject$ = component["readySubject"]; + const allFilters$ = filtersSvc.allFilters$ as unknown as Subject; + + // Unblock loading + readySubject$.next(true); + allFilters$.next({}); + fixture.detectChanges(); + + const autofillElement = fixture.debugElement.query(By.css("app-autofill-vault-list-items")); + expect(autofillElement).toBeFalsy(); + + const favoritesElement = fixture.debugElement.query(By.css("#favorites")); + expect(favoritesElement).toBeFalsy(); + }); + + it("does render app-autofill-vault-list-items and favorites item container when hasSearchText$ is false", () => { + // Ensure vaultState is null (not Empty, NoResults, or DeactivatedOrg) + itemsSvc.emptyVault$.next(false); + itemsSvc.noFilteredResults$.next(false); + itemsSvc.showDeactivatedOrg$.next(false); + itemsSvc.hasSearchText$.next(false); + loadingSvc.loading$.next(false); + + const fixture = TestBed.createComponent(VaultV2Component); + component = fixture.componentInstance; + + const readySubject$ = component["readySubject"]; + const allFilters$ = filtersSvc.allFilters$ as unknown as Subject; + + // Unblock loading + readySubject$.next(true); + allFilters$.next({}); + fixture.detectChanges(); + + const autofillElement = fixture.debugElement.query(By.css("app-autofill-vault-list-items")); + expect(autofillElement).toBeTruthy(); + + const favoritesElement = fixture.debugElement.query(By.css("#favorites")); + expect(favoritesElement).toBeTruthy(); + }); + + it("does set the title for allItems container to allItems when hasSearchText$ and numberOfAppliedFilters$ are false and 0 respectively", () => { + // Ensure vaultState is null (not Empty, NoResults, or DeactivatedOrg) + itemsSvc.emptyVault$.next(false); + itemsSvc.noFilteredResults$.next(false); + itemsSvc.showDeactivatedOrg$.next(false); + itemsSvc.hasSearchText$.next(false); + filtersSvc.numberOfAppliedFilters$.next(0); + loadingSvc.loading$.next(false); + + const fixture = TestBed.createComponent(VaultV2Component); + component = fixture.componentInstance; + + const readySubject$ = component["readySubject"]; + const allFilters$ = filtersSvc.allFilters$ as unknown as Subject; + + // Unblock loading + readySubject$.next(true); + allFilters$.next({}); + fixture.detectChanges(); + + const allItemsElement = fixture.debugElement.query(By.css("#allItems")); + const allItemsTitle = allItemsElement.componentInstance.title(); + expect(allItemsTitle).toBe("allItems"); + }); + + it("does set the title for allItems container to searchResults when hasSearchText$ is true", () => { + // Ensure vaultState is null (not Empty, NoResults, or DeactivatedOrg) + itemsSvc.emptyVault$.next(false); + itemsSvc.noFilteredResults$.next(false); + itemsSvc.showDeactivatedOrg$.next(false); + itemsSvc.hasSearchText$.next(true); + loadingSvc.loading$.next(false); + + const fixture = TestBed.createComponent(VaultV2Component); + component = fixture.componentInstance; + + const readySubject$ = component["readySubject"]; + const allFilters$ = filtersSvc.allFilters$ as unknown as Subject; + + // Unblock loading + readySubject$.next(true); + allFilters$.next({}); + fixture.detectChanges(); + + const allItemsElement = fixture.debugElement.query(By.css("#allItems")); + const allItemsTitle = allItemsElement.componentInstance.title(); + expect(allItemsTitle).toBe("searchResults"); + }); + + it("does set the title for allItems container to items when numberOfAppliedFilters$ is > 0", fakeAsync(() => { + // Ensure vaultState is null (not Empty, NoResults, or DeactivatedOrg) + itemsSvc.emptyVault$.next(false); + itemsSvc.noFilteredResults$.next(false); + itemsSvc.showDeactivatedOrg$.next(false); + itemsSvc.hasSearchText$.next(false); + filtersSvc.numberOfAppliedFilters$.next(1); + loadingSvc.loading$.next(false); + + const fixture = TestBed.createComponent(VaultV2Component); + component = fixture.componentInstance; + + const readySubject$ = component["readySubject"]; + const allFilters$ = filtersSvc.allFilters$ as unknown as Subject; + + // Unblock loading + readySubject$.next(true); + allFilters$.next({}); + fixture.detectChanges(); + + const allItemsElement = fixture.debugElement.query(By.css("#allItems")); + const allItemsTitle = allItemsElement.componentInstance.title(); + expect(allItemsTitle).toBe("items"); + })); + describe("AutoConfirmExtensionSetupDialog", () => { beforeEach(() => { autoConfirmDialogSpy.mockClear(); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts index 51e735fb1ef..fce084542a9 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts @@ -160,6 +160,11 @@ export class VaultV2Component implements OnInit, OnDestroy { FeatureFlag.BrowserPremiumSpotlight, ); + protected readonly hasSearchText$ = this.vaultPopupItemsService.hasSearchText$; + protected readonly numberOfAppliedFilters$ = + this.vaultPopupListFiltersService.numberOfAppliedFilters$; + + protected filteredCiphers$ = this.vaultPopupItemsService.filteredCiphers$; protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$; protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$; protected allFilters$ = this.vaultPopupListFiltersService.allFilters$; diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts index 7cd73279c3d..093fdbfb66d 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts @@ -323,6 +323,25 @@ describe("VaultPopupItemsService", () => { }); }); + describe("filteredCiphers$", () => { + it("should filter filteredCipher$ down to search term", (done) => { + const cipherList = Object.values(allCiphers); + const searchText = "Login"; + + searchService.searchCiphers.mockImplementation(async () => { + return cipherList.filter((cipher) => { + return cipher.name.includes(searchText); + }); + }); + + service.filteredCiphers$.subscribe((ciphers) => { + // There are 10 ciphers but only 3 with "Login" in the name + expect(ciphers.length).toBe(3); + done(); + }); + }); + }); + describe("favoriteCiphers$", () => { it("should exclude autofill ciphers", (done) => { service.favoriteCiphers$.subscribe((ciphers) => { diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index 321d7936806..7ccfc834c87 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -201,6 +201,15 @@ export class VaultPopupItemsService { shareReplay({ refCount: true, bufferSize: 1 }), ); + /** + * List of ciphers that are filtered using filters and search. + * Includes favorite ciphers and ciphers currently suggested for autofill. + * Ciphers are sorted by name. + */ + filteredCiphers$: Observable = this._filteredCipherList$.pipe( + shareReplay({ refCount: false, bufferSize: 1 }), + ); + /** * List of ciphers that can be used for autofill on the current tab. Includes cards and/or identities * if enabled in the vault settings. Ciphers are sorted by type, then by last used date, then by name.