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:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user