mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[PM-25084] Vault Skeleton loading (#17321)
* add import to overflow styles to override the overflow applied by virtual scrolling * add position relative so absolute children display in scrolling context rather over the entire page * add fade in skeleton to vault page * refactor vault loading state to shared service * disable search while loading * add live announcement when vault is loading / loaded * simplify announcement * resolve CI issues * add feature flag for skeletons * add feature flag observables for loading state * update component naming
This commit is contained in:
@@ -5806,6 +5806,12 @@
|
||||
"upgradeToPremium": {
|
||||
"message": "Upgrade to Premium"
|
||||
},
|
||||
"loadingVault": {
|
||||
"message": "Loading vault"
|
||||
},
|
||||
"vaultLoaded": {
|
||||
"message": "Vault loaded"
|
||||
},
|
||||
"settingDisabledByPolicy": {
|
||||
"message": "This setting is disabled by your organization's policy.",
|
||||
"description": "This hint text is displayed when a user setting is disabled due to an organization policy."
|
||||
|
||||
@@ -27,10 +27,10 @@
|
||||
data-testid="popup-layout-scroll-region"
|
||||
(scroll)="handleScroll($event)"
|
||||
[ngClass]="{
|
||||
'tw-overflow-hidden': hideOverflow(),
|
||||
'!tw-overflow-hidden': hideOverflow(),
|
||||
'tw-overflow-y-auto': !hideOverflow(),
|
||||
'tw-invisible': loading(),
|
||||
'tw-py-3 bit-compact:tw-py-2 tw-px-[max(0.75rem,calc((100%-(var(--tw-sm-breakpoint)))/2))] bit-compact:tw-px-[max(0.5rem,calc((100%-(var(--tw-sm-breakpoint)))/2))]':
|
||||
'tw-relative tw-py-3 bit-compact:tw-py-2 tw-px-[max(0.75rem,calc((100%-(var(--tw-sm-breakpoint)))/2))] bit-compact:tw-px-[max(0.5rem,calc((100%-(var(--tw-sm-breakpoint)))/2))]':
|
||||
!disablePadding(),
|
||||
}"
|
||||
bitScrollLayoutHost
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<!-- tw-p-3 matches the padding of the popup-page -->
|
||||
<div
|
||||
class="tw-absolute tw-left-0 tw-top-0 tw-size-full tw-p-3 tw-overflow-hidden tw-bg-background-alt"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { animate, style, transition, trigger } from "@angular/animations";
|
||||
import { ChangeDetectionStrategy, Component, HostBinding } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "vault-fade-in-out-skeleton",
|
||||
templateUrl: "./vault-fade-in-out-skeleton.component.html",
|
||||
animations: [
|
||||
trigger("fadeInOut", [
|
||||
transition(":enter", [
|
||||
style({ opacity: 0 }),
|
||||
animate("100ms ease-in", style({ opacity: 1 })),
|
||||
]),
|
||||
transition(":leave", [animate("300ms ease-out", style({ opacity: 0 }))]),
|
||||
]),
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class VaultFadeInOutSkeletonComponent {
|
||||
@HostBinding("@fadeInOut") fadeInOut = true;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
PopupListFilter,
|
||||
VaultPopupListFiltersService,
|
||||
} from "../../../../../vault/popup/services/vault-popup-list-filters.service";
|
||||
import { VaultPopupLoadingService } from "../../../services/vault-popup-loading.service";
|
||||
|
||||
import { VaultHeaderV2Component } from "./vault-header-v2.component";
|
||||
|
||||
@@ -99,6 +100,10 @@ describe("VaultHeaderV2Component", () => {
|
||||
provide: StateProvider,
|
||||
useValue: { getGlobal: () => ({ state$, update }) },
|
||||
},
|
||||
{
|
||||
provide: VaultPopupLoadingService,
|
||||
useValue: { loading$: new BehaviorSubject(false) },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
[(ngModel)]="searchText"
|
||||
(ngModelChange)="onSearchTextChanged()"
|
||||
appAutofocus
|
||||
[disabled]="loading$ | async"
|
||||
>
|
||||
</bit-search>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/sea
|
||||
import { SearchModule } from "@bitwarden/components";
|
||||
|
||||
import { VaultPopupItemsService } from "../../../services/vault-popup-items.service";
|
||||
import { VaultPopupLoadingService } from "../../../services/vault-popup-loading.service";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@@ -22,8 +23,10 @@ export class VaultV2SearchComponent {
|
||||
|
||||
private searchText$ = new Subject<string>();
|
||||
|
||||
protected loading$ = this.vaultPopupLoadingService.loading$;
|
||||
constructor(
|
||||
private vaultPopupItemsService: VaultPopupItemsService,
|
||||
private vaultPopupLoadingService: VaultPopupLoadingService,
|
||||
private ngZone: NgZone,
|
||||
) {
|
||||
this.subscribeToLatestSearchText();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<popup-page [loading]="loading$ | async">
|
||||
<popup-page [loading]="showSpinnerLoaders$ | async" [hideOverflow]="showSkeletonsLoaders$ | async">
|
||||
<popup-header slot="header" [pageTitle]="'vault' | i18n">
|
||||
<ng-container slot="end">
|
||||
<app-new-item-dropdown [initialValues]="newItemItemValues$ | async"></app-new-item-dropdown>
|
||||
@@ -103,4 +103,10 @@
|
||||
></app-vault-list-items-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
@if (showSkeletonsLoaders$ | async) {
|
||||
<vault-fade-in-out-skeleton>
|
||||
<vault-loading-skeleton></vault-loading-skeleton>
|
||||
</vault-fade-in-out-skeleton>
|
||||
}
|
||||
</popup-page>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LiveAnnouncer } from "@angular/cdk/a11y";
|
||||
import { CdkVirtualScrollableElement, ScrollingModule } from "@angular/cdk/scrolling";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { AfterViewInit, Component, DestroyRef, OnDestroy, OnInit, ViewChild } from "@angular/core";
|
||||
@@ -5,14 +6,15 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { Router, RouterModule } from "@angular/router";
|
||||
import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
Observable,
|
||||
shareReplay,
|
||||
startWith,
|
||||
switchMap,
|
||||
take,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
@@ -22,6 +24,8 @@ import { DeactivatedOrg, NoResults, VaultOpen } from "@bitwarden/assets/svg";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
@@ -41,11 +45,13 @@ import { PopOutComponent } from "../../../../platform/popup/components/pop-out.c
|
||||
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
|
||||
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
|
||||
import { IntroCarouselService } from "../../services/intro-carousel.service";
|
||||
import { VaultPopupCopyButtonsService } from "../../services/vault-popup-copy-buttons.service";
|
||||
import { VaultPopupItemsService } from "../../services/vault-popup-items.service";
|
||||
import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.service";
|
||||
import { VaultPopupLoadingService } from "../../services/vault-popup-loading.service";
|
||||
import { VaultPopupScrollPositionService } from "../../services/vault-popup-scroll-position.service";
|
||||
import { AtRiskPasswordCalloutComponent } from "../at-risk-callout/at-risk-password-callout.component";
|
||||
import { VaultFadeInOutSkeletonComponent } from "../vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component";
|
||||
import { VaultLoadingSkeletonComponent } from "../vault-loading-skeleton/vault-loading-skeleton.component";
|
||||
|
||||
import { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injection-banner.component";
|
||||
import {
|
||||
@@ -88,6 +94,8 @@ type VaultState = UnionOfValues<typeof VaultState>;
|
||||
SpotlightComponent,
|
||||
RouterModule,
|
||||
TypographyModule,
|
||||
VaultLoadingSkeletonComponent,
|
||||
VaultFadeInOutSkeletonComponent,
|
||||
],
|
||||
})
|
||||
export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
|
||||
@@ -108,19 +116,30 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
|
||||
);
|
||||
|
||||
activeUserId: UserId | null = null;
|
||||
|
||||
private loading$ = this.vaultPopupLoadingService.loading$.pipe(
|
||||
distinctUntilChanged(),
|
||||
tap((loading) => {
|
||||
const key = loading ? "loadingVault" : "vaultLoaded";
|
||||
void this.liveAnnouncer.announce(this.i18nService.translate(key), "polite");
|
||||
}),
|
||||
);
|
||||
private skeletonFeatureFlag$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.VaultLoadingSkeletons,
|
||||
);
|
||||
|
||||
protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$;
|
||||
protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$;
|
||||
protected allFilters$ = this.vaultPopupListFiltersService.allFilters$;
|
||||
|
||||
protected loading$ = combineLatest([
|
||||
this.vaultPopupItemsService.loading$,
|
||||
this.allFilters$,
|
||||
// Added as a dependency to avoid flashing the copyActions on slower devices
|
||||
this.vaultCopyButtonsService.showQuickCopyActions$,
|
||||
]).pipe(
|
||||
map(([itemsLoading, filters]) => itemsLoading || !filters),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
startWith(true),
|
||||
/** When true, show spinner loading state */
|
||||
protected showSpinnerLoaders$ = combineLatest([this.loading$, this.skeletonFeatureFlag$]).pipe(
|
||||
map(([loading, skeletonsEnabled]) => loading && !skeletonsEnabled),
|
||||
);
|
||||
|
||||
/** When true, show skeleton loading state */
|
||||
protected showSkeletonsLoaders$ = combineLatest([this.loading$, this.skeletonFeatureFlag$]).pipe(
|
||||
map(([loading, skeletonsEnabled]) => loading && skeletonsEnabled),
|
||||
);
|
||||
|
||||
protected newItemItemValues$: Observable<NewItemInitialValues> =
|
||||
@@ -150,14 +169,17 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
|
||||
private vaultPopupItemsService: VaultPopupItemsService,
|
||||
private vaultPopupListFiltersService: VaultPopupListFiltersService,
|
||||
private vaultScrollPositionService: VaultPopupScrollPositionService,
|
||||
private vaultPopupLoadingService: VaultPopupLoadingService,
|
||||
private accountService: AccountService,
|
||||
private destroyRef: DestroyRef,
|
||||
private cipherService: CipherService,
|
||||
private dialogService: DialogService,
|
||||
private vaultCopyButtonsService: VaultPopupCopyButtonsService,
|
||||
private introCarouselService: IntroCarouselService,
|
||||
private nudgesService: NudgesService,
|
||||
private router: Router,
|
||||
private liveAnnouncer: LiveAnnouncer,
|
||||
private i18nService: I18nService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
combineLatest([
|
||||
this.vaultPopupItemsService.emptyVault$,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { firstValueFrom, skip, Subject } from "rxjs";
|
||||
|
||||
import { VaultPopupCopyButtonsService } from "./vault-popup-copy-buttons.service";
|
||||
import { VaultPopupItemsService } from "./vault-popup-items.service";
|
||||
import { VaultPopupListFiltersService } from "./vault-popup-list-filters.service";
|
||||
import { VaultPopupLoadingService } from "./vault-popup-loading.service";
|
||||
|
||||
describe("VaultPopupLoadingService", () => {
|
||||
let service: VaultPopupLoadingService;
|
||||
let itemsLoading$: Subject<boolean>;
|
||||
let allFilters$: Subject<any>;
|
||||
let showQuickCopyActions$: Subject<boolean>;
|
||||
|
||||
beforeEach(() => {
|
||||
itemsLoading$ = new Subject<boolean>();
|
||||
allFilters$ = new Subject<any>();
|
||||
showQuickCopyActions$ = new Subject<boolean>();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
VaultPopupLoadingService,
|
||||
{ provide: VaultPopupItemsService, useValue: { loading$: itemsLoading$ } },
|
||||
{ provide: VaultPopupListFiltersService, useValue: { allFilters$: allFilters$ } },
|
||||
{
|
||||
provide: VaultPopupCopyButtonsService,
|
||||
useValue: { showQuickCopyActions$: showQuickCopyActions$ },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(VaultPopupLoadingService);
|
||||
});
|
||||
|
||||
it("emits true initially", async () => {
|
||||
const loading = await firstValueFrom(service.loading$);
|
||||
|
||||
expect(loading).toBe(true);
|
||||
});
|
||||
|
||||
it("emits false when items are loaded and filters are available", async () => {
|
||||
const loadingPromise = firstValueFrom(service.loading$.pipe(skip(1)));
|
||||
|
||||
itemsLoading$.next(false);
|
||||
allFilters$.next({});
|
||||
showQuickCopyActions$.next(true);
|
||||
|
||||
expect(await loadingPromise).toBe(false);
|
||||
});
|
||||
|
||||
it("emits true when filters are not available", async () => {
|
||||
const loadingPromise = firstValueFrom(service.loading$.pipe(skip(2)));
|
||||
|
||||
itemsLoading$.next(false);
|
||||
allFilters$.next({});
|
||||
showQuickCopyActions$.next(true);
|
||||
allFilters$.next(null);
|
||||
|
||||
expect(await loadingPromise).toBe(true);
|
||||
});
|
||||
|
||||
it("emits true when items are loading", async () => {
|
||||
const loadingPromise = firstValueFrom(service.loading$.pipe(skip(2)));
|
||||
|
||||
itemsLoading$.next(false);
|
||||
allFilters$.next({});
|
||||
showQuickCopyActions$.next(true);
|
||||
itemsLoading$.next(true);
|
||||
|
||||
expect(await loadingPromise).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { combineLatest, map, shareReplay, startWith } from "rxjs";
|
||||
|
||||
import { VaultPopupCopyButtonsService } from "./vault-popup-copy-buttons.service";
|
||||
import { VaultPopupItemsService } from "./vault-popup-items.service";
|
||||
import { VaultPopupListFiltersService } from "./vault-popup-list-filters.service";
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class VaultPopupLoadingService {
|
||||
private vaultPopupItemsService = inject(VaultPopupItemsService);
|
||||
private vaultPopupListFiltersService = inject(VaultPopupListFiltersService);
|
||||
private vaultCopyButtonsService = inject(VaultPopupCopyButtonsService);
|
||||
|
||||
/** Loading state of the vault */
|
||||
loading$ = combineLatest([
|
||||
this.vaultPopupItemsService.loading$,
|
||||
this.vaultPopupListFiltersService.allFilters$,
|
||||
// Added as a dependency to avoid flashing the copyActions on slower devices
|
||||
this.vaultCopyButtonsService.showQuickCopyActions$,
|
||||
]).pipe(
|
||||
map(([itemsLoading, filters]) => itemsLoading || !filters),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
startWith(true),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user