[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:
parent
e3b425069c
commit
94438d4138
|
@ -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>
|
||||||
|
|
|
@ -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$;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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$);
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue