1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 14:23:32 +00:00

[PM-8379] Update vault popup items service to track loading state (#9528)

This commit is contained in:
Shane Melton
2024-06-10 09:55:12 -07:00
committed by GitHub
parent 7fb9408202
commit 19f2d2aefc
3 changed files with 78 additions and 14 deletions

View File

@@ -379,6 +379,54 @@ describe("VaultPopupItemsService", () => {
}); });
}); });
describe("loading$", () => {
let tracked: ObservableTracker<boolean>;
let trackedCiphers: ObservableTracker<any>;
beforeEach(() => {
// Start tracking loading$ emissions
tracked = new ObservableTracker(service.loading$);
// Track remainingCiphers$ to make cipher observables active
trackedCiphers = new ObservableTracker(service.remainingCiphers$);
});
it("should initialize with true first", async () => {
expect(tracked.emissions[0]).toBe(true);
});
it("should emit false once ciphers are available", async () => {
expect(tracked.emissions.length).toBe(2);
expect(tracked.emissions[0]).toBe(true);
expect(tracked.emissions[1]).toBe(false);
});
it("should cycle when cipherService.ciphers$ emits", async () => {
// Restart tracking
tracked = new ObservableTracker(service.loading$);
(cipherServiceMock.ciphers$ as BehaviorSubject<any>).next(null);
await trackedCiphers.pauseUntilReceived(2);
expect(tracked.emissions.length).toBe(3);
expect(tracked.emissions[0]).toBe(false);
expect(tracked.emissions[1]).toBe(true);
expect(tracked.emissions[2]).toBe(false);
});
it("should cycle when filters are applied", async () => {
// Restart tracking
tracked = new ObservableTracker(service.loading$);
service.applyFilter("test");
await trackedCiphers.pauseUntilReceived(2);
expect(tracked.emissions.length).toBe(3);
expect(tracked.emissions[0]).toBe(false);
expect(tracked.emissions[1]).toBe(true);
expect(tracked.emissions[2]).toBe(false);
});
});
describe("applyFilter", () => { describe("applyFilter", () => {
it("should call search Service with the new search term", (done) => { it("should call search Service with the new search term", (done) => {
const searchText = "Hello"; const searchText = "Hello";

View File

@@ -2,6 +2,7 @@ import { inject, Injectable, NgZone } from "@angular/core";
import { import {
BehaviorSubject, BehaviorSubject,
combineLatest, combineLatest,
distinctUntilChanged,
distinctUntilKeyChanged, distinctUntilKeyChanged,
from, from,
map, map,
@@ -12,6 +13,8 @@ import {
startWith, startWith,
Subject, Subject,
switchMap, switchMap,
tap,
withLatestFrom,
} from "rxjs"; } from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service";
@@ -40,6 +43,13 @@ import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-fi
export class VaultPopupItemsService { export class VaultPopupItemsService {
private _refreshCurrentTab$ = new Subject<void>(); private _refreshCurrentTab$ = new Subject<void>();
private _searchText$ = new BehaviorSubject<string>(""); private _searchText$ = new BehaviorSubject<string>("");
/**
* Subject that emits whenever new ciphers are being processed/filtered.
* @private
*/
private _ciphersLoading$ = new Subject<void>();
latestSearchText$: Observable<string> = this._searchText$.asObservable(); latestSearchText$: Observable<string> = this._searchText$.asObservable();
/** /**
@@ -84,6 +94,7 @@ export class VaultPopupItemsService {
this.cipherService.localData$, this.cipherService.localData$,
).pipe( ).pipe(
runInsideAngular(inject(NgZone)), // Workaround to ensure cipher$ state provider emissions are run inside Angular runInsideAngular(inject(NgZone)), // Workaround to ensure cipher$ state provider emissions are run inside Angular
tap(() => this._ciphersLoading$.next()),
switchMap(() => Utils.asyncToObservable(() => this.cipherService.getAllDecrypted())), switchMap(() => Utils.asyncToObservable(() => this.cipherService.getAllDecrypted())),
switchMap((ciphers) => switchMap((ciphers) =>
combineLatest([ combineLatest([
@@ -112,6 +123,7 @@ export class VaultPopupItemsService {
this._searchText$, this._searchText$,
this.vaultPopupListFiltersService.filterFunction$, this.vaultPopupListFiltersService.filterFunction$,
]).pipe( ]).pipe(
tap(() => this._ciphersLoading$.next()),
map(([ciphers, searchText, filterFunction]): [CipherView[], string] => [ map(([ciphers, searchText, filterFunction]): [CipherView[], string] => [
filterFunction(ciphers), filterFunction(ciphers),
searchText, searchText,
@@ -148,10 +160,8 @@ export class VaultPopupItemsService {
* List of favorite ciphers that are not currently suggested for autofill. * List of favorite ciphers that are not currently suggested for autofill.
* Ciphers are sorted by last used date, then by name. * Ciphers are sorted by last used date, then by name.
*/ */
favoriteCiphers$: Observable<PopupCipherView[]> = combineLatest([ favoriteCiphers$: Observable<PopupCipherView[]> = this.autoFillCiphers$.pipe(
this.autoFillCiphers$, withLatestFrom(this._filteredCipherList$),
this._filteredCipherList$,
]).pipe(
map(([autoFillCiphers, ciphers]) => map(([autoFillCiphers, ciphers]) =>
ciphers.filter((cipher) => cipher.favorite && !autoFillCiphers.includes(cipher)), ciphers.filter((cipher) => cipher.favorite && !autoFillCiphers.includes(cipher)),
), ),
@@ -165,12 +175,9 @@ export class VaultPopupItemsService {
* List of all remaining ciphers that are not currently suggested for autofill or marked as favorite. * List of all remaining ciphers that are not currently suggested for autofill or marked as favorite.
* Ciphers are sorted by name. * Ciphers are sorted by name.
*/ */
remainingCiphers$: Observable<PopupCipherView[]> = combineLatest([ remainingCiphers$: Observable<PopupCipherView[]> = this.favoriteCiphers$.pipe(
this.autoFillCiphers$, withLatestFrom(this._filteredCipherList$, this.autoFillCiphers$),
this.favoriteCiphers$, map(([favoriteCiphers, ciphers, autoFillCiphers]) =>
this._filteredCipherList$,
]).pipe(
map(([autoFillCiphers, favoriteCiphers, ciphers]) =>
ciphers.filter( ciphers.filter(
(cipher) => !autoFillCiphers.includes(cipher) && !favoriteCiphers.includes(cipher), (cipher) => !autoFillCiphers.includes(cipher) && !favoriteCiphers.includes(cipher),
), ),
@@ -179,6 +186,14 @@ export class VaultPopupItemsService {
shareReplay({ refCount: false, bufferSize: 1 }), shareReplay({ refCount: false, bufferSize: 1 }),
); );
/**
* Observable that indicates whether the service is currently loading ciphers.
*/
loading$: Observable<boolean> = merge(
this._ciphersLoading$.pipe(map(() => true)),
this.remainingCiphers$.pipe(map(() => false)),
).pipe(startWith(true), distinctUntilChanged(), shareReplay({ refCount: false, bufferSize: 1 }));
/** /**
* Observable that indicates whether a filter is currently applied to the ciphers. * Observable that indicates whether a filter is currently applied to the ciphers.
*/ */

View File

@@ -1,4 +1,4 @@
import { Observable, Subject, Subscription, firstValueFrom, throwError, timeout } from "rxjs"; import { firstValueFrom, Observable, Subject, Subscription, throwError, timeout } from "rxjs";
/** Test class to enable async awaiting of observable emissions */ /** Test class to enable async awaiting of observable emissions */
export class ObservableTracker<T> { export class ObservableTracker<T> {
@@ -43,6 +43,9 @@ export class ObservableTracker<T> {
private trackEmissions(observable: Observable<T>): T[] { private trackEmissions(observable: Observable<T>): T[] {
const emissions: T[] = []; const emissions: T[] = [];
this.emissionReceived.subscribe((value) => {
emissions.push(value);
});
this.subscription = observable.subscribe((value) => { this.subscription = observable.subscribe((value) => {
if (value == null) { if (value == null) {
this.emissionReceived.next(null); this.emissionReceived.next(null);
@@ -64,9 +67,7 @@ export class ObservableTracker<T> {
} }
} }
}); });
this.emissionReceived.subscribe((value) => {
emissions.push(value);
});
return emissions; return emissions;
} }
} }