1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 06:13:38 +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": { "upgradeToPremium": {
"message": "Upgrade to Premium" "message": "Upgrade to Premium"
}, },
"loadingVault": {
"message": "Loading vault"
},
"vaultLoaded": {
"message": "Vault loaded"
},
"settingDisabledByPolicy": { "settingDisabledByPolicy": {
"message": "This setting is disabled by your organization's policy.", "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." "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" data-testid="popup-layout-scroll-region"
(scroll)="handleScroll($event)" (scroll)="handleScroll($event)"
[ngClass]="{ [ngClass]="{
'tw-overflow-hidden': hideOverflow(), '!tw-overflow-hidden': hideOverflow(),
'tw-overflow-y-auto': !hideOverflow(), 'tw-overflow-y-auto': !hideOverflow(),
'tw-invisible': loading(), '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(), !disablePadding(),
}" }"
bitScrollLayoutHost 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, PopupListFilter,
VaultPopupListFiltersService, VaultPopupListFiltersService,
} from "../../../../../vault/popup/services/vault-popup-list-filters.service"; } from "../../../../../vault/popup/services/vault-popup-list-filters.service";
import { VaultPopupLoadingService } from "../../../services/vault-popup-loading.service";
import { VaultHeaderV2Component } from "./vault-header-v2.component"; import { VaultHeaderV2Component } from "./vault-header-v2.component";
@@ -99,6 +100,10 @@ describe("VaultHeaderV2Component", () => {
provide: StateProvider, provide: StateProvider,
useValue: { getGlobal: () => ({ state$, update }) }, useValue: { getGlobal: () => ({ state$, update }) },
}, },
{
provide: VaultPopupLoadingService,
useValue: { loading$: new BehaviorSubject(false) },
},
], ],
}).compileComponents(); }).compileComponents();

View File

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

View File

@@ -9,6 +9,7 @@ import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/sea
import { SearchModule } from "@bitwarden/components"; import { SearchModule } from "@bitwarden/components";
import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; 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 // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@@ -22,8 +23,10 @@ export class VaultV2SearchComponent {
private searchText$ = new Subject<string>(); private searchText$ = new Subject<string>();
protected loading$ = this.vaultPopupLoadingService.loading$;
constructor( constructor(
private vaultPopupItemsService: VaultPopupItemsService, private vaultPopupItemsService: VaultPopupItemsService,
private vaultPopupLoadingService: VaultPopupLoadingService,
private ngZone: NgZone, private ngZone: NgZone,
) { ) {
this.subscribeToLatestSearchText(); 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"> <popup-header slot="header" [pageTitle]="'vault' | i18n">
<ng-container slot="end"> <ng-container slot="end">
<app-new-item-dropdown [initialValues]="newItemItemValues$ | async"></app-new-item-dropdown> <app-new-item-dropdown [initialValues]="newItemItemValues$ | async"></app-new-item-dropdown>
@@ -103,4 +103,10 @@
></app-vault-list-items-container> ></app-vault-list-items-container>
</ng-container> </ng-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> </popup-page>

View File

@@ -1,3 +1,4 @@
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { CdkVirtualScrollableElement, ScrollingModule } from "@angular/cdk/scrolling"; import { CdkVirtualScrollableElement, ScrollingModule } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { AfterViewInit, Component, DestroyRef, OnDestroy, OnInit, ViewChild } from "@angular/core"; 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 { Router, RouterModule } from "@angular/router";
import { import {
combineLatest, combineLatest,
distinctUntilChanged,
filter, filter,
firstValueFrom, firstValueFrom,
map, map,
Observable, Observable,
shareReplay, shareReplay,
startWith,
switchMap, switchMap,
take, take,
tap,
} from "rxjs"; } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; 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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; 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 { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums"; 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 { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component"; import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
import { IntroCarouselService } from "../../services/intro-carousel.service"; 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 { VaultPopupItemsService } from "../../services/vault-popup-items.service";
import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.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 { VaultPopupScrollPositionService } from "../../services/vault-popup-scroll-position.service";
import { AtRiskPasswordCalloutComponent } from "../at-risk-callout/at-risk-password-callout.component"; 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 { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injection-banner.component";
import { import {
@@ -88,6 +94,8 @@ type VaultState = UnionOfValues<typeof VaultState>;
SpotlightComponent, SpotlightComponent,
RouterModule, RouterModule,
TypographyModule, TypographyModule,
VaultLoadingSkeletonComponent,
VaultFadeInOutSkeletonComponent,
], ],
}) })
export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
@@ -108,19 +116,30 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
); );
activeUserId: UserId | null = null; 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 favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$;
protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$; protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$;
protected allFilters$ = this.vaultPopupListFiltersService.allFilters$; protected allFilters$ = this.vaultPopupListFiltersService.allFilters$;
protected loading$ = combineLatest([ /** When true, show spinner loading state */
this.vaultPopupItemsService.loading$, protected showSpinnerLoaders$ = combineLatest([this.loading$, this.skeletonFeatureFlag$]).pipe(
this.allFilters$, map(([loading, skeletonsEnabled]) => loading && !skeletonsEnabled),
// Added as a dependency to avoid flashing the copyActions on slower devices );
this.vaultCopyButtonsService.showQuickCopyActions$,
]).pipe( /** When true, show skeleton loading state */
map(([itemsLoading, filters]) => itemsLoading || !filters), protected showSkeletonsLoaders$ = combineLatest([this.loading$, this.skeletonFeatureFlag$]).pipe(
shareReplay({ bufferSize: 1, refCount: true }), map(([loading, skeletonsEnabled]) => loading && skeletonsEnabled),
startWith(true),
); );
protected newItemItemValues$: Observable<NewItemInitialValues> = protected newItemItemValues$: Observable<NewItemInitialValues> =
@@ -150,14 +169,17 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
private vaultPopupItemsService: VaultPopupItemsService, private vaultPopupItemsService: VaultPopupItemsService,
private vaultPopupListFiltersService: VaultPopupListFiltersService, private vaultPopupListFiltersService: VaultPopupListFiltersService,
private vaultScrollPositionService: VaultPopupScrollPositionService, private vaultScrollPositionService: VaultPopupScrollPositionService,
private vaultPopupLoadingService: VaultPopupLoadingService,
private accountService: AccountService, private accountService: AccountService,
private destroyRef: DestroyRef, private destroyRef: DestroyRef,
private cipherService: CipherService, private cipherService: CipherService,
private dialogService: DialogService, private dialogService: DialogService,
private vaultCopyButtonsService: VaultPopupCopyButtonsService,
private introCarouselService: IntroCarouselService, private introCarouselService: IntroCarouselService,
private nudgesService: NudgesService, private nudgesService: NudgesService,
private router: Router, private router: Router,
private liveAnnouncer: LiveAnnouncer,
private i18nService: I18nService,
private configService: ConfigService,
) { ) {
combineLatest([ combineLatest([
this.vaultPopupItemsService.emptyVault$, 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),
);
}