1
0
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:
Nick Krantz
2025-11-12 08:31:25 -06:00
committed by GitHub
parent 2762d46c34
commit d71add85e8
11 changed files with 183 additions and 15 deletions

View File

@@ -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."

View File

@@ -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

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -4,5 +4,6 @@
[(ngModel)]="searchText"
(ngModelChange)="onSearchTextChanged()"
appAutofocus
[disabled]="loading$ | async"
>
</bit-search>

View File

@@ -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();

View File

@@ -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>

View File

@@ -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$,

View File

@@ -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);
});
});

View File

@@ -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),
);
}