mirror of
https://github.com/bitwarden/clients.git
synced 2026-07-01 21:10:49 +08:00
[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:
parent
748ef081a6
commit
f5300ab54c
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
},
|
||||
];
|
||||
@ -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>
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
192
apps/web/src/app/vault/components/coachmark/coachmark.service.ts
Normal file
192
apps/web/src/app/vault/components/coachmark/coachmark.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
apps/web/src/app/vault/components/coachmark/index.ts
Normal file
3
apps/web/src/app/vault/components/coachmark/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { CoachmarkService } from "./coachmark.service";
|
||||
export { CoachmarkComponent } from "./coachmark.component";
|
||||
export { CoachmarkStep, CoachmarkStepId, COACHMARK_STEPS } from "./coachmark-step";
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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>();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user