1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-29 14:43:31 +00:00

[PM-28222] delay progress bar (#17507)

This commit is contained in:
Alex
2025-12-18 11:00:48 -05:00
committed by GitHub
parent 2afa36f598
commit 735af3c890
9 changed files with 170 additions and 144 deletions

View File

@@ -1,5 +1,5 @@
@if ((dataService.reportStatus$ | async) == ReportStatusEnum.Loading) {
<dirt-risk-insights-loading></dirt-risk-insights-loading>
<dirt-report-loading></dirt-report-loading>
} @else {
<ul
class="tw-inline-grid tw-grid-cols-3 tw-gap-6 tw-m-0 tw-p-0 tw-w-full tw-auto-cols-auto tw-list-none"

View File

@@ -14,7 +14,7 @@ import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.component";
import { ReportLoadingComponent } from "../shared/report-loading.component";
import { ActivityCardComponent } from "./activity-card.component";
import { PasswordChangeMetricComponent } from "./activity-cards/password-change-metric.component";
@@ -25,7 +25,7 @@ import { NewApplicationsDialogComponent } from "./application-review-dialog/new-
@Component({
selector: "dirt-all-activity",
imports: [
ApplicationsLoadingComponent,
ReportLoadingComponent,
SharedModule,
ActivityCardComponent,
PasswordChangeMetricComponent,

View File

@@ -1,5 +1,5 @@
@if ((dataService.reportStatus$ | async) == ReportStatusEnum.Loading) {
<dirt-risk-insights-loading></dirt-risk-insights-loading>
<dirt-report-loading></dirt-report-loading>
} @else {
@let drawerDetails = dataService.drawerDetails$ | async;
<div class="tw-mt-4 tw-flex tw-flex-col">

View File

@@ -36,14 +36,14 @@ import {
ApplicationTableDataSource,
AppTableRowScrollableComponent,
} from "../shared/app-table-row-scrollable.component";
import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.component";
import { ReportLoadingComponent } from "../shared/report-loading.component";
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "dirt-all-applications",
templateUrl: "./all-applications.component.html",
imports: [
ApplicationsLoadingComponent,
ReportLoadingComponent,
HeaderModule,
LinkModule,
SearchModule,

View File

@@ -3,86 +3,86 @@
<ng-container>
@let status = dataService.reportStatus$ | async;
@let hasCiphers = dataService.hasCiphers$ | async;
@let isGeneratingReport = dataService.isGeneratingReport$ | async;
@if (status == ReportStatusEnum.Initializing || hasCiphers === null) {
<!-- Show page loading state when initializing risk insights (quick page load) -->
<dirt-page-loading></dirt-page-loading>
} @else {
<!-- Check final states after initial calls have been completed -->
@if (isRiskInsightsActivityTabFeatureEnabled && !(dataService.hasReportData$ | async)) {
<!-- Show empty state only when feature flag is enabled and there's no report data -->
<div @fadeIn class="tw-flex tw-justify-center tw-items-center tw-min-h-[70vh] tw-w-full">
@if (!hasCiphers) {
<!-- Show Empty state when there are no applications (no ciphers to make reports on) -->
<empty-state-card
[videoSrc]="emptyStateVideoSrc"
[title]="this.i18nService.t('noDataInOrgTitle')"
[description]="this.i18nService.t('noDataInOrgDescription')"
[benefits]="emptyStateBenefits"
[buttonText]="this.i18nService.t('importData')"
[buttonIcon]="IMPORT_ICON"
[buttonAction]="this.goToImportPage"
></empty-state-card>
} @else {
<!-- Show empty state for no reports run -->
<empty-state-card
[videoSrc]="emptyStateVideoSrc"
[title]="this.i18nService.t('noReportsRunTitle')"
[description]="this.i18nService.t('noReportsRunDescription')"
[benefits]="emptyStateBenefits"
[buttonText]="this.i18nService.t('riskInsightsRunReport')"
[buttonIcon]=""
[buttonAction]="this.generateReport.bind(this)"
></empty-state-card>
}
</div>
<!-- Show loading component at top level so it's not destroyed when hasReportData$ changes -->
<!-- currentProgressStep is non-null when report generation is in progress -->
@if (currentProgressStep(); as step) {
<dirt-report-loading [progressStep]="step"></dirt-report-loading>
} @else {
<!-- Show screen when there is report data OR when feature flag is disabled (show tabs even without data) -->
<div @fadeIn class="tw-min-h-screen tw-flex tw-flex-col">
<div>
<div class="tw-text-main tw-max-w-4xl tw-mb-2" *ngIf="appsCount > 0">
{{ "reviewAtRiskPasswords" | i18n }}
</div>
@let isRunningReport = dataService.isGeneratingReport$ | async;
<div
class="tw-bg-primary-100 tw-rounded-lg tw-w-full tw-px-8 tw-py-4 tw-my-4 tw-flex tw-items-center"
>
<i
class="bwi bwi-exclamation-triangle bwi-lg tw-text-[1.2rem] tw-text-muted"
aria-hidden="true"
></i>
@if (dataLastUpdated) {
<span class="tw-mx-4">{{
"dataLastUpdated" | i18n: (dataLastUpdated | date: "MMMM d, y 'at' h:mm a")
}}</span>
}
<span class="tw-flex tw-justify-center">
<button
*ngIf="!isRunningReport"
type="button"
bitButton
buttonType="secondary"
class="tw-border-none !tw-font-normal tw-cursor-pointer !tw-py-0"
tabindex="0"
[bitAction]="generateReport.bind(this)"
>
{{ "riskInsightsRunReport" | i18n }}
</button>
<span>
<i
*ngIf="isRunningReport"
class="bwi bwi-spinner bwi-spin tw-text-muted tw-text-[1.2rem]"
aria-hidden="true"
></i>
</span>
</span>
</div>
<!-- Check final states after initial calls have been completed -->
@if (isRiskInsightsActivityTabFeatureEnabled && !(dataService.hasReportData$ | async)) {
<!-- Show empty state only when feature flag is enabled and there's no report data -->
<div @fadeIn class="tw-flex tw-justify-center tw-items-center tw-min-h-[70vh] tw-w-full">
@if (!hasCiphers) {
<!-- Show Empty state when there are no applications (no ciphers to make reports on) -->
<empty-state-card
[videoSrc]="emptyStateVideoSrc"
[title]="this.i18nService.t('noDataInOrgTitle')"
[description]="this.i18nService.t('noDataInOrgDescription')"
[benefits]="emptyStateBenefits"
[buttonText]="this.i18nService.t('importData')"
[buttonIcon]="IMPORT_ICON"
[buttonAction]="this.goToImportPage"
></empty-state-card>
} @else {
<!-- Show empty state for no reports run -->
<empty-state-card
[videoSrc]="emptyStateVideoSrc"
[title]="this.i18nService.t('noReportsRunTitle')"
[description]="this.i18nService.t('noReportsRunDescription')"
[benefits]="emptyStateBenefits"
[buttonText]="this.i18nService.t('riskInsightsRunReport')"
[buttonIcon]=""
[buttonAction]="this.generateReport.bind(this)"
></empty-state-card>
}
</div>
} @else {
<!-- Show screen when there is report data OR when feature flag is disabled (show tabs even without data) -->
<div @fadeIn class="tw-min-h-screen tw-flex tw-flex-col">
<div>
<div class="tw-text-main tw-max-w-4xl tw-mb-2" *ngIf="appsCount > 0">
{{ "reviewAtRiskPasswords" | i18n }}
</div>
@let isRunningReport = dataService.isGeneratingReport$ | async;
<div
class="tw-bg-primary-100 tw-rounded-lg tw-w-full tw-px-8 tw-py-4 tw-my-4 tw-flex tw-items-center"
>
<i
class="bwi bwi-exclamation-triangle bwi-lg tw-text-[1.2rem] tw-text-muted"
aria-hidden="true"
></i>
@if (dataLastUpdated) {
<span class="tw-mx-4">{{
"dataLastUpdated" | i18n: (dataLastUpdated | date: "MMMM d, y 'at' h:mm a")
}}</span>
}
<span class="tw-flex tw-justify-center">
<button
*ngIf="!isRunningReport"
type="button"
bitButton
buttonType="secondary"
class="tw-border-none !tw-font-normal tw-cursor-pointer !tw-py-0"
tabindex="0"
[bitAction]="generateReport.bind(this)"
>
{{ "riskInsightsRunReport" | i18n }}
</button>
<span>
<i
*ngIf="isRunningReport"
class="bwi bwi-spinner bwi-spin tw-text-muted tw-text-[1.2rem]"
aria-hidden="true"
></i>
</span>
</span>
</div>
</div>
@if (status == ReportStatusEnum.Loading && isGeneratingReport) {
<!-- Show detailed progress when generating report (longer operation) -->
<dirt-risk-insights-loading></dirt-risk-insights-loading>
} @else {
<div class="tw-flex-1 tw-flex tw-flex-col">
<bit-tab-group [(selectedIndex)]="tabIndex" (selectedIndexChange)="onTabChange($event)">
@if (isRiskInsightsActivityTabFeatureEnabled) {
@@ -105,8 +105,8 @@
</bit-tab>
</bit-tab-group>
</div>
}
</div>
</div>
}
}
}
</ng-container>

View File

@@ -6,16 +6,18 @@ import {
OnDestroy,
OnInit,
inject,
signal,
ChangeDetectionStrategy,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { EMPTY, firstValueFrom } from "rxjs";
import { distinctUntilChanged, map, tap } from "rxjs/operators";
import { concat, EMPTY, firstValueFrom, of } from "rxjs";
import { concatMap, delay, distinctUntilChanged, map, skip, tap } from "rxjs/operators";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
DrawerType,
ReportProgress,
ReportStatus,
RiskInsightsDataService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
@@ -42,8 +44,11 @@ import { CriticalApplicationsComponent } from "./critical-applications/critical-
import { EmptyStateCardComponent } from "./empty-state-card.component";
import { RiskInsightsTabType } from "./models/risk-insights.models";
import { PageLoadingComponent } from "./shared/page-loading.component";
import { ReportLoadingComponent } from "./shared/report-loading.component";
import { RiskInsightsDrawerDialogComponent } from "./shared/risk-insights-drawer-dialog.component";
import { ApplicationsLoadingComponent } from "./shared/risk-insights-loading.component";
// Type alias for progress step (used in concatMap emissions)
type ProgressStep = ReportProgress | null;
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -59,7 +64,7 @@ import { ApplicationsLoadingComponent } from "./shared/risk-insights-loading.com
HeaderModule,
TabsModule,
AllActivityComponent,
ApplicationsLoadingComponent,
ReportLoadingComponent,
PageLoadingComponent,
],
animations: [
@@ -95,6 +100,13 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
protected IMPORT_ICON = "bwi bwi-download";
protected currentDialogRef: DialogRef<unknown, RiskInsightsDrawerDialogComponent> | null = null;
// Current progress step for loading component (null = not loading)
// Uses concatMap with delay to ensure each step is displayed for a minimum time
protected readonly currentProgressStep = signal<ProgressStep>(null);
// Minimum time to display each progress step (in milliseconds)
private readonly STEP_DISPLAY_DELAY_MS = 250;
// TODO: See https://github.com/bitwarden/clients/pull/16832#discussion_r2474523235
constructor(
@@ -170,6 +182,44 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
// this happens when navigating between orgs
// or just navigating away from the page and back
this.currentDialogRef?.close();
// Subscribe to progress steps with delay to ensure each step is displayed for a minimum time
// - skip(1): Skip initial BehaviorSubject emission (may contain stale Complete from previous run)
// - concatMap: Queue steps and process them sequentially
// - First visible step (FetchingMembers) shows immediately so loading appears instantly
// - Subsequent steps are delayed to prevent jarring quick transitions
// - After Complete step is shown, emit null to hide loading
this.dataService.reportProgress$
.pipe(
// Skip the initial emission from _reportProgressSubject (BehaviorSubject in orchestrator).
// Without this, navigating to the page would flash the loading component briefly
// because BehaviorSubject emits its current value (e.g., Complete from last run) to new subscribers.
skip(1),
concatMap((step) => {
// Show null and FetchingMembers immediately (first visible step)
// This ensures loading component appears instantly when user clicks "Run Report"
if (step === null || step === ReportProgress.FetchingMembers) {
return of(step);
}
// Delay subsequent steps to prevent jarring quick transitions
if (step === ReportProgress.Complete) {
// Show Complete step, wait, then emit null to hide loading
// Why concat is needed:
// - The orchestrator emits Complete but never emits null afterward
// - Without this concat, the loading would stay on "Compiling insights..." forever
// - The concat automatically emits null to hide the loader
return concat(
of(step as ProgressStep).pipe(delay(this.STEP_DISPLAY_DELAY_MS)),
of(null as ProgressStep).pipe(delay(this.STEP_DISPLAY_DELAY_MS)),
);
}
return of(step).pipe(delay(this.STEP_DISPLAY_DELAY_MS));
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((step) => {
this.currentProgressStep.set(step);
});
}
ngOnDestroy(): void {

View File

@@ -3,7 +3,7 @@
<!-- Progress bar -->
<div class="tw-w-64" role="progressbar" attr.aria-label="{{ 'loadingProgress' | i18n }}">
<bit-progress
[barWidth]="progress()"
[barWidth]="stepConfig[progressStep()].progress"
[showText]="false"
size="default"
bgColor="primary"
@@ -13,7 +13,7 @@
<!-- Status message and subtitle -->
<div class="tw-text-center tw-flex tw-flex-col tw-gap-1">
<span class="tw-text-main tw-text-base tw-font-medium tw-leading-4">
{{ currentMessage() | i18n }}
{{ stepConfig[progressStep()].message | i18n }}
</span>
<span class="tw-text-muted tw-text-sm tw-font-normal tw-leading-4">
{{ "thisMightTakeFewMinutes" | i18n }}

View File

@@ -0,0 +1,33 @@
import { CommonModule } from "@angular/common";
import { Component, input } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ReportProgress } from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { ProgressModule } from "@bitwarden/components";
// Map of progress step to display config
const ProgressStepConfig = Object.freeze({
[ReportProgress.FetchingMembers]: { message: "fetchingMemberData", progress: 20 },
[ReportProgress.AnalyzingPasswords]: { message: "analyzingPasswordHealth", progress: 40 },
[ReportProgress.CalculatingRisks]: { message: "calculatingRiskScores", progress: 60 },
[ReportProgress.GeneratingReport]: { message: "generatingReportData", progress: 80 },
[ReportProgress.Saving]: { message: "savingReport", progress: 95 },
[ReportProgress.Complete]: { message: "compilingInsights", progress: 100 },
} as const);
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "dirt-report-loading",
imports: [CommonModule, JslibModule, ProgressModule],
templateUrl: "./report-loading.component.html",
})
export class ReportLoadingComponent {
// Progress step input from parent component.
// Recommended: delay emissions to this input to ensure each step displays for a minimum time.
// Refer to risk-insights.component for implementation example.
readonly progressStep = input<ReportProgress>(ReportProgress.FetchingMembers);
// Expose config map to template for direct lookup
protected readonly stepConfig = ProgressStepConfig;
}

View File

@@ -1,57 +0,0 @@
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
ReportProgress,
RiskInsightsDataService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { ProgressModule } from "@bitwarden/components";
const PROGRESS_STEPS = [
{ step: ReportProgress.FetchingMembers, message: "fetchingMemberData", progress: 20 },
{ step: ReportProgress.AnalyzingPasswords, message: "analyzingPasswordHealth", progress: 40 },
{ step: ReportProgress.CalculatingRisks, message: "calculatingRiskScores", progress: 60 },
{ step: ReportProgress.GeneratingReport, message: "generatingReportData", progress: 80 },
{ step: ReportProgress.Saving, message: "savingReport", progress: 95 },
{ step: ReportProgress.Complete, message: "compilingInsights", progress: 100 },
] as const;
type LoadingMessage = (typeof PROGRESS_STEPS)[number]["message"];
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "dirt-risk-insights-loading",
imports: [CommonModule, JslibModule, ProgressModule],
templateUrl: "./risk-insights-loading.component.html",
})
export class ApplicationsLoadingComponent implements OnInit {
private dataService = inject(RiskInsightsDataService);
private destroyRef = inject(DestroyRef);
readonly currentMessage = signal<LoadingMessage>(PROGRESS_STEPS[0].message);
readonly progress = signal<number>(PROGRESS_STEPS[0].progress);
ngOnInit(): void {
// Subscribe to actual progress events from the orchestrator
this.dataService.reportProgress$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((progressStep) => {
if (progressStep === null) {
// Reset to initial state
this.currentMessage.set(PROGRESS_STEPS[0].message);
this.progress.set(PROGRESS_STEPS[0].progress);
return;
}
// Find the matching step configuration
const stepConfig = PROGRESS_STEPS.find((config) => config.step === progressStep);
if (stepConfig) {
this.currentMessage.set(stepConfig.message);
this.progress.set(stepConfig.progress);
}
});
}
}