mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 06:13:38 +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:
@@ -10,6 +10,7 @@ import { CollectionService } from "@bitwarden/admin-console/common";
|
|||||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
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 { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
@@ -76,6 +77,10 @@ describe("VaultHeaderV2Component", () => {
|
|||||||
{ provide: MessageSender, useValue: mock<MessageSender>() },
|
{ provide: MessageSender, useValue: mock<MessageSender>() },
|
||||||
{ provide: AccountService, useValue: mock<AccountService>() },
|
{ provide: AccountService, useValue: mock<AccountService>() },
|
||||||
{ provide: LogService, useValue: mock<LogService>() },
|
{ provide: LogService, useValue: mock<LogService>() },
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: { getFeatureFlag$: jest.fn(() => new BehaviorSubject(true)) },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: VaultPopupItemsService,
|
provide: VaultPopupItemsService,
|
||||||
useValue: mock<VaultPopupItemsService>({ searchText$: new BehaviorSubject("") }),
|
useValue: mock<VaultPopupItemsService>({ searchText$: new BehaviorSubject("") }),
|
||||||
|
|||||||
@@ -4,6 +4,5 @@
|
|||||||
[(ngModel)]="searchText"
|
[(ngModel)]="searchText"
|
||||||
(ngModelChange)="onSearchTextChanged()"
|
(ngModelChange)="onSearchTextChanged()"
|
||||||
appAutofocus
|
appAutofocus
|
||||||
[disabled]="loading$ | async"
|
|
||||||
>
|
>
|
||||||
</bit-search>
|
</bit-search>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,9 +2,22 @@ import { CommonModule } from "@angular/common";
|
|||||||
import { Component, NgZone } from "@angular/core";
|
import { Component, NgZone } from "@angular/core";
|
||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
import { FormsModule } from "@angular/forms";
|
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 { 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 { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service";
|
||||||
import { SearchModule } from "@bitwarden/components";
|
import { SearchModule } from "@bitwarden/components";
|
||||||
|
|
||||||
@@ -27,6 +40,7 @@ export class VaultV2SearchComponent {
|
|||||||
constructor(
|
constructor(
|
||||||
private vaultPopupItemsService: VaultPopupItemsService,
|
private vaultPopupItemsService: VaultPopupItemsService,
|
||||||
private vaultPopupLoadingService: VaultPopupLoadingService,
|
private vaultPopupLoadingService: VaultPopupLoadingService,
|
||||||
|
private configService: ConfigService,
|
||||||
private ngZone: NgZone,
|
private ngZone: NgZone,
|
||||||
) {
|
) {
|
||||||
this.subscribeToLatestSearchText();
|
this.subscribeToLatestSearchText();
|
||||||
@@ -48,13 +62,38 @@ export class VaultV2SearchComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribeToApplyFilter(): Subscription {
|
subscribeToApplyFilter(): void {
|
||||||
return this.searchText$
|
this.configService
|
||||||
.pipe(debounceTime(SearchTextDebounceInterval), distinctUntilChanged(), takeUntilDestroyed())
|
.getFeatureFlag$(FeatureFlag.VaultLoadingSkeletons)
|
||||||
.subscribe((data) => {
|
.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.runOutsideAngular(() => {
|
||||||
this.ngZone.run(() => {
|
this.ngZone.run(() => {
|
||||||
this.vaultPopupItemsService.applyFilter(data);
|
this.vaultPopupItemsService.applyFilter(text);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user