[PM-8208] Fix: Product Navigation flash (#9587)

* wait until a sync is complete to render the product switcher content

* refactor unneeded observables into their own variable

* do not show product switcher button until content is loaded

* use `ReplaySubject` to ensure that `syncCompleted$` last value is always used
This commit is contained in:
Nick Krantz 2024-06-14 08:24:50 -05:00 committed by GitHub
parent e3b425069c
commit 94438d4138
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 102 additions and 31 deletions

View File

@ -4,5 +4,6 @@
[bitMenuTriggerFor]="content?.menu" [bitMenuTriggerFor]="content?.menu"
[buttonType]="buttonType" [buttonType]="buttonType"
[attr.aria-label]="'switchProducts' | i18n" [attr.aria-label]="'switchProducts' | i18n"
*ngIf="products$ | async"
></button> ></button>
<product-switcher-content #content></product-switcher-content> <product-switcher-content #content></product-switcher-content>

View File

@ -1,6 +1,8 @@
import { AfterViewInit, ChangeDetectorRef, Component, Input } from "@angular/core"; import { AfterViewInit, ChangeDetectorRef, Component, Input } from "@angular/core";
import { IconButtonType } from "@bitwarden/components/src/icon-button/icon-button.component"; import { IconButtonType } from "@bitwarden/components/src/icon-button/icon-button.component";
import { ProductSwitcherService } from "./shared/product-switcher.service";
@Component({ @Component({
selector: "product-switcher", selector: "product-switcher",
templateUrl: "./product-switcher.component.html", templateUrl: "./product-switcher.component.html",
@ -21,5 +23,10 @@ export class ProductSwitcherComponent implements AfterViewInit {
this.changeDetector.detectChanges(); this.changeDetector.detectChanges();
} }
constructor(private changeDetector: ChangeDetectorRef) {} constructor(
private changeDetector: ChangeDetectorRef,
private productSwitcherService: ProductSwitcherService,
) {}
protected readonly products$ = this.productSwitcherService.products$;
} }

View File

@ -8,6 +8,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { SyncService } from "@bitwarden/common/platform/sync";
import { ProductSwitcherService } from "./product-switcher.service"; import { ProductSwitcherService } from "./product-switcher.service";
@ -17,8 +18,19 @@ describe("ProductSwitcherService", () => {
let organizationService: MockProxy<OrganizationService>; let organizationService: MockProxy<OrganizationService>;
let providerService: MockProxy<ProviderService>; let providerService: MockProxy<ProviderService>;
let activeRouteParams = convertToParamMap({ organizationId: "1234" }); let activeRouteParams = convertToParamMap({ organizationId: "1234" });
const getLastSync = jest.fn().mockResolvedValue(new Date("2024-05-14"));
// The service is dependent on the SyncService, which is behind a `setTimeout`
// Most of the tests don't need to test this aspect so `advanceTimersByTime`
// is used to simulate the completion of the sync
function initiateService() {
service = TestBed.inject(ProductSwitcherService);
jest.advanceTimersByTime(201);
}
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers();
getLastSync.mockResolvedValue(new Date("2024-05-14"));
router = mock<Router>(); router = mock<Router>();
organizationService = mock<OrganizationService>(); organizationService = mock<OrganizationService>();
providerService = mock<ProviderService>(); providerService = mock<ProviderService>();
@ -46,14 +58,40 @@ describe("ProductSwitcherService", () => {
transform: (key: string) => key, transform: (key: string) => key,
}, },
}, },
{
provide: SyncService,
useValue: { getLastSync },
},
], ],
}); });
}); });
afterEach(() => {
jest.useRealTimers();
});
describe("SyncService", () => {
it("waits until sync is complete before emitting products", (done) => {
getLastSync.mockResolvedValue(null);
initiateService();
// The subscription will only emit once the sync returns a value
service.products$.subscribe((products) => {
expect(products).toBeDefined();
done();
});
// Simulate sync completion & advance timers
getLastSync.mockResolvedValue(new Date("2024-05-15"));
jest.advanceTimersByTime(201);
});
});
describe("product separation", () => { describe("product separation", () => {
describe("Password Manager", () => { describe("Password Manager", () => {
it("is always included", async () => { it("is always included", async () => {
service = TestBed.inject(ProductSwitcherService); initiateService();
const products = await firstValueFrom(service.products$); const products = await firstValueFrom(service.products$);
@ -63,7 +101,7 @@ describe("ProductSwitcherService", () => {
describe("Secret Manager", () => { describe("Secret Manager", () => {
it("is included in other when there are no organizations with SM", async () => { it("is included in other when there are no organizations with SM", async () => {
service = TestBed.inject(ProductSwitcherService); initiateService();
const products = await firstValueFrom(service.products$); const products = await firstValueFrom(service.products$);
@ -75,7 +113,7 @@ describe("ProductSwitcherService", () => {
{ id: "1234", canAccessSecretsManager: true, enabled: true }, { id: "1234", canAccessSecretsManager: true, enabled: true },
] as Organization[]); ] as Organization[]);
service = TestBed.inject(ProductSwitcherService); initiateService();
const products = await firstValueFrom(service.products$); const products = await firstValueFrom(service.products$);
@ -85,7 +123,7 @@ describe("ProductSwitcherService", () => {
describe("Admin/Organizations", () => { describe("Admin/Organizations", () => {
it("includes Organizations in other when there are organizations", async () => { it("includes Organizations in other when there are organizations", async () => {
service = TestBed.inject(ProductSwitcherService); initiateService();
const products = await firstValueFrom(service.products$); const products = await firstValueFrom(service.products$);
@ -96,7 +134,7 @@ describe("ProductSwitcherService", () => {
it("includes Admin Console in bento when a user has access to it", async () => { it("includes Admin Console in bento when a user has access to it", async () => {
organizationService.organizations$ = of([{ id: "1234", isOwner: true }] as Organization[]); organizationService.organizations$ = of([{ id: "1234", isOwner: true }] as Organization[]);
service = TestBed.inject(ProductSwitcherService); initiateService();
const products = await firstValueFrom(service.products$); const products = await firstValueFrom(service.products$);
@ -107,8 +145,7 @@ describe("ProductSwitcherService", () => {
describe("Provider Portal", () => { describe("Provider Portal", () => {
it("is not included when there are no providers", async () => { it("is not included when there are no providers", async () => {
service = TestBed.inject(ProductSwitcherService); initiateService();
const products = await firstValueFrom(service.products$); const products = await firstValueFrom(service.products$);
expect(products.bento.find((p) => p.name === "Provider Portal")).toBeUndefined(); expect(products.bento.find((p) => p.name === "Provider Portal")).toBeUndefined();
@ -118,7 +155,7 @@ describe("ProductSwitcherService", () => {
it("is included when there are providers", async () => { it("is included when there are providers", async () => {
providerService.getAll.mockResolvedValue([{ id: "67899" }] as Provider[]); providerService.getAll.mockResolvedValue([{ id: "67899" }] as Provider[]);
service = TestBed.inject(ProductSwitcherService); initiateService();
const products = await firstValueFrom(service.products$); const products = await firstValueFrom(service.products$);
@ -129,7 +166,7 @@ describe("ProductSwitcherService", () => {
describe("active product", () => { describe("active product", () => {
it("marks Password Manager as active", async () => { it("marks Password Manager as active", async () => {
service = TestBed.inject(ProductSwitcherService); initiateService();
const products = await firstValueFrom(service.products$); const products = await firstValueFrom(service.products$);
@ -141,7 +178,7 @@ describe("ProductSwitcherService", () => {
it("marks Secret Manager as active", async () => { it("marks Secret Manager as active", async () => {
router.url = "/sm/"; router.url = "/sm/";
service = TestBed.inject(ProductSwitcherService); initiateService();
const products = await firstValueFrom(service.products$); const products = await firstValueFrom(service.products$);
@ -155,7 +192,7 @@ describe("ProductSwitcherService", () => {
activeRouteParams = convertToParamMap({ organizationId: "1" }); activeRouteParams = convertToParamMap({ organizationId: "1" });
router.url = "/organizations/"; router.url = "/organizations/";
service = TestBed.inject(ProductSwitcherService); initiateService();
const products = await firstValueFrom(service.products$); const products = await firstValueFrom(service.products$);
@ -168,7 +205,7 @@ describe("ProductSwitcherService", () => {
providerService.getAll.mockResolvedValue([{ id: "67899" }] as Provider[]); providerService.getAll.mockResolvedValue([{ id: "67899" }] as Provider[]);
router.url = "/providers/"; router.url = "/providers/";
service = TestBed.inject(ProductSwitcherService); initiateService();
const products = await firstValueFrom(service.products$); const products = await firstValueFrom(service.products$);
@ -187,7 +224,7 @@ describe("ProductSwitcherService", () => {
{ id: "4243", canAccessSecretsManager: true, enabled: true, name: "Org 32" }, { id: "4243", canAccessSecretsManager: true, enabled: true, name: "Org 32" },
] as Organization[]); ] as Organization[]);
service = TestBed.inject(ProductSwitcherService); initiateService();
const products = await firstValueFrom(service.products$); const products = await firstValueFrom(service.products$);
@ -205,7 +242,7 @@ describe("ProductSwitcherService", () => {
{ id: "4243", isOwner: true, name: "My Org" }, { id: "4243", isOwner: true, name: "My Org" },
] as Organization[]); ] as Organization[]);
service = TestBed.inject(ProductSwitcherService); initiateService();
const products = await firstValueFrom(service.products$); const products = await firstValueFrom(service.products$);

View File

@ -1,13 +1,6 @@
import { Injectable } from "@angular/core"; import { Injectable } from "@angular/core";
import { import { ActivatedRoute, NavigationEnd, NavigationStart, ParamMap, Router } from "@angular/router";
ActivatedRoute, import { combineLatest, concatMap, filter, map, Observable, ReplaySubject, startWith } from "rxjs";
Event,
NavigationEnd,
NavigationStart,
ParamMap,
Router,
} from "@angular/router";
import { combineLatest, concatMap, filter, map, Observable, startWith } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { import {
@ -16,6 +9,7 @@ import {
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { SyncService } from "@bitwarden/common/platform/sync";
export type ProductSwitcherItem = { export type ProductSwitcherItem = {
/** /**
@ -59,13 +53,38 @@ export type ProductSwitcherItem = {
providedIn: "root", providedIn: "root",
}) })
export class ProductSwitcherService { export class ProductSwitcherService {
/**
* Emits when the sync service has completed a sync
*
* Without waiting for a sync to be complete, in accurate product information
* can be displayed to the user for a brief moment until the sync is complete
* and all data is available.
*/
private syncCompleted$ = new ReplaySubject<void>(1);
/**
* Certain events should trigger an update to the `products$` observable but the values
* themselves are not needed. This observable is used to only trigger the update.
*/
private triggerProductUpdate$: Observable<void> = combineLatest([
this.syncCompleted$,
this.router.events.pipe(
// Product paths need to be updated when routes change, but the router event isn't actually needed
startWith(null), // Start with a null event to trigger the initial combineLatest
filter((e) => e instanceof NavigationEnd || e instanceof NavigationStart || e === null),
),
]).pipe(map(() => null));
constructor( constructor(
private organizationService: OrganizationService, private organizationService: OrganizationService,
private providerService: ProviderService, private providerService: ProviderService,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private i18n: I18nPipe, private i18n: I18nPipe,
) {} private syncService: SyncService,
) {
this.pollUntilSynced();
}
products$: Observable<{ products$: Observable<{
bento: ProductSwitcherItem[]; bento: ProductSwitcherItem[];
@ -73,13 +92,9 @@ export class ProductSwitcherService {
}> = combineLatest([ }> = combineLatest([
this.organizationService.organizations$, this.organizationService.organizations$,
this.route.paramMap, this.route.paramMap,
this.router.events.pipe( this.triggerProductUpdate$,
// Product paths need to be updated when routes change, but the router event isn't actually needed
startWith(null), // Start with a null event to trigger the initial combineLatest
filter((e) => e instanceof NavigationEnd || e instanceof NavigationStart || e === null),
),
]).pipe( ]).pipe(
map(([orgs, ...rest]): [Organization[], ParamMap, Event | null] => { map(([orgs, ...rest]): [Organization[], ParamMap, void] => {
return [ return [
// Sort orgs by name to match the order within the sidebar // Sort orgs by name to match the order within the sidebar
orgs.sort((a, b) => a.name.localeCompare(b.name)), orgs.sort((a, b) => a.name.localeCompare(b.name)),
@ -186,4 +201,15 @@ export class ProductSwitcherService {
}; };
}), }),
); );
/** Poll the `syncService` until a sync is completed */
private pollUntilSynced() {
const interval = setInterval(async () => {
const lastSync = await this.syncService.getLastSync();
if (lastSync !== null) {
clearInterval(interval);
this.syncCompleted$.next();
}
}, 200);
}
} }