[PM-29439] - Product Tour – Coachmarks (#19476)

* add coachmark

* add coachmark tour

* fixes to coachmarks

* updates to coachmarks

* add tests

* add specs

* cleanup

* merge main

* add comment
This commit is contained in:
Jordan Aasen 2026-03-12 09:58:54 -07:00 committed by GitHub
parent 748ef081a6
commit f5300ab54c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 884 additions and 16 deletions

View File

@ -6,12 +6,36 @@
@if (sendEnabled$ | async) {
<bit-nav-item icon="bwi-send" [text]="'send' | i18n" route="sends"></bit-nav-item>
}
<bit-nav-group icon="bwi-wrench" [text]="'tools' | i18n" route="tools">
<bit-nav-group
icon="bwi-wrench"
[text]="'tools' | i18n"
route="tools"
[open]="toolsNavGroupOpen()"
>
<bit-nav-item [text]="'generator' | i18n" route="tools/generator"></bit-nav-item>
<bit-nav-item [text]="'importNoun' | i18n" route="tools/import"></bit-nav-item>
<bit-nav-item
[text]="'importNoun' | i18n"
route="tools/import"
[bitPopoverAnchorFor]="importCoachmark.popover()"
[popoverOpen]="importCoachmarkOpen()"
[position]="coachmarkService.getStepPosition('importData')"
[closeOnBackdropClick]="false"
[spotlight]="true"
></bit-nav-item>
<app-coachmark #importCoachmark stepId="importData" />
<bit-nav-item [text]="'exportNoun' | i18n" route="tools/export"></bit-nav-item>
</bit-nav-group>
<bit-nav-item icon="bwi-sliders" [text]="'reports' | i18n" route="reports"></bit-nav-item>
<bit-nav-item
icon="bwi-sliders"
[text]="'reports' | i18n"
route="reports"
[bitPopoverAnchorFor]="reportsCoachmark.popover()"
[popoverOpen]="reportsCoachmarkOpen()"
[position]="coachmarkService.getStepPosition('monitorSecurity')"
[closeOnBackdropClick]="false"
[spotlight]="true"
></bit-nav-item>
<app-coachmark #reportsCoachmark stepId="monitorSecurity" />
<bit-nav-group icon="bwi-cog" [text]="'settings' | i18n" route="settings">
<bit-nav-item [text]="'myAccount' | i18n" route="settings/account"></bit-nav-item>
<bit-nav-item [text]="'security' | i18n" route="settings/security"></bit-nav-item>

View File

@ -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<string | null>;
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,

View File

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

View File

@ -0,0 +1,39 @@
<bit-popover [title]="service.getStepTitle(stepId())" (closed)="service.completeTour()">
<p bitTypography="body2" class="tw-mb-3">
{{ service.getStepDescription(stepId()) }}
</p>
@if (service.getStepLearnMoreUrl(stepId()); as url) {
<a
bitLink
linkType="primary"
[href]="url"
target="_blank"
rel="noopener noreferrer"
class="tw-mb-3 tw-flex tw-items-center tw-gap-1"
>
{{ "learnMore" | i18n }}
<i class="bwi bwi-external-link tw-text-sm" aria-hidden="true"></i>
</a>
}
<!-- Footer with navigation -->
<div class="tw-flex tw-items-center tw-justify-between tw-pt-2">
<span bitTypography="body2" class="tw-text-muted">
{{ "coachmarkStepIndicator" | i18n: service.currentStepNumber() : service.totalSteps() }}
</span>
<div class="tw-flex tw-gap-2">
@if (service.currentStepNumber() > 1) {
<button bitButton buttonType="secondary" type="button" (click)="service.previousStep()">
{{ "back" | i18n }}
</button>
}
<button bitButton buttonType="primary" type="button" (click)="service.nextStep()">
{{
service.currentStepNumber() === service.totalSteps() ? ("close" | i18n) : ("next" | i18n)
}}
</button>
</div>
</div>
</bit-popover>

View File

@ -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 `<bit-popover>` internally use `coachmark.popover()` with `[bitPopoverAnchorFor]`.
*
* @example
* ```html
* <div [bitPopoverAnchorFor]="myCoachmark.popover()" [popoverOpen]="isOpen()">
* Highlighted element
* </div>
* <app-coachmark #myCoachmark stepId="importData" />
* ```
*/
@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<CoachmarkStepId>();
/** Exposed so parent templates can bind `[bitPopoverAnchorFor]="ref.popover()"` */
readonly popover = viewChild.required(PopoverComponent);
protected readonly service = inject(CoachmarkService);
}

View File

@ -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<Account | null>;
function createAccount(overrides: Partial<Account> = {}): Account {
return {
id: mockUserId,
creationDate: new Date(),
...overrides,
} as Account;
}
beforeEach(() => {
jest.clearAllMocks();
activeAccount$ = new BehaviorSubject<Account | null>(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);
});
});
});

View File

@ -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<boolean>(
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<CoachmarkStepId | null>(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<CoachmarkStep[]>([]);
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
}

View File

@ -0,0 +1,3 @@
export { CoachmarkService } from "./coachmark.service";
export { CoachmarkComponent } from "./coachmark.component";
export { CoachmarkStep, CoachmarkStepId, COACHMARK_STEPS } from "./coachmark-step";

View File

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

View File

@ -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<boolean>(
export class VaultWelcomeDialogComponent {
private readonly accountService = inject(AccountService);
private readonly stateProvider = inject(StateProvider);
private readonly coachmarkService = inject(CoachmarkService);
constructor(private readonly dialogRef: DialogRef<VaultWelcomeDialogResult>) {}
@ -53,6 +56,7 @@ export class VaultWelcomeDialogComponent {
protected async onPrimaryCta(): Promise<void> {
await this.setAcknowledged();
this.dialogRef.close(VaultWelcomeDialogResult.GetStarted);
await this.coachmarkService.startTour();
}
private async setAcknowledged(): Promise<void> {

View File

@ -32,7 +32,12 @@
</div>
<ng-container *ngFor="let f of filtersList">
<div class="filter">
<app-filter-section [activeFilter]="activeFilter" [section]="f"> </app-filter-section>
<app-filter-section
[activeFilter]="activeFilter"
[section]="f"
[isCollectionFilter]="f === filters?.collectionFilter"
>
</app-filter-section>
</div>
</ng-container>
</div>

View File

@ -1,6 +1,23 @@
<!-- eslint-disable tailwindcss/no-custom-classname -->
<ng-container *ngIf="filters && filters.length">
<div *ngIf="headerInfo.showHeader" class="filter-heading">
<!-- Collections coachmark (only active for the collections filter section) -->
<app-coachmark #collectionsCoachmark stepId="shareWithCollections" />
<!--
Note: passing null to [bitPopoverAnchorFor] violates its input.required<PopoverComponent>() type,
but is runtime safe. Ideally the directive would be conditionally applied, but Angular doesn't support conditional
directive bindings.
-->
<div
*ngIf="headerInfo.showHeader"
class="filter-heading"
[bitPopoverAnchorFor]="isCollectionFilter ? collectionsCoachmark.popover() : null"
[popoverOpen]="isCollectionFilter ? collectionsCoachmarkOpen() : false"
[spotlight]="isCollectionFilter"
[spotlightPadding]="5"
[position]="coachmarkService.getStepPosition('shareWithCollections')"
[closeOnBackdropClick]="false"
>
<button
type="button"
class="toggle-button"

View File

@ -1,6 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, InjectionToken, Injector, Input, OnDestroy, OnInit } from "@angular/core";
import {
Component,
computed,
inject,
InjectionToken,
Injector,
Input,
OnDestroy,
OnInit,
} from "@angular/core";
import { firstValueFrom, Observable, Subject, takeUntil } from "rxjs";
import { map } from "rxjs/operators";
@ -15,6 +24,8 @@ import {
VaultFilter,
} from "@bitwarden/vault";
import { CoachmarkService } from "../../../../components/coachmark";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
@ -33,6 +44,17 @@ export class VaultFilterSectionComponent implements OnInit, OnDestroy {
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() section: VaultFilterSection;
/** Whether this section is the collection filter (enables coachmark) */
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() isCollectionFilter = false;
protected readonly coachmarkService = inject(CoachmarkService);
/** Computed signal for collections coachmark open state */
protected readonly collectionsCoachmarkOpen = computed(
() => this.coachmarkService.activeStepId() === "shareWithCollections",
);
data: TreeNode<VaultFilterType>;
collapsedFilterNodes: Set<string> = new Set();

View File

@ -1,14 +1,15 @@
import { NgModule } from "@angular/core";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { SearchModule } from "@bitwarden/components";
import { PopoverModule, SearchModule } from "@bitwarden/components";
import { SharedModule } from "../../../../shared";
import { CoachmarkComponent } from "../../../components/coachmark";
import { VaultFilterSectionComponent } from "./components/vault-filter-section.component";
@NgModule({
imports: [SharedModule, SearchModule, PremiumBadgeComponent],
imports: [SharedModule, SearchModule, PremiumBadgeComponent, PopoverModule, CoachmarkComponent],
declarations: [VaultFilterSectionComponent],
exports: [SharedModule, VaultFilterSectionComponent, SearchModule],
})

View File

@ -80,9 +80,13 @@
[canCreateFolder]="true"
[canCreateSshKey]="true"
[canCreateCollection]="canCreateCollections"
[coachmarkPopover]="addItemCoachmark.popover()"
[coachmarkPopoverOpen]="addItemCoachmarkOpen()"
[coachmarkPosition]="coachmarkService.getStepPosition('addItem')"
(cipherAdded)="addCipher($event)"
(folderAdded)="addFolder()"
(collectionAdded)="addCollection()"
/>
<app-coachmark #addItemCoachmark stepId="addItem" />
</div>
</app-header>

View File

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

View File

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

View File

@ -57,6 +57,8 @@ export class PopoverAnchorForDirective implements OnDestroy {
/** The popover component to display */
readonly popover = input.required<PopoverComponent>({ alias: "bitPopoverAnchorFor" });
readonly closeOnBackdropClick = input<boolean>(true);
/** Preferred popover position (e.g., "right-start", "below-center") */
readonly position = input<PositionIdentifier>();
@ -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));

View File

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

View File

@ -8,6 +8,11 @@
(click)="handleButtonClick()"
id="newItemDropdown"
[appA11yTitle]="getButtonLabel() | i18n"
[bitPopoverAnchorFor]="coachmarkPopover()"
[popoverOpen]="coachmarkPopoverOpen()"
[position]="coachmarkPosition()"
[spotlight]="!!coachmarkPopover()"
[closeOnBackdropClick]="false"
>
<i class="bwi bwi-plus tw-me-2" aria-hidden="true"></i>
{{ getButtonLabel() | i18n }}

View File

@ -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<PopoverComponent>();
/** Whether the coachmark popover is open */
readonly coachmarkPopoverOpen = input(false);
/** Popover position */
readonly coachmarkPosition = input<PositionIdentifier>();
folderAdded = output();
collectionAdded = output();
cipherAdded = output<CipherType>();