1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-18803] nudges new items (#14523)

* Added new-items-nudge service and component to show spotlight for new item nudges
This commit is contained in:
Jason Ng
2025-05-01 12:43:55 -04:00
committed by GitHub
parent 64daf5a889
commit a62d269a89
15 changed files with 398 additions and 7 deletions

View File

@@ -34,7 +34,9 @@ import { AsyncActionsModule, ButtonModule, ItemModule, ToastService } from "@bit
import {
CipherFormConfig,
CipherFormGenerationService,
NudgeStatus,
PasswordRepromptService,
VaultNudgesService,
} from "@bitwarden/vault";
// FIXME: remove `/apps` import from `/libs`
// FIXME: remove `src` and fix import
@@ -47,6 +49,7 @@ import { CipherFormService } from "./abstractions/cipher-form.service";
import { TotpCaptureService } from "./abstractions/totp-capture.service";
import { CipherFormModule } from "./cipher-form.module";
import { CipherFormComponent } from "./components/cipher-form.component";
import { NewItemNudgeComponent } from "./components/new-item-nudge/new-item-nudge.component";
import { CipherFormCacheService } from "./services/default-cipher-form-cache.service";
const defaultConfig: CipherFormConfig = {
@@ -132,8 +135,23 @@ export default {
component: CipherFormComponent,
decorators: [
moduleMetadata({
imports: [CipherFormModule, AsyncActionsModule, ButtonModule, ItemModule],
imports: [
CipherFormModule,
AsyncActionsModule,
ButtonModule,
ItemModule,
NewItemNudgeComponent,
],
providers: [
{
provide: VaultNudgesService,
useValue: {
showNudge$: new BehaviorSubject({
hasBadgeDismissed: true,
hasSpotlightDismissed: true,
} as NudgeStatus),
},
},
{
provide: CipherFormService,
useClass: TestAddEditFormService,

View File

@@ -1,3 +1,4 @@
<vault-new-item-nudge *ngIf="!loading" [configType]="config.cipherType"> </vault-new-item-nudge>
<form [id]="formId" [formGroup]="cipherForm" [bitSubmit]="submit">
<!-- TODO: Should we show a loading spinner here? Or emit a ready event for the container to handle loading state -->
<ng-container *ngIf="!loading">

View File

@@ -45,6 +45,7 @@ import { CardDetailsSectionComponent } from "./card-details-section/card-details
import { IdentitySectionComponent } from "./identity/identity.component";
import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component";
import { LoginDetailsSectionComponent } from "./login-details-section/login-details-section.component";
import { NewItemNudgeComponent } from "./new-item-nudge/new-item-nudge.component";
import { SshKeySectionComponent } from "./sshkey-section/sshkey-section.component";
@Component({
@@ -76,6 +77,7 @@ import { SshKeySectionComponent } from "./sshkey-section/sshkey-section.componen
NgIf,
AdditionalOptionsSectionComponent,
LoginDetailsSectionComponent,
NewItemNudgeComponent,
],
})
export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, CipherFormContainer {

View File

@@ -0,0 +1,8 @@
<ng-container *ngIf="showNewItemSpotlight">
<bit-spotlight
[title]="nudgeTitle"
[subtitle]="nudgeBody"
(onDismiss)="dismissNewItemSpotlight()"
>
</bit-spotlight>
</ng-container>

View File

@@ -0,0 +1,101 @@
import { CommonModule } from "@angular/common";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/sdk-internal";
import { VaultNudgesService, VaultNudgeType } from "../../../services/vault-nudges.service";
import { NewItemNudgeComponent } from "./new-item-nudge.component";
describe("NewItemNudgeComponent", () => {
let component: NewItemNudgeComponent;
let fixture: ComponentFixture<NewItemNudgeComponent>;
let i18nService: MockProxy<I18nService>;
let accountService: MockProxy<AccountService>;
let vaultNudgesService: MockProxy<VaultNudgesService>;
beforeEach(async () => {
i18nService = mock<I18nService>({ t: (key: string) => key });
accountService = mock<AccountService>();
vaultNudgesService = mock<VaultNudgesService>();
await TestBed.configureTestingModule({
imports: [NewItemNudgeComponent, CommonModule],
providers: [
{ provide: I18nService, useValue: i18nService },
{ provide: AccountService, useValue: accountService },
{ provide: VaultNudgesService, useValue: vaultNudgesService },
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(NewItemNudgeComponent);
component = fixture.componentInstance;
component.configType = null; // Set to null for initial state
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should set nudge title and body for CipherType.Login type", async () => {
component.configType = CipherType.Login;
accountService.activeAccount$ = of({ id: "test-user-id" as UserId } as Account);
jest.spyOn(component, "checkHasSpotlightDismissed").mockResolvedValue(true);
await component.ngOnInit();
expect(component.showNewItemSpotlight).toBe(true);
expect(component.nudgeTitle).toBe("newLoginNudgeTitle");
expect(component.nudgeBody).toBe("newLoginNudgeBody");
expect(component.dismissalNudgeType).toBe(VaultNudgeType.newLoginItemStatus);
});
it("should set nudge title and body for CipherType.Card type", async () => {
component.configType = CipherType.Card;
accountService.activeAccount$ = of({ id: "test-user-id" as UserId } as Account);
jest.spyOn(component, "checkHasSpotlightDismissed").mockResolvedValue(true);
await component.ngOnInit();
expect(component.showNewItemSpotlight).toBe(true);
expect(component.nudgeTitle).toBe("newCardNudgeTitle");
expect(component.nudgeBody).toBe("newCardNudgeBody");
expect(component.dismissalNudgeType).toBe(VaultNudgeType.newCardItemStatus);
});
it("should not show anything if spotlight has been dismissed", async () => {
component.configType = CipherType.Identity;
accountService.activeAccount$ = of({ id: "test-user-id" as UserId } as Account);
jest.spyOn(component, "checkHasSpotlightDismissed").mockResolvedValue(false);
await component.ngOnInit();
expect(component.showNewItemSpotlight).toBe(false);
expect(component.dismissalNudgeType).toBe(VaultNudgeType.newIdentityItemStatus);
});
it("should set showNewItemSpotlight to false when user dismisses spotlight", async () => {
component.showNewItemSpotlight = true;
component.dismissalNudgeType = VaultNudgeType.newLoginItemStatus;
component.activeUserId = "test-user-id" as UserId;
const dismissSpy = jest.spyOn(vaultNudgesService, "dismissNudge").mockResolvedValue();
await component.dismissNewItemSpotlight();
expect(component.showNewItemSpotlight).toBe(false);
expect(dismissSpy).toHaveBeenCalledWith(
VaultNudgeType.newLoginItemStatus,
component.activeUserId,
);
});
});

View File

@@ -0,0 +1,90 @@
import { NgIf } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/sdk-internal";
import { SpotlightComponent } from "../../../components/spotlight/spotlight.component";
import { VaultNudgesService, VaultNudgeType } from "../../../services/vault-nudges.service";
@Component({
selector: "vault-new-item-nudge",
templateUrl: "./new-item-nudge.component.html",
standalone: true,
imports: [NgIf, SpotlightComponent],
})
export class NewItemNudgeComponent implements OnInit {
@Input({ required: true }) configType: CipherType | null = null;
activeUserId: UserId | null = null;
showNewItemSpotlight: boolean = false;
nudgeTitle: string = "";
nudgeBody: string = "";
dismissalNudgeType: VaultNudgeType | null = null;
constructor(
private i18nService: I18nService,
private accountService: AccountService,
private vaultNudgesService: VaultNudgesService,
) {}
async ngOnInit() {
this.activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
switch (this.configType) {
case CipherType.Login:
this.dismissalNudgeType = VaultNudgeType.newLoginItemStatus;
this.nudgeTitle = this.i18nService.t("newLoginNudgeTitle");
this.nudgeBody = this.i18nService.t("newLoginNudgeBody");
break;
case CipherType.Card:
this.dismissalNudgeType = VaultNudgeType.newCardItemStatus;
this.nudgeTitle = this.i18nService.t("newCardNudgeTitle");
this.nudgeBody = this.i18nService.t("newCardNudgeBody");
break;
case CipherType.Identity:
this.dismissalNudgeType = VaultNudgeType.newIdentityItemStatus;
this.nudgeTitle = this.i18nService.t("newIdentityNudgeTitle");
this.nudgeBody = this.i18nService.t("newIdentityNudgeBody");
break;
case CipherType.SecureNote:
this.dismissalNudgeType = VaultNudgeType.newNoteItemStatus;
this.nudgeTitle = this.i18nService.t("newNoteNudgeTitle");
this.nudgeBody = this.i18nService.t("newNoteNudgeBody");
break;
case CipherType.SshKey:
this.dismissalNudgeType = VaultNudgeType.newSshItemStatus;
this.nudgeTitle = this.i18nService.t("newSshNudgeTitle");
this.nudgeBody = this.i18nService.t("newSshNudgeBody");
break;
default:
throw new Error("Unsupported cipher type");
}
this.showNewItemSpotlight = await this.checkHasSpotlightDismissed(
this.dismissalNudgeType as VaultNudgeType,
this.activeUserId,
);
}
async dismissNewItemSpotlight() {
if (this.dismissalNudgeType && this.activeUserId) {
await this.vaultNudgesService.dismissNudge(
this.dismissalNudgeType,
this.activeUserId as UserId,
);
this.showNewItemSpotlight = false;
}
}
async checkHasSpotlightDismissed(nudgeType: VaultNudgeType, userId: UserId): Promise<boolean> {
return !(await firstValueFrom(this.vaultNudgesService.showNudge$(nudgeType, userId)))
.hasSpotlightDismissed;
}
}

View File

@@ -18,8 +18,6 @@ export class HasNudgeService extends DefaultSingleNudgeService {
private nudgeTypes: VaultNudgeType[] = [
VaultNudgeType.EmptyVaultNudge,
VaultNudgeType.HasVaultItems,
VaultNudgeType.IntroCarouselDismissal,
// add additional nudge types here as needed
];

View File

@@ -1,3 +1,4 @@
export * from "./has-items-nudge.service";
export * from "./empty-vault-nudge.service";
export * from "./has-nudge.service";
export * from "./new-item-nudge.service";

View File

@@ -0,0 +1,65 @@
import { inject, Injectable } from "@angular/core";
import { combineLatest, Observable, switchMap } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service";
/**
* Custom Nudge Service Checking Nudge Status For Vault New Item Types
*/
@Injectable({
providedIn: "root",
})
export class NewItemNudgeService extends DefaultSingleNudgeService {
cipherService = inject(CipherService);
nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([
this.getNudgeStatus$(nudgeType, userId),
this.cipherService.cipherViews$(userId),
]).pipe(
switchMap(async ([nudgeStatus, ciphers]) => {
if (nudgeStatus.hasSpotlightDismissed) {
return nudgeStatus;
}
let currentType: CipherType;
switch (nudgeType) {
case VaultNudgeType.newLoginItemStatus:
currentType = CipherType.Login;
break;
case VaultNudgeType.newCardItemStatus:
currentType = CipherType.Card;
break;
case VaultNudgeType.newIdentityItemStatus:
currentType = CipherType.Identity;
break;
case VaultNudgeType.newNoteItemStatus:
currentType = CipherType.SecureNote;
break;
case VaultNudgeType.newSshItemStatus:
currentType = CipherType.SshKey;
break;
}
const ciphersBoolean = ciphers.some((cipher) => cipher.type === currentType);
if (ciphersBoolean) {
const dismissedStatus = {
hasSpotlightDismissed: true,
hasBadgeDismissed: true,
};
await this.setNudgeStatus(nudgeType, dismissedStatus, userId);
return dismissedStatus;
}
return nudgeStatus;
}),
);
}
}

View File

@@ -5,6 +5,7 @@ import { firstValueFrom, of } from "rxjs";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FakeStateProvider, mockAccountServiceWith } from "../../../common/spec";
@@ -46,6 +47,7 @@ describe("Vault Nudges Service", () => {
provide: EmptyVaultNudgeService,
useValue: mock<EmptyVaultNudgeService>(),
},
{ provide: CipherService, useValue: mock<CipherService>() },
],
});
});

View File

@@ -6,7 +6,11 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { UserKeyDefinition, VAULT_NUDGES_DISK } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { HasItemsNudgeService, EmptyVaultNudgeService } from "./custom-nudges-services";
import {
HasItemsNudgeService,
EmptyVaultNudgeService,
NewItemNudgeService,
} from "./custom-nudges-services";
import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service";
export type NudgeStatus = {
@@ -23,7 +27,11 @@ export enum VaultNudgeType {
*/
EmptyVaultNudge = "empty-vault-nudge",
HasVaultItems = "has-vault-items",
IntroCarouselDismissal = "intro-carousel-dismissal",
newLoginItemStatus = "new-login-item-status",
newCardItemStatus = "new-card-item-status",
newIdentityItemStatus = "new-identity-item-status",
newNoteItemStatus = "new-note-item-status",
newSshItemStatus = "new-ssh-item-status",
}
export const VAULT_NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition<
@@ -37,6 +45,8 @@ export const VAULT_NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition<
providedIn: "root",
})
export class VaultNudgesService {
private newItemNudgeService = inject(NewItemNudgeService);
/**
* Custom nudge services to use for specific nudge types
* Each nudge type can have its own service to determine when to show the nudge
@@ -45,6 +55,11 @@ export class VaultNudgesService {
private customNudgeServices: any = {
[VaultNudgeType.HasVaultItems]: inject(HasItemsNudgeService),
[VaultNudgeType.EmptyVaultNudge]: inject(EmptyVaultNudgeService),
[VaultNudgeType.newLoginItemStatus]: this.newItemNudgeService,
[VaultNudgeType.newCardItemStatus]: this.newItemNudgeService,
[VaultNudgeType.newIdentityItemStatus]: this.newItemNudgeService,
[VaultNudgeType.newNoteItemStatus]: this.newItemNudgeService,
[VaultNudgeType.newSshItemStatus]: this.newItemNudgeService,
};
/**