1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-27520] Allow for search while vault is loading (#17274)

* allow for search while vault is loading

* fix comment wording

* remove subscription return value - it is not used

* update `distinctUntilChanged` to account for tuple

* use feature flag to determine search pattern

* fix tests & lint issues

* fix lint errors part 2
This commit is contained in:
Nick Krantz
2025-11-12 15:34:54 -06:00
committed by GitHub
parent 3da3aa5e8c
commit b2682a4139
4 changed files with 210 additions and 7 deletions

View File

@@ -10,6 +10,7 @@ import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -76,6 +77,10 @@ describe("VaultHeaderV2Component", () => {
{ provide: MessageSender, useValue: mock<MessageSender>() },
{ provide: AccountService, useValue: mock<AccountService>() },
{ provide: LogService, useValue: mock<LogService>() },
{
provide: ConfigService,
useValue: { getFeatureFlag$: jest.fn(() => new BehaviorSubject(true)) },
},
{
provide: VaultPopupItemsService,
useValue: mock<VaultPopupItemsService>({ searchText$: new BehaviorSubject("") }),

View File

@@ -4,6 +4,5 @@
[(ngModel)]="searchText"
(ngModelChange)="onSearchTextChanged()"
appAutofocus
[disabled]="loading$ | async"
>
</bit-search>

View File

@@ -0,0 +1,160 @@
import { CommonModule } from "@angular/common";
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { FormsModule } from "@angular/forms";
import { BehaviorSubject } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service";
import { SearchModule } from "@bitwarden/components";
import { VaultPopupItemsService } from "../../../services/vault-popup-items.service";
import { VaultPopupLoadingService } from "../../../services/vault-popup-loading.service";
import { VaultV2SearchComponent } from "./vault-v2-search.component";
describe("VaultV2SearchComponent", () => {
let component: VaultV2SearchComponent;
let fixture: ComponentFixture<VaultV2SearchComponent>;
const searchText$ = new BehaviorSubject("");
const loading$ = new BehaviorSubject(false);
const featureFlag$ = new BehaviorSubject(true);
const applyFilter = jest.fn();
const createComponent = () => {
fixture = TestBed.createComponent(VaultV2SearchComponent);
component = fixture.componentInstance;
fixture.detectChanges();
};
beforeEach(async () => {
applyFilter.mockClear();
featureFlag$.next(true);
await TestBed.configureTestingModule({
imports: [VaultV2SearchComponent, CommonModule, SearchModule, JslibModule, FormsModule],
providers: [
{
provide: VaultPopupItemsService,
useValue: {
searchText$,
applyFilter,
},
},
{
provide: VaultPopupLoadingService,
useValue: {
loading$,
},
},
{
provide: ConfigService,
useValue: {
getFeatureFlag$: jest.fn(() => featureFlag$),
},
},
{ provide: I18nService, useValue: { t: (key: string) => key } },
],
}).compileComponents();
});
it("subscribes to search text from service", () => {
createComponent();
searchText$.next("test search");
fixture.detectChanges();
expect(component.searchText).toBe("test search");
});
describe("debouncing behavior", () => {
describe("when feature flag is enabled", () => {
beforeEach(() => {
featureFlag$.next(true);
createComponent();
});
it("debounces search text changes when not loading", fakeAsync(() => {
loading$.next(false);
component.searchText = "test";
component.onSearchTextChanged();
expect(applyFilter).not.toHaveBeenCalled();
tick(SearchTextDebounceInterval);
expect(applyFilter).toHaveBeenCalledWith("test");
expect(applyFilter).toHaveBeenCalledTimes(1);
}));
it("should not debounce search text changes when loading", fakeAsync(() => {
loading$.next(true);
component.searchText = "test";
component.onSearchTextChanged();
tick(0);
expect(applyFilter).toHaveBeenCalledWith("test");
expect(applyFilter).toHaveBeenCalledTimes(1);
}));
it("cancels previous debounce when new text is entered", fakeAsync(() => {
loading$.next(false);
component.searchText = "test";
component.onSearchTextChanged();
tick(SearchTextDebounceInterval / 2);
component.searchText = "test2";
component.onSearchTextChanged();
tick(SearchTextDebounceInterval / 2);
expect(applyFilter).not.toHaveBeenCalled();
tick(SearchTextDebounceInterval / 2);
expect(applyFilter).toHaveBeenCalledWith("test2");
expect(applyFilter).toHaveBeenCalledTimes(1);
}));
});
describe("when feature flag is disabled", () => {
beforeEach(() => {
featureFlag$.next(false);
createComponent();
});
it("debounces search text changes", fakeAsync(() => {
component.searchText = "test";
component.onSearchTextChanged();
expect(applyFilter).not.toHaveBeenCalled();
tick(SearchTextDebounceInterval);
expect(applyFilter).toHaveBeenCalledWith("test");
expect(applyFilter).toHaveBeenCalledTimes(1);
}));
it("ignores loading state and always debounces", fakeAsync(() => {
loading$.next(true);
component.searchText = "test";
component.onSearchTextChanged();
expect(applyFilter).not.toHaveBeenCalled();
tick(SearchTextDebounceInterval);
expect(applyFilter).toHaveBeenCalledWith("test");
expect(applyFilter).toHaveBeenCalledTimes(1);
}));
});
});
});

View File

@@ -2,9 +2,22 @@ import { CommonModule } from "@angular/common";
import { Component, NgZone } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormsModule } from "@angular/forms";
import { Subject, Subscription, debounceTime, distinctUntilChanged, filter } from "rxjs";
import {
Subject,
Subscription,
combineLatest,
debounce,
debounceTime,
distinctUntilChanged,
filter,
map,
switchMap,
timer,
} from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service";
import { SearchModule } from "@bitwarden/components";
@@ -27,6 +40,7 @@ export class VaultV2SearchComponent {
constructor(
private vaultPopupItemsService: VaultPopupItemsService,
private vaultPopupLoadingService: VaultPopupLoadingService,
private configService: ConfigService,
private ngZone: NgZone,
) {
this.subscribeToLatestSearchText();
@@ -48,13 +62,38 @@ export class VaultV2SearchComponent {
});
}
subscribeToApplyFilter(): Subscription {
return this.searchText$
.pipe(debounceTime(SearchTextDebounceInterval), distinctUntilChanged(), takeUntilDestroyed())
.subscribe((data) => {
subscribeToApplyFilter(): void {
this.configService
.getFeatureFlag$(FeatureFlag.VaultLoadingSkeletons)
.pipe(
switchMap((enabled) => {
if (!enabled) {
return this.searchText$.pipe(
debounceTime(SearchTextDebounceInterval),
distinctUntilChanged(),
);
}
return combineLatest([this.searchText$, this.loading$]).pipe(
debounce(([_, isLoading]) => {
// If loading apply immediately to avoid stale searches.
// After loading completes, debounce to avoid excessive searches.
const delayTime = isLoading ? 0 : SearchTextDebounceInterval;
return timer(delayTime);
}),
distinctUntilChanged(
([prevText, prevLoading], [newText, newLoading]) =>
prevText === newText && prevLoading === newLoading,
),
map(([text, _]) => text),
);
}),
takeUntilDestroyed(),
)
.subscribe((text) => {
this.ngZone.runOutsideAngular(() => {
this.ngZone.run(() => {
this.vaultPopupItemsService.applyFilter(data);
this.vaultPopupItemsService.applyFilter(text);
});
});
});