From f5300ab54c39d43bf634e3cc3fa83cf02dbc6067 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:58:54 -0700 Subject: [PATCH] =?UTF-8?q?[PM-29439]=20-=20Product=20Tour=20=E2=80=93=20C?= =?UTF-8?q?oachmarks=20(#19476)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add coachmark * add coachmark tour * fixes to coachmarks * updates to coachmarks * add tests * add specs * cleanup * merge main * add comment --- .../app/layouts/user-layout.component.html | 30 +- .../src/app/layouts/user-layout.component.ts | 22 +- .../components/coachmark/coachmark-step.ts | 65 ++++ .../coachmark/coachmark.component.html | 39 +++ .../coachmark/coachmark.component.ts | 44 +++ .../coachmark/coachmark.service.spec.ts | 330 ++++++++++++++++++ .../components/coachmark/coachmark.service.ts | 192 ++++++++++ .../app/vault/components/coachmark/index.ts | 3 + .../vault-welcome-dialog.component.spec.ts | 20 ++ .../vault-welcome-dialog.component.ts | 4 + .../components/vault-filter.component.html | 7 +- .../vault-filter-section.component.html | 19 +- .../vault-filter-section.component.ts | 24 +- .../shared/vault-filter-shared.module.ts | 5 +- .../vault-header/vault-header.component.html | 4 + .../vault-header/vault-header.component.ts | 19 +- apps/web/src/locales/en/messages.json | 40 +++ .../popover/popover-anchor-for.directive.ts | 8 +- libs/components/src/tw-theme.css | 2 +- .../new-cipher-menu.component.html | 5 + .../new-cipher-menu.component.ts | 18 +- 21 files changed, 884 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/app/vault/components/coachmark/coachmark-step.ts create mode 100644 apps/web/src/app/vault/components/coachmark/coachmark.component.html create mode 100644 apps/web/src/app/vault/components/coachmark/coachmark.component.ts create mode 100644 apps/web/src/app/vault/components/coachmark/coachmark.service.spec.ts create mode 100644 apps/web/src/app/vault/components/coachmark/coachmark.service.ts create mode 100644 apps/web/src/app/vault/components/coachmark/index.ts diff --git a/apps/web/src/app/layouts/user-layout.component.html b/apps/web/src/app/layouts/user-layout.component.html index 1802904de25..bb66cacca95 100644 --- a/apps/web/src/app/layouts/user-layout.component.html +++ b/apps/web/src/app/layouts/user-layout.component.html @@ -6,12 +6,36 @@ @if (sendEnabled$ | async) { } - + - + + - + + diff --git a/apps/web/src/app/layouts/user-layout.component.ts b/apps/web/src/app/layouts/user-layout.component.ts index 6b99464e293..8a061572161 100644 --- a/apps/web/src/app/layouts/user-layout.component.ts +++ b/apps/web/src/app/layouts/user-layout.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, OnInit, Signal } from "@angular/core"; +import { Component, computed, inject, OnInit, Signal } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; import { RouterModule } from "@angular/router"; import { map, Observable, switchMap } from "rxjs"; @@ -15,10 +15,11 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SyncService } from "@bitwarden/common/platform/sync"; -import { SvgModule } from "@bitwarden/components"; +import { PopoverModule, SvgModule } from "@bitwarden/components"; import { PremiumSubscriptionRoutingService } from "@bitwarden/web-vault/app/billing/individual/services/premium-subscription-routing.service"; import { BillingFreeFamiliesNavItemComponent } from "../billing/shared/billing-free-families-nav-item.component"; +import { CoachmarkComponent, CoachmarkService } from "../vault/components/coachmark"; import { WebLayoutModule } from "./web-layout.module"; @@ -34,6 +35,8 @@ import { WebLayoutModule } from "./web-layout.module"; WebLayoutModule, SvgModule, BillingFreeFamiliesNavItemComponent, + PopoverModule, + CoachmarkComponent, ], }) export class UserLayoutComponent implements OnInit { @@ -46,6 +49,21 @@ export class UserLayoutComponent implements OnInit { ); protected subscriptionRoute$: Observable; + protected readonly coachmarkService = inject(CoachmarkService); + + protected readonly importCoachmarkOpen = computed( + () => this.coachmarkService.activeStepId() === "importData", + ); + + protected readonly reportsCoachmarkOpen = computed( + () => this.coachmarkService.activeStepId() === "monitorSecurity", + ); + + /** Expand tools nav group when import coachmark is active */ + protected readonly toolsNavGroupOpen = computed( + () => this.coachmarkService.activeStepId() === "importData", + ); + constructor( private syncService: SyncService, private accountService: AccountService, diff --git a/apps/web/src/app/vault/components/coachmark/coachmark-step.ts b/apps/web/src/app/vault/components/coachmark/coachmark-step.ts new file mode 100644 index 00000000000..84afcb814eb --- /dev/null +++ b/apps/web/src/app/vault/components/coachmark/coachmark-step.ts @@ -0,0 +1,65 @@ +import { PositionIdentifier } from "@bitwarden/components"; + +/** Identifies a specific step in the coachmark tour */ +export type CoachmarkStepId = "importData" | "addItem" | "shareWithCollections" | "monitorSecurity"; + +/** Configuration for a single coachmark step */ +export interface CoachmarkStep { + /** Unique identifier for this step */ + id: CoachmarkStepId; + + /** Title displayed in the coachmark popover */ + titleKey: string; + + /** Description/content displayed in the coachmark popover */ + descriptionKey: string; + + /** Position of the popover relative to the anchor */ + position: PositionIdentifier; + + /** Optional URL for "Learn more" link */ + learnMoreUrl?: string; + + /** Whether this step is only shown to organizational users */ + requiresOrganization?: boolean; + + /** Route to navigate to before showing this step */ + route?: string; +} + +/** All available coachmark steps in display order */ +export const COACHMARK_STEPS: CoachmarkStep[] = [ + { + id: "importData", + titleKey: "coachmarkImportTitle", + descriptionKey: "coachmarkImportDescription", + position: "right-center", + learnMoreUrl: "https://bitwarden.com/help/import-data/", + route: "/tools/import", + }, + { + id: "addItem", + titleKey: "coachmarkAddItemTitle", + descriptionKey: "coachmarkAddItemDescription", + position: "below-center", + learnMoreUrl: "https://bitwarden.com/help/managing-items/", + route: "/vault", + }, + { + id: "shareWithCollections", + titleKey: "coachmarkShareWithCollectionsTitle", + descriptionKey: "coachmarkShareWithCollectionsDescription", + position: "right-center", + learnMoreUrl: "https://bitwarden.com/help/about-collections/", + requiresOrganization: true, + route: "/vault", + }, + { + id: "monitorSecurity", + titleKey: "coachmarkMonitorSecurityTitle", + descriptionKey: "coachmarkMonitorSecurityDescription", + position: "right-center", + learnMoreUrl: "https://bitwarden.com/help/reports/", + route: "/reports", + }, +]; diff --git a/apps/web/src/app/vault/components/coachmark/coachmark.component.html b/apps/web/src/app/vault/components/coachmark/coachmark.component.html new file mode 100644 index 00000000000..fbf487c6ec4 --- /dev/null +++ b/apps/web/src/app/vault/components/coachmark/coachmark.component.html @@ -0,0 +1,39 @@ + +

+ {{ service.getStepDescription(stepId()) }} +

+ + @if (service.getStepLearnMoreUrl(stepId()); as url) { + + {{ "learnMore" | i18n }} + + + } + + +
+ + {{ "coachmarkStepIndicator" | i18n: service.currentStepNumber() : service.totalSteps() }} + + +
+ @if (service.currentStepNumber() > 1) { + + } + +
+
+
diff --git a/apps/web/src/app/vault/components/coachmark/coachmark.component.ts b/apps/web/src/app/vault/components/coachmark/coachmark.component.ts new file mode 100644 index 00000000000..24b150104fe --- /dev/null +++ b/apps/web/src/app/vault/components/coachmark/coachmark.component.ts @@ -0,0 +1,44 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, input, viewChild } from "@angular/core"; + +import { + ButtonModule, + LinkModule, + PopoverComponent, + PopoverModule, + TypographyModule, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { CoachmarkStepId } from "./coachmark-step"; +import { CoachmarkService } from "./coachmark.service"; + +/** + * Self-contained coachmark tour step. + * Wraps a `` internally — use `coachmark.popover()` with `[bitPopoverAnchorFor]`. + * + * @example + * ```html + *
+ * Highlighted element + *
+ * + * ``` + */ +@Component({ + selector: "app-coachmark", + standalone: true, + imports: [CommonModule, ButtonModule, I18nPipe, LinkModule, PopoverModule, TypographyModule], + templateUrl: "coachmark.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + exportAs: "coachmark", +}) +export class CoachmarkComponent { + /** Which coachmark step this instance represents */ + readonly stepId = input.required(); + + /** Exposed so parent templates can bind `[bitPopoverAnchorFor]="ref.popover()"` */ + readonly popover = viewChild.required(PopoverComponent); + + protected readonly service = inject(CoachmarkService); +} diff --git a/apps/web/src/app/vault/components/coachmark/coachmark.service.spec.ts b/apps/web/src/app/vault/components/coachmark/coachmark.service.spec.ts new file mode 100644 index 00000000000..2604377ade2 --- /dev/null +++ b/apps/web/src/app/vault/components/coachmark/coachmark.service.spec.ts @@ -0,0 +1,330 @@ +import { TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { BehaviorSubject, of } from "rxjs"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { StateProvider } from "@bitwarden/state"; + +import { COACHMARK_STEPS } from "./coachmark-step"; +import { CoachmarkService } from "./coachmark.service"; + +describe("CoachmarkService", () => { + let service: CoachmarkService; + + const mockUserId = "user-123" as UserId; + + const getUserState$ = jest.fn().mockReturnValue(of(false)); + const setUserState = jest.fn().mockResolvedValue(undefined); + const navigate = jest.fn().mockResolvedValue(true); + const hasOrganizations = jest.fn().mockReturnValue(of(false)); + const t = jest.fn((key: string) => key); + + let activeAccount$: BehaviorSubject; + + function createAccount(overrides: Partial = {}): Account { + return { + id: mockUserId, + creationDate: new Date(), + ...overrides, + } as Account; + } + + beforeEach(() => { + jest.clearAllMocks(); + + activeAccount$ = new BehaviorSubject(createAccount()); + + TestBed.configureTestingModule({ + providers: [ + CoachmarkService, + { provide: AccountService, useValue: { activeAccount$ } }, + { provide: OrganizationService, useValue: { hasOrganizations } }, + { provide: StateProvider, useValue: { getUserState$, setUserState } }, + { provide: I18nService, useValue: { t } }, + { provide: Router, useValue: { navigate } }, + ], + }); + + service = TestBed.inject(CoachmarkService); + }); + + describe("getStepConfig", () => { + it("returns the config for a known step", () => { + const config = service.getStepConfig("importData"); + expect(config).toEqual(COACHMARK_STEPS[0]); + }); + + it("returns undefined for an unknown step", () => { + const config = service.getStepConfig("nonExistent" as any); + expect(config).toBeUndefined(); + }); + }); + + describe("getStepTitle", () => { + it("returns translated title for a valid step", () => { + service.getStepTitle("importData"); + expect(t).toHaveBeenCalledWith("coachmarkImportTitle"); + }); + + it("returns empty string for an unknown step", () => { + const result = service.getStepTitle("nonExistent" as any); + expect(result).toBe(""); + }); + }); + + describe("getStepDescription", () => { + it("returns translated description for a valid step", () => { + service.getStepDescription("addItem"); + expect(t).toHaveBeenCalledWith("coachmarkAddItemDescription"); + }); + + it("returns empty string for an unknown step", () => { + const result = service.getStepDescription("nonExistent" as any); + expect(result).toBe(""); + }); + }); + + describe("getStepLearnMoreUrl", () => { + it("returns the learn more URL for a step that has one", () => { + const url = service.getStepLearnMoreUrl("importData"); + expect(url).toBe("https://bitwarden.com/help/import-data/"); + }); + + it("returns undefined for an unknown step", () => { + const url = service.getStepLearnMoreUrl("nonExistent" as any); + expect(url).toBeUndefined(); + }); + }); + + describe("getStepPosition", () => { + it("returns the position for a valid step", () => { + const position = service.getStepPosition("importData"); + expect(position).toBe("right-center"); + }); + + it("returns undefined for an unknown step", () => { + const position = service.getStepPosition("nonExistent" as any); + expect(position).toBeUndefined(); + }); + }); + + describe("startTour", () => { + it("should not start if already running", fakeAsync(() => { + getUserState$.mockReturnValue(of(false)); + hasOrganizations.mockReturnValue(of(false)); + + void service.startTour(); + tick(200); + + expect(service.isRunning()).toBe(true); + + navigate.mockClear(); + void service.startTour(); + tick(200); + + expect(navigate).not.toHaveBeenCalled(); + })); + + it("should not start if there is no active account", fakeAsync(() => { + activeAccount$.next(null); + + void service.startTour(); + tick(200); + + expect(service.isRunning()).toBe(false); + })); + + it("should not start if tour has already been completed", fakeAsync(() => { + getUserState$.mockReturnValue(of(true)); + + void service.startTour(); + tick(200); + + expect(service.isRunning()).toBe(false); + })); + + it("should start tour and navigate to first step for non-org user", fakeAsync(() => { + getUserState$.mockReturnValue(of(false)); + hasOrganizations.mockReturnValue(of(false)); + + void service.startTour(); + tick(200); + + expect(navigate).toHaveBeenCalledWith(["/tools/import"]); + expect(service.activeStepId()).toBe("importData"); + expect(service.isRunning()).toBe(true); + expect(service.currentStepNumber()).toBe(1); + })); + + it("should include org-only steps for org users", fakeAsync(() => { + getUserState$.mockReturnValue(of(false)); + hasOrganizations.mockReturnValue(of(true)); + + void service.startTour(); + tick(200); + + expect(service.totalSteps()).toBe(4); + })); + + it("should exclude org-only steps for non-org users", fakeAsync(() => { + getUserState$.mockReturnValue(of(false)); + hasOrganizations.mockReturnValue(of(false)); + + void service.startTour(); + tick(200); + + // shareWithCollections step is excluded + expect(service.totalSteps()).toBe(3); + })); + }); + + describe("nextStep", () => { + beforeEach(fakeAsync(() => { + getUserState$.mockReturnValue(of(false)); + hasOrganizations.mockReturnValue(of(false)); + + void service.startTour(); + tick(200); + + navigate.mockClear(); + })); + + it("should advance to the next step", fakeAsync(() => { + void service.nextStep(); + tick(200); + + expect(service.activeStepId()).toBe("addItem"); + expect(service.currentStepNumber()).toBe(2); + expect(navigate).toHaveBeenCalledWith(["/vault"]); + })); + + it("should complete tour when on the last step", fakeAsync(() => { + // Advance to step 2 + void service.nextStep(); + tick(200); + + // Advance to step 3 (last for non-org) + void service.nextStep(); + tick(200); + + expect(service.activeStepId()).toBe("monitorSecurity"); + + // Next completes the tour + void service.nextStep(); + tick(200); + + expect(service.isRunning()).toBe(false); + expect(setUserState).toHaveBeenCalled(); + })); + + it("should do nothing if tour is not running", fakeAsync(() => { + void service.completeTour(); + tick(200); + navigate.mockClear(); + + void service.nextStep(); + tick(200); + + expect(navigate).not.toHaveBeenCalled(); + })); + }); + + describe("previousStep", () => { + beforeEach(fakeAsync(() => { + getUserState$.mockReturnValue(of(false)); + hasOrganizations.mockReturnValue(of(false)); + + void service.startTour(); + tick(200); + + navigate.mockClear(); + })); + + it("should not go back from the first step", fakeAsync(() => { + void service.previousStep(); + tick(200); + + expect(navigate).not.toHaveBeenCalled(); + expect(service.activeStepId()).toBe("importData"); + })); + + it("should go back to the previous step", fakeAsync(() => { + // Advance to step 2 + void service.nextStep(); + tick(200); + + expect(service.activeStepId()).toBe("addItem"); + navigate.mockClear(); + + // Go back + void service.previousStep(); + tick(200); + + expect(service.activeStepId()).toBe("importData"); + expect(navigate).toHaveBeenCalledWith(["/tools/import"]); + })); + + it("should do nothing if tour is not running", fakeAsync(() => { + void service.completeTour(); + tick(200); + navigate.mockClear(); + + void service.previousStep(); + tick(200); + + expect(navigate).not.toHaveBeenCalled(); + })); + }); + + describe("completeTour", () => { + it("should reset state and persist completion", fakeAsync(() => { + getUserState$.mockReturnValue(of(false)); + hasOrganizations.mockReturnValue(of(false)); + + void service.startTour(); + tick(200); + + expect(service.isRunning()).toBe(true); + + void service.completeTour(); + tick(200); + + expect(service.isRunning()).toBe(false); + expect(service.activeStepId()).toBeNull(); + expect(service.totalSteps()).toBe(0); + expect(setUserState).toHaveBeenCalledWith(expect.anything(), true, mockUserId); + })); + + it("should not persist if no active account", fakeAsync(() => { + getUserState$.mockReturnValue(of(false)); + hasOrganizations.mockReturnValue(of(false)); + + void service.startTour(); + tick(200); + + activeAccount$.next(null); + + void service.completeTour(); + tick(200); + + expect(setUserState).not.toHaveBeenCalled(); + })); + }); + + describe("computed signals", () => { + it("currentStepNumber returns 0 when not running", () => { + expect(service.currentStepNumber()).toBe(0); + }); + + it("totalSteps returns 0 when not running", () => { + expect(service.totalSteps()).toBe(0); + }); + + it("isRunning returns false when not running", () => { + expect(service.isRunning()).toBe(false); + }); + }); +}); diff --git a/apps/web/src/app/vault/components/coachmark/coachmark.service.ts b/apps/web/src/app/vault/components/coachmark/coachmark.service.ts new file mode 100644 index 00000000000..d9116c2b312 --- /dev/null +++ b/apps/web/src/app/vault/components/coachmark/coachmark.service.ts @@ -0,0 +1,192 @@ +import { computed, Injectable, signal } from "@angular/core"; +import { Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; +import { map } from "rxjs/operators"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { StateProvider, UserKeyDefinition, VAULT_WELCOME_DIALOG_DISK } from "@bitwarden/state"; + +import { CoachmarkStep, CoachmarkStepId, COACHMARK_STEPS } from "./coachmark-step"; + +/** State key for tracking coachmark tour completion */ +const COACHMARK_TOUR_COMPLETED_KEY = new UserKeyDefinition( + VAULT_WELCOME_DIALOG_DISK, + "coachmarkTourCompleted", + { + deserializer: (value) => value ?? false, + clearOn: [], + }, +); + +@Injectable({ + providedIn: "root", +}) +export class CoachmarkService { + /** The currently active step ID, or null if tour is not running */ + readonly activeStepId = signal(null); + + /** Current step number (1-indexed) */ + readonly currentStepNumber = computed(() => { + const activeId = this.activeStepId(); + if (!activeId) { + return 0; + } + const index = this.applicableSteps().findIndex((s) => s.id === activeId); + return index >= 0 ? index + 1 : 0; + }); + + /** Total number of steps in the tour */ + readonly totalSteps = computed(() => this.applicableSteps().length); + + /** Whether the tour is currently running */ + readonly isRunning = computed(() => this.activeStepId() !== null); + + /** The applicable steps for the current user (filtered by organization membership) */ + private readonly applicableSteps = signal([]); + + constructor( + private accountService: AccountService, + private organizationService: OrganizationService, + private stateProvider: StateProvider, + private i18nService: I18nService, + private router: Router, + ) {} + + /** + * Gets the configuration for a specific step. + */ + getStepConfig(stepId: CoachmarkStepId): CoachmarkStep | undefined { + return COACHMARK_STEPS.find((s) => s.id === stepId); + } + + /** + * Gets translated title for a step. + */ + getStepTitle(stepId: CoachmarkStepId): string { + const step = this.getStepConfig(stepId); + return step ? this.i18nService.t(step.titleKey) : ""; + } + + /** + * Gets translated description for a step. + */ + getStepDescription(stepId: CoachmarkStepId): string { + const step = this.getStepConfig(stepId); + return step ? this.i18nService.t(step.descriptionKey) : ""; + } + + /** + * Gets learn more URL for a step. + */ + getStepLearnMoreUrl(stepId: CoachmarkStepId): string | undefined { + const step = this.getStepConfig(stepId); + return step?.learnMoreUrl; + } + + /** + * Gets the position for a step's popover. + */ + getStepPosition(stepId: CoachmarkStepId): CoachmarkStep["position"] | undefined { + const step = this.getStepConfig(stepId); + return step?.position; + } + + /** + * Starts the coachmark tour if it hasn't been completed yet. + * The tour will display steps based on user type (org vs non-org). + */ + async startTour(): Promise { + if (this.isRunning()) { + return; + } + + const account = await firstValueFrom(this.accountService.activeAccount$); + if (!account) { + return; + } + + const completed = await firstValueFrom( + this.stateProvider + .getUserState$(COACHMARK_TOUR_COMPLETED_KEY, account.id) + .pipe(map((v) => v ?? false)), + ); + + if (completed) { + return; + } + + const hasOrganizations = await firstValueFrom( + this.organizationService.hasOrganizations(account.id), + ); + + const steps = COACHMARK_STEPS.filter((step) => !step.requiresOrganization || hasOrganizations); + + if (steps.length === 0) { + return; + } + + this.applicableSteps.set(steps); + await this.navigateToStep(steps[0]); + } + + /** + * Navigates to the step's route and sets it as active after navigation completes. + */ + private async navigateToStep(step: CoachmarkStep): Promise { + if (step.route) { + await this.router.navigate([step.route]); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + this.activeStepId.set(step.id); + } + + /** + * Moves to the next step in the tour, or completes if on the last step. + */ + async nextStep(): Promise { + if (!this.isRunning()) { + return; + } + + const steps = this.applicableSteps(); + const currentIndex = steps.findIndex((s) => s.id === this.activeStepId()); + + if (currentIndex >= steps.length - 1) { + await this.completeTour(); + } else { + await this.navigateToStep(steps[currentIndex + 1]); + } + } + + /** + * Moves to the previous step in the tour. + */ + async previousStep(): Promise { + if (!this.isRunning()) { + return; + } + + const steps = this.applicableSteps(); + const currentIndex = steps.findIndex((s) => s.id === this.activeStepId()); + + if (currentIndex > 0) { + await this.navigateToStep(steps[currentIndex - 1]); + } + } + + /** + * Completes the tour and persists the completion state. + */ + async completeTour(): Promise { + this.activeStepId.set(null); + this.applicableSteps.set([]); + + const account = await firstValueFrom(this.accountService.activeAccount$); + if (account) { + await this.stateProvider.setUserState(COACHMARK_TOUR_COMPLETED_KEY, true, account.id); + } + } +} diff --git a/apps/web/src/app/vault/components/coachmark/index.ts b/apps/web/src/app/vault/components/coachmark/index.ts new file mode 100644 index 00000000000..9ead1fafd97 --- /dev/null +++ b/apps/web/src/app/vault/components/coachmark/index.ts @@ -0,0 +1,3 @@ +export { CoachmarkService } from "./coachmark.service"; +export { CoachmarkComponent } from "./coachmark.component"; +export { CoachmarkStep, CoachmarkStepId, COACHMARK_STEPS } from "./coachmark-step"; diff --git a/apps/web/src/app/vault/components/vault-welcome-dialog/vault-welcome-dialog.component.spec.ts b/apps/web/src/app/vault/components/vault-welcome-dialog/vault-welcome-dialog.component.spec.ts index bc0142b374d..784f3c7936a 100644 --- a/apps/web/src/app/vault/components/vault-welcome-dialog/vault-welcome-dialog.component.spec.ts +++ b/apps/web/src/app/vault/components/vault-welcome-dialog/vault-welcome-dialog.component.spec.ts @@ -7,6 +7,8 @@ import { UserId } from "@bitwarden/common/types/guid"; import { DialogRef } from "@bitwarden/components"; import { StateProvider } from "@bitwarden/state"; +import { CoachmarkService } from "../coachmark/coachmark.service"; + import { VaultWelcomeDialogComponent, VaultWelcomeDialogResult, @@ -22,6 +24,7 @@ describe("VaultWelcomeDialogComponent", () => { } as Account); const setUserState = jest.fn().mockResolvedValue([mockUserId, true]); const close = jest.fn(); + const startTour = jest.fn().mockResolvedValue(undefined); beforeEach(async () => { jest.clearAllMocks(); @@ -33,6 +36,7 @@ describe("VaultWelcomeDialogComponent", () => { { provide: StateProvider, useValue: { setUserState } }, { provide: DialogRef, useValue: { close } }, { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: CoachmarkService, useValue: { startTour } }, ], }).compileComponents(); @@ -76,6 +80,22 @@ describe("VaultWelcomeDialogComponent", () => { expect(close).toHaveBeenCalledWith(VaultWelcomeDialogResult.GetStarted); }); + it("should start the coachmark tour after closing", async () => { + activeAccount$.next({ id: mockUserId } as Account); + + await component["onPrimaryCta"](); + + expect(startTour).toHaveBeenCalled(); + }); + + it("should not start the coachmark tour on dismiss", async () => { + activeAccount$.next({ id: mockUserId } as Account); + + await component["onDismiss"](); + + expect(startTour).not.toHaveBeenCalled(); + }); + it("should throw if no active account", async () => { activeAccount$.next(null); diff --git a/apps/web/src/app/vault/components/vault-welcome-dialog/vault-welcome-dialog.component.ts b/apps/web/src/app/vault/components/vault-welcome-dialog/vault-welcome-dialog.component.ts index 0d6b2248b57..af14d68d60a 100644 --- a/apps/web/src/app/vault/components/vault-welcome-dialog/vault-welcome-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-welcome-dialog/vault-welcome-dialog.component.ts @@ -15,6 +15,8 @@ import { } from "@bitwarden/components"; import { StateProvider, UserKeyDefinition, VAULT_WELCOME_DIALOG_DISK } from "@bitwarden/state"; +import { CoachmarkService } from "../coachmark/coachmark.service"; + export const VaultWelcomeDialogResult = { Dismissed: "dismissed", GetStarted: "getStarted", @@ -42,6 +44,7 @@ const VAULT_WELCOME_DIALOG_ACKNOWLEDGED_KEY = new UserKeyDefinition( export class VaultWelcomeDialogComponent { private readonly accountService = inject(AccountService); private readonly stateProvider = inject(StateProvider); + private readonly coachmarkService = inject(CoachmarkService); constructor(private readonly dialogRef: DialogRef) {} @@ -53,6 +56,7 @@ export class VaultWelcomeDialogComponent { protected async onPrimaryCta(): Promise { await this.setAcknowledged(); this.dialogRef.close(VaultWelcomeDialogResult.GetStarted); + await this.coachmarkService.startTour(); } private async setAcknowledged(): Promise { diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.html b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.html index 640bf1bb653..999a351854f 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.html @@ -32,7 +32,12 @@
- + +
diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html b/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html index 66f14dcf2f6..5d4021b8c77 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html @@ -1,6 +1,23 @@ -
+ + + + +
diff --git a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts index df89238f966..c3d6d4bcff3 100644 --- a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts @@ -1,5 +1,13 @@ import { CommonModule } from "@angular/common"; -import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from "@angular/core"; +import { + ChangeDetectionStrategy, + Component, + computed, + EventEmitter, + inject, + Input, + Output, +} from "@angular/core"; import { Router } from "@angular/router"; import { firstValueFrom, switchMap } from "rxjs"; @@ -29,6 +37,7 @@ import { NewCipherMenuComponent, All, RoutedVaultFilterModel } from "@bitwarden/ import { CollectionDialogTabType } from "../../../admin-console/organizations/shared/components/collection-dialog"; import { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared"; +import { CoachmarkComponent, CoachmarkService } from "../../components/coachmark"; import { PipesModule } from "../pipes/pipes.module"; @Component({ @@ -43,6 +52,7 @@ import { PipesModule } from "../pipes/pipes.module"; PipesModule, JslibModule, NewCipherMenuComponent, + CoachmarkComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, }) @@ -52,6 +62,13 @@ export class VaultHeaderComponent { protected readonly CollectionDialogTabType = CollectionDialogTabType; protected readonly CipherType = CipherType; + protected readonly coachmarkService = inject(CoachmarkService); + + /** Computed signal for add item coachmark open state */ + protected readonly addItemCoachmarkOpen = computed( + () => this.coachmarkService.activeStepId() === "addItem", + ); + /** * Boolean to determine the loading state of the header. * Shows a loading spinner if set to true diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index dfe86618966..60f9c5dadb1 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -13048,5 +13048,45 @@ "example": "Jan 1, 1970" } } + }, + "of": { + "message": "of" + }, + "coachmarkImportTitle": { + "message": "Quickly import your passwords" + }, + "coachmarkImportDescription": { + "message": "Did you have a password manager before? You can import a CSV of existing logins and other data into your vault." + }, + "coachmarkAddItemTitle": { + "message": "Add an item" + }, + "coachmarkAddItemDescription": { + "message": "Add new passwords, notes, and other info to your vault. Everything is protected with end-to-end encryption." + }, + "coachmarkShareWithCollectionsTitle": { + "message": "Safely share items with others" + }, + "coachmarkShareWithCollectionsDescription": { + "message": "By moving an item into a collection, you can securely share it with other people in your organization." + }, + "coachmarkMonitorSecurityTitle": { + "message": "Monitor your password security" + }, + "coachmarkMonitorSecurityDescription": { + "message": "Keep the security of your vault items strong by checking reports for vulnerabilities." + }, + "coachmarkStepIndicator": { + "message": "Step $CURRENT$ of $TOTAL$", + "placeholders": { + "current": { + "content": "$1", + "example": "1" + }, + "total": { + "content": "$2", + "example": "4" + } + } } } diff --git a/libs/components/src/popover/popover-anchor-for.directive.ts b/libs/components/src/popover/popover-anchor-for.directive.ts index 27c0d003734..6792e974370 100644 --- a/libs/components/src/popover/popover-anchor-for.directive.ts +++ b/libs/components/src/popover/popover-anchor-for.directive.ts @@ -57,6 +57,8 @@ export class PopoverAnchorForDirective implements OnDestroy { /** The popover component to display */ readonly popover = input.required({ alias: "bitPopoverAnchorFor" }); + readonly closeOnBackdropClick = input(true); + /** Preferred popover position (e.g., "right-start", "below-center") */ readonly position = input(); @@ -171,8 +173,10 @@ export class PopoverAnchorForDirective implements OnDestroy { const detachments = this.overlayRef.detachments(); const escKey = this.overlayRef .keydownEvents() - .pipe(filter((event: KeyboardEvent) => event.key === "Escape")); - const backdrop = this.overlayRef.backdropClick().pipe(filter(() => !this.spotlight())); + .pipe(filter((event: KeyboardEvent) => event.key === "Escape" && !this.spotlight())); + const backdrop = this.overlayRef + .backdropClick() + .pipe(filter(() => !this.spotlight() && this.closeOnBackdropClick())); const popoverClosed = this.popover().closed; return detachments.pipe(mergeWith(escKey, backdrop, popoverClosed)); diff --git a/libs/components/src/tw-theme.css b/libs/components/src/tw-theme.css index 1327c5d0476..98ecb8a76a9 100644 --- a/libs/components/src/tw-theme.css +++ b/libs/components/src/tw-theme.css @@ -517,7 +517,7 @@ /* Hover & Overlay */ --color-bg-hover: rgba(var(--color-white-rgb), 0.05); - --color-bg-overlay: rgba(var(--color-gray-950-rgb), 0.85); + --color-bg-overlay: rgba(var(--color-gray-950-rgb), 0.65); /* ======================================== * SEMANTIC BORDER COLORS (Dark Mode Overrides) diff --git a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html index d816b69bc58..5b70b432656 100644 --- a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html +++ b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html @@ -8,6 +8,11 @@ (click)="handleButtonClick()" id="newItemDropdown" [appA11yTitle]="getButtonLabel() | i18n" + [bitPopoverAnchorFor]="coachmarkPopover()" + [popoverOpen]="coachmarkPopoverOpen()" + [position]="coachmarkPosition()" + [spotlight]="!!coachmarkPopover()" + [closeOnBackdropClick]="false" > {{ getButtonLabel() | i18n }} diff --git a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts index 1a592809691..76204a131aa 100644 --- a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts +++ b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts @@ -7,7 +7,13 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { CipherType } from "@bitwarden/common/vault/enums"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items"; -import { ButtonModule, MenuModule } from "@bitwarden/components"; +import { + ButtonModule, + MenuModule, + PopoverComponent, + PopoverModule, + PositionIdentifier, +} from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -15,7 +21,7 @@ import { I18nPipe } from "@bitwarden/ui-common"; @Component({ selector: "vault-new-cipher-menu", templateUrl: "new-cipher-menu.component.html", - imports: [ButtonModule, CommonModule, MenuModule, I18nPipe, JslibModule], + imports: [ButtonModule, CommonModule, MenuModule, PopoverModule, I18nPipe, JslibModule], }) export class NewCipherMenuComponent { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals @@ -30,6 +36,14 @@ export class NewCipherMenuComponent { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals canCreateSshKey = input(false); + + /** Optional popover to anchor to the "New" button for coachmark tours */ + readonly coachmarkPopover = input(); + /** Whether the coachmark popover is open */ + readonly coachmarkPopoverOpen = input(false); + /** Popover position */ + readonly coachmarkPosition = input(); + folderAdded = output(); collectionAdded = output(); cipherAdded = output();