1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-06 03:33:30 +00:00

[PM-26706] Update search results header for extension (#18676)

* dynamically changes the allItems title from 'All items' to 'Search results' based on search text length

* updates logic and copy for changing the allItems header text

* changes how ciphers are displayed when a user has a search term and/or filters applied

* Update apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html

Co-authored-by: Nick Krantz <125900171+nick-livefront@users.noreply.github.com>

* refactors tests

---------

Co-authored-by: Nick Krantz <125900171+nick-livefront@users.noreply.github.com>
This commit is contained in:
Jackson Engstrom
2026-02-04 11:21:20 -08:00
committed by GitHub
parent a07c9ebf6b
commit a686ea1640
6 changed files with 202 additions and 22 deletions

View File

@@ -6123,6 +6123,12 @@
"whyAmISeeingThis": {
"message": "Why am I seeing this?"
},
"items": {
"message": "Items"
},
"searchResults": {
"message": "Search results"
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},

View File

@@ -107,20 +107,32 @@
@if (vaultState === null) {
<vault-fade-in-out>
@if (!(loading$ | async)) {
<app-autofill-vault-list-items></app-autofill-vault-list-items>
<app-vault-list-items-container
[title]="'favorites' | i18n"
[ciphers]="(favoriteCiphers$ | async) || []"
id="favorites"
collapsibleKey="favorites"
></app-vault-list-items-container>
<app-vault-list-items-container
[title]="'allItems' | i18n"
[ciphers]="(remainingCiphers$ | async) || []"
id="allItems"
disableSectionMargin
collapsibleKey="allItems"
></app-vault-list-items-container>
<!--If there is search text fold all the filtered ciphers into one container-->
@if (hasSearchText$ | async) {
<app-vault-list-items-container
[title]="'searchResults' | i18n"
[ciphers]="(filteredCiphers$ | async) || []"
id="allItems"
disableSectionMargin
collapsibleKey="allItems"
></app-vault-list-items-container>
} @else {
<app-autofill-vault-list-items></app-autofill-vault-list-items>
<app-vault-list-items-container
[title]="'favorites' | i18n"
[ciphers]="(favoriteCiphers$ | async) || []"
id="favorites"
collapsibleKey="favorites"
></app-vault-list-items-container>
<!--Change the title header when a filter is applied-->
<app-vault-list-items-container
[title]="((numberOfAppliedFilters$ | async) === 0 ? 'allItems' : 'items') | i18n"
[ciphers]="(remainingCiphers$ | async) || []"
id="allItems"
disableSectionMargin
collapsibleKey="allItems"
></app-vault-list-items-container>
}
}
</vault-fade-in-out>
}

View File

@@ -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<boolean>(false),
favoriteCiphers$: new BehaviorSubject<any[]>([]),
remainingCiphers$: new BehaviorSubject<any[]>([]),
filteredCiphers$: new BehaviorSubject<any[]>([]),
cipherCount$: new BehaviorSubject<number>(0),
loading$: new BehaviorSubject<boolean>(true),
hasSearchText$: new BehaviorSubject<boolean>(false),
} as Partial<VaultPopupItemsService>;
const filtersSvc = {
const filtersSvc: any = {
allFilters$: new Subject<any>(),
filters$: new BehaviorSubject<any>({}),
filterVisibilityState$: new BehaviorSubject<any>({}),
} as Partial<VaultPopupListFiltersService>;
numberOfAppliedFilters$: new BehaviorSubject<number>(0),
};
const loadingSvc: any = {
loading$: new BehaviorSubject<boolean>(false),
};
const activeAccount$ = new BehaviorSubject<FakeAccount | null>({ 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<boolean>;
const vaultLoading$ = loadingSvc.loading$ as unknown as BehaviorSubject<boolean>;
const allFilters$ = filtersSvc.allFilters$ as unknown as Subject<any>;
const readySubject$ = component["readySubject"] as unknown as BehaviorSubject<boolean>;
const values: boolean[] = [];
getObs<boolean>(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<boolean>;
const itemsLoading$ = itemsSvc.loading$ as unknown as BehaviorSubject<boolean>;
const vaultLoading$ = loadingSvc.loading$ as unknown as BehaviorSubject<boolean>;
const allFilters$ = filtersSvc.allFilters$ as unknown as Subject<any>;
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<any>;
// 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<any>;
// 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<any>;
// 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<any>;
// 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<any>;
// 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();

View File

@@ -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$;

View File

@@ -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) => {

View File

@@ -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<PopupCipherViewLike[]> = 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.