mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 22:33:35 +00:00
[PM-24722][PM-27695] - add persistent callout in settings for non-premium users (#17246)
* add persistent callout in settings for non-premium users * remove premium v2 component * add spec * remove premium-v2.component.html * fix title * fix typo * conditionally render h2 * re-add pemiumv2component. change class prop to observable * change from bold to semibold * remove unecessary tw classes. use transform: booleanAttribute * add spotlight specs * code cleanup
This commit is contained in:
@@ -4902,6 +4902,9 @@
|
|||||||
"premium": {
|
"premium": {
|
||||||
"message": "Premium"
|
"message": "Premium"
|
||||||
},
|
},
|
||||||
|
"unlockFeaturesWithPremium": {
|
||||||
|
"message": "Unlock reporting, emergency access, and more security features with Premium."
|
||||||
|
},
|
||||||
"freeOrgsCannotUseAttachments": {
|
"freeOrgsCannotUseAttachments": {
|
||||||
"message": "Free organizations cannot use attachments"
|
"message": "Free organizations cannot use attachments"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
<popup-page>
|
<popup-page>
|
||||||
|
<bit-spotlight *ngIf="!(hasPremium$ | async)" persistent>
|
||||||
|
<span class="tw-text-xs"
|
||||||
|
>{{ "unlockFeaturesWithPremium" | i18n }}
|
||||||
|
<button
|
||||||
|
bitLink
|
||||||
|
buttonType="primary"
|
||||||
|
class="tw-text-xs"
|
||||||
|
type="button"
|
||||||
|
(click)="openUpgradeDialog()"
|
||||||
|
[title]="'upgradeNow' | i18n"
|
||||||
|
>
|
||||||
|
{{ "upgradeNow" | i18n }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</bit-spotlight>
|
||||||
<popup-header slot="header" pageTitle="{{ 'settings' | i18n }}">
|
<popup-header slot="header" pageTitle="{{ 'settings' | i18n }}">
|
||||||
<ng-container slot="end">
|
<ng-container slot="end">
|
||||||
<app-pop-out></app-pop-out>
|
<app-pop-out></app-pop-out>
|
||||||
@@ -20,7 +35,7 @@
|
|||||||
<div class="tw-flex tw-items-center tw-justify-center">
|
<div class="tw-flex tw-items-center tw-justify-center">
|
||||||
<p class="tw-pr-2">{{ "autofill" | i18n }}</p>
|
<p class="tw-pr-2">{{ "autofill" | i18n }}</p>
|
||||||
<span
|
<span
|
||||||
*ngIf="!isBrowserAutofillSettingOverridden && (showAutofillBadge$ | async)"
|
*ngIf="!(isBrowserAutofillSettingOverridden$ | async) && (showAutofillBadge$ | async)"
|
||||||
bitBadge
|
bitBadge
|
||||||
variant="notification"
|
variant="notification"
|
||||||
[attr.aria-label]="'nudgeBadgeAria' | i18n"
|
[attr.aria-label]="'nudgeBadgeAria' | i18n"
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
|
||||||
|
import { TestBed, waitForAsync } from "@angular/core/testing";
|
||||||
|
import { RouterTestingModule } from "@angular/router/testing";
|
||||||
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
|
import { BehaviorSubject, firstValueFrom, of, Subject } from "rxjs";
|
||||||
|
|
||||||
|
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
|
||||||
|
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
|
||||||
|
import { AutofillBrowserSettingsService } from "@bitwarden/browser/autofill/services/autofill-browser-settings.service";
|
||||||
|
import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api";
|
||||||
|
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||||
|
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
|
||||||
|
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
|
import { DialogService } from "@bitwarden/components";
|
||||||
|
import { GlobalStateProvider } from "@bitwarden/state";
|
||||||
|
import { FakeGlobalStateProvider } from "@bitwarden/state-test-utils";
|
||||||
|
|
||||||
|
import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component";
|
||||||
|
|
||||||
|
import { SettingsV2Component } from "./settings-v2.component";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-current-account",
|
||||||
|
standalone: true,
|
||||||
|
template: "",
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
class CurrentAccountStubComponent {}
|
||||||
|
|
||||||
|
describe("SettingsV2Component", () => {
|
||||||
|
let account$: BehaviorSubject<Account | null>;
|
||||||
|
let mockAccountService: Partial<AccountService>;
|
||||||
|
let mockBillingState: { hasPremiumFromAnySource$: jest.Mock };
|
||||||
|
let mockNudges: {
|
||||||
|
showNudgeBadge$: jest.Mock;
|
||||||
|
dismissNudge: jest.Mock;
|
||||||
|
};
|
||||||
|
let mockAutofillSettings: {
|
||||||
|
defaultBrowserAutofillDisabled$: Subject<boolean>;
|
||||||
|
isBrowserAutofillSettingOverridden: jest.Mock<Promise<boolean>>;
|
||||||
|
};
|
||||||
|
let dialogService: MockProxy<DialogService>;
|
||||||
|
let openSpy: jest.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(waitForAsync(async () => {
|
||||||
|
dialogService = mock<DialogService>();
|
||||||
|
account$ = new BehaviorSubject<Account | null>(null);
|
||||||
|
mockAccountService = {
|
||||||
|
activeAccount$: account$ as unknown as AccountService["activeAccount$"],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockBillingState = {
|
||||||
|
hasPremiumFromAnySource$: jest.fn().mockReturnValue(of(false)),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockNudges = {
|
||||||
|
showNudgeBadge$: jest.fn().mockImplementation(() => of(false)),
|
||||||
|
dismissNudge: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockAutofillSettings = {
|
||||||
|
defaultBrowserAutofillDisabled$: new BehaviorSubject<boolean>(false),
|
||||||
|
isBrowserAutofillSettingOverridden: jest.fn().mockResolvedValue(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.spyOn(BrowserApi, "getBrowserClientVendor").mockReturnValue("Chrome");
|
||||||
|
|
||||||
|
const cfg = TestBed.configureTestingModule({
|
||||||
|
imports: [SettingsV2Component, RouterTestingModule],
|
||||||
|
providers: [
|
||||||
|
{ provide: AccountService, useValue: mockAccountService },
|
||||||
|
{ provide: BillingAccountProfileStateService, useValue: mockBillingState },
|
||||||
|
{ provide: NudgesService, useValue: mockNudges },
|
||||||
|
{ provide: AutofillBrowserSettingsService, useValue: mockAutofillSettings },
|
||||||
|
{ provide: DialogService, useValue: dialogService },
|
||||||
|
{ provide: I18nService, useValue: { t: jest.fn((key: string) => key) } },
|
||||||
|
{ provide: GlobalStateProvider, useValue: new FakeGlobalStateProvider() },
|
||||||
|
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
|
||||||
|
{ provide: AvatarService, useValue: mock<AvatarService>() },
|
||||||
|
{ provide: AuthService, useValue: mock<AuthService>() },
|
||||||
|
],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||||
|
});
|
||||||
|
|
||||||
|
TestBed.overrideComponent(SettingsV2Component, {
|
||||||
|
add: {
|
||||||
|
imports: [CurrentAccountStubComponent],
|
||||||
|
providers: [{ provide: DialogService, useValue: dialogService }],
|
||||||
|
},
|
||||||
|
remove: {
|
||||||
|
imports: [CurrentAccountComponent],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await cfg.compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function pushActiveAccount(id = "user-123"): Account {
|
||||||
|
const acct = { id } as Account;
|
||||||
|
account$.next(acct);
|
||||||
|
return acct;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("shows the premium spotlight when user does NOT have premium", async () => {
|
||||||
|
mockBillingState.hasPremiumFromAnySource$.mockReturnValue(of(false));
|
||||||
|
pushActiveAccount();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SettingsV2Component);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
const el: HTMLElement = fixture.nativeElement;
|
||||||
|
|
||||||
|
expect(el.querySelector("bit-spotlight")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the premium spotlight when user HAS premium", async () => {
|
||||||
|
mockBillingState.hasPremiumFromAnySource$.mockReturnValue(of(true));
|
||||||
|
pushActiveAccount();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SettingsV2Component);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
const el: HTMLElement = fixture.nativeElement;
|
||||||
|
expect(el.querySelector("bit-spotlight")).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("openUpgradeDialog calls PremiumUpgradeDialogComponent.open with the DialogService", async () => {
|
||||||
|
openSpy = jest.spyOn(PremiumUpgradeDialogComponent, "open").mockImplementation();
|
||||||
|
mockBillingState.hasPremiumFromAnySource$.mockReturnValue(of(false));
|
||||||
|
pushActiveAccount();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SettingsV2Component);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
component["openUpgradeDialog"]();
|
||||||
|
expect(openSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(openSpy).toHaveBeenCalledWith(dialogService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isBrowserAutofillSettingOverridden$ emits the value from the AutofillBrowserSettingsService", async () => {
|
||||||
|
pushActiveAccount();
|
||||||
|
|
||||||
|
mockAutofillSettings.isBrowserAutofillSettingOverridden.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SettingsV2Component);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
const value = await firstValueFrom(component["isBrowserAutofillSettingOverridden$"]);
|
||||||
|
expect(value).toBe(true);
|
||||||
|
|
||||||
|
mockAutofillSettings.isBrowserAutofillSettingOverridden.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const fixture2 = TestBed.createComponent(SettingsV2Component);
|
||||||
|
const component2 = fixture2.componentInstance;
|
||||||
|
fixture2.detectChanges();
|
||||||
|
await fixture2.whenStable();
|
||||||
|
|
||||||
|
const value2 = await firstValueFrom(component2["isBrowserAutofillSettingOverridden$"]);
|
||||||
|
expect(value2).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("showAutofillBadge$ emits true when default autofill is NOT disabled and nudge is true", async () => {
|
||||||
|
pushActiveAccount();
|
||||||
|
|
||||||
|
mockNudges.showNudgeBadge$.mockImplementation((type: NudgeType) =>
|
||||||
|
of(type === NudgeType.AutofillNudge),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SettingsV2Component);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
mockAutofillSettings.defaultBrowserAutofillDisabled$.next(false);
|
||||||
|
|
||||||
|
const value = await firstValueFrom(component.showAutofillBadge$);
|
||||||
|
expect(value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("showAutofillBadge$ emits false when default autofill IS disabled even if nudge is true", async () => {
|
||||||
|
pushActiveAccount();
|
||||||
|
|
||||||
|
mockNudges.showNudgeBadge$.mockImplementation((type: NudgeType) =>
|
||||||
|
of(type === NudgeType.AutofillNudge),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SettingsV2Component);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
mockAutofillSettings.defaultBrowserAutofillDisabled$.next(true);
|
||||||
|
|
||||||
|
const value = await firstValueFrom(component.showAutofillBadge$);
|
||||||
|
expect(value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismissBadge dismisses when showVaultBadge$ emits true", async () => {
|
||||||
|
const acct = pushActiveAccount();
|
||||||
|
|
||||||
|
mockNudges.showNudgeBadge$.mockImplementation((type: NudgeType) => {
|
||||||
|
return of(type === NudgeType.EmptyVaultNudge);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SettingsV2Component);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
await component.dismissBadge(NudgeType.EmptyVaultNudge);
|
||||||
|
|
||||||
|
expect(mockNudges.dismissNudge).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockNudges.dismissNudge).toHaveBeenCalledWith(NudgeType.EmptyVaultNudge, acct.id, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismissBadge does nothing when showVaultBadge$ emits false", async () => {
|
||||||
|
pushActiveAccount();
|
||||||
|
|
||||||
|
mockNudges.showNudgeBadge$.mockReturnValue(of(false));
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SettingsV2Component);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
await component.dismissBadge(NudgeType.EmptyVaultNudge);
|
||||||
|
|
||||||
|
expect(mockNudges.dismissNudge).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("showDownloadBitwardenNudge$ proxies to nudges service for the active account", async () => {
|
||||||
|
const acct = pushActiveAccount("user-xyz");
|
||||||
|
|
||||||
|
mockNudges.showNudgeBadge$.mockImplementation((type: NudgeType) =>
|
||||||
|
of(type === NudgeType.DownloadBitwarden),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SettingsV2Component);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
const val = await firstValueFrom(component.showDownloadBitwardenNudge$);
|
||||||
|
expect(val).toBe(true);
|
||||||
|
expect(mockNudges.showNudgeBadge$).toHaveBeenCalledWith(NudgeType.DownloadBitwarden, acct.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,21 +1,31 @@
|
|||||||
import { CommonModule } from "@angular/common";
|
import { CommonModule } from "@angular/common";
|
||||||
import { Component, OnInit } from "@angular/core";
|
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||||
import { RouterModule } from "@angular/router";
|
import { RouterModule } from "@angular/router";
|
||||||
import {
|
import {
|
||||||
combineLatest,
|
combineLatest,
|
||||||
filter,
|
filter,
|
||||||
firstValueFrom,
|
firstValueFrom,
|
||||||
|
from,
|
||||||
map,
|
map,
|
||||||
Observable,
|
Observable,
|
||||||
shareReplay,
|
shareReplay,
|
||||||
switchMap,
|
switchMap,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
|
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
|
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
|
||||||
|
import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component";
|
||||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { BadgeComponent, ItemModule } from "@bitwarden/components";
|
import {
|
||||||
|
BadgeComponent,
|
||||||
|
DialogService,
|
||||||
|
ItemModule,
|
||||||
|
LinkModule,
|
||||||
|
TypographyModule,
|
||||||
|
} from "@bitwarden/components";
|
||||||
|
|
||||||
import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component";
|
import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component";
|
||||||
import { AutofillBrowserSettingsService } from "../../../autofill/services/autofill-browser-settings.service";
|
import { AutofillBrowserSettingsService } from "../../../autofill/services/autofill-browser-settings.service";
|
||||||
@@ -24,8 +34,6 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp
|
|||||||
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
|
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
|
||||||
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
|
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
|
||||||
|
|
||||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
|
||||||
@Component({
|
@Component({
|
||||||
templateUrl: "settings-v2.component.html",
|
templateUrl: "settings-v2.component.html",
|
||||||
imports: [
|
imports: [
|
||||||
@@ -38,18 +46,30 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
|
|||||||
ItemModule,
|
ItemModule,
|
||||||
CurrentAccountComponent,
|
CurrentAccountComponent,
|
||||||
BadgeComponent,
|
BadgeComponent,
|
||||||
|
SpotlightComponent,
|
||||||
|
TypographyModule,
|
||||||
|
LinkModule,
|
||||||
],
|
],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class SettingsV2Component implements OnInit {
|
export class SettingsV2Component {
|
||||||
NudgeType = NudgeType;
|
NudgeType = NudgeType;
|
||||||
activeUserId: UserId | null = null;
|
|
||||||
protected isBrowserAutofillSettingOverridden = false;
|
protected isBrowserAutofillSettingOverridden$ = from(
|
||||||
|
this.autofillBrowserSettingsService.isBrowserAutofillSettingOverridden(
|
||||||
|
BrowserApi.getBrowserClientVendor(window),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
private authenticatedAccount$: Observable<Account> = this.accountService.activeAccount$.pipe(
|
private authenticatedAccount$: Observable<Account> = this.accountService.activeAccount$.pipe(
|
||||||
filter((account): account is Account => account !== null),
|
filter((account): account is Account => account !== null),
|
||||||
shareReplay({ bufferSize: 1, refCount: true }),
|
shareReplay({ bufferSize: 1, refCount: true }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
protected hasPremium$ = this.authenticatedAccount$.pipe(
|
||||||
|
switchMap((account) => this.accountProfileStateService.hasPremiumFromAnySource$(account.id)),
|
||||||
|
);
|
||||||
|
|
||||||
showDownloadBitwardenNudge$: Observable<boolean> = this.authenticatedAccount$.pipe(
|
showDownloadBitwardenNudge$: Observable<boolean> = this.authenticatedAccount$.pipe(
|
||||||
switchMap((account) =>
|
switchMap((account) =>
|
||||||
this.nudgesService.showNudgeBadge$(NudgeType.DownloadBitwarden, account.id),
|
this.nudgesService.showNudgeBadge$(NudgeType.DownloadBitwarden, account.id),
|
||||||
@@ -79,13 +99,12 @@ export class SettingsV2Component implements OnInit {
|
|||||||
private readonly nudgesService: NudgesService,
|
private readonly nudgesService: NudgesService,
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly autofillBrowserSettingsService: AutofillBrowserSettingsService,
|
private readonly autofillBrowserSettingsService: AutofillBrowserSettingsService,
|
||||||
|
private readonly accountProfileStateService: BillingAccountProfileStateService,
|
||||||
|
private readonly dialogService: DialogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ngOnInit() {
|
protected openUpgradeDialog() {
|
||||||
this.isBrowserAutofillSettingOverridden =
|
PremiumUpgradeDialogComponent.open(this.dialogService);
|
||||||
await this.autofillBrowserSettingsService.isBrowserAutofillSettingOverridden(
|
|
||||||
BrowserApi.getBrowserClientVendor(window),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async dismissBadge(type: NudgeType) {
|
async dismissBadge(type: NudgeType) {
|
||||||
|
|||||||
@@ -6,12 +6,6 @@
|
|||||||
</popup-header>
|
</popup-header>
|
||||||
|
|
||||||
<bit-item-group>
|
<bit-item-group>
|
||||||
<bit-item *ngIf="!(canAccessPremium$ | async)">
|
|
||||||
<a type="button" bit-item-content routerLink="/premium">
|
|
||||||
{{ "premiumMembership" | i18n }}
|
|
||||||
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
|
|
||||||
</a>
|
|
||||||
</bit-item>
|
|
||||||
<bit-item
|
<bit-item
|
||||||
*ngIf="
|
*ngIf="
|
||||||
(familySponsorshipAvailable$ | async) &&
|
(familySponsorshipAvailable$ | async) &&
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { CommonModule } from "@angular/common";
|
import { CommonModule } from "@angular/common";
|
||||||
import { Component } from "@angular/core";
|
import { Component } from "@angular/core";
|
||||||
import { RouterModule } from "@angular/router";
|
import { RouterModule } from "@angular/router";
|
||||||
import { Observable, firstValueFrom, of, switchMap } from "rxjs";
|
import { Observable, firstValueFrom, switchMap } from "rxjs";
|
||||||
|
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
|
||||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
import { DialogService, ItemModule } from "@bitwarden/components";
|
import { DialogService, ItemModule } from "@bitwarden/components";
|
||||||
|
|
||||||
@@ -32,14 +31,12 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class MoreFromBitwardenPageV2Component {
|
export class MoreFromBitwardenPageV2Component {
|
||||||
canAccessPremium$: Observable<boolean>;
|
|
||||||
protected familySponsorshipAvailable$: Observable<boolean>;
|
protected familySponsorshipAvailable$: Observable<boolean>;
|
||||||
protected isFreeFamilyPolicyEnabled$: Observable<boolean>;
|
protected isFreeFamilyPolicyEnabled$: Observable<boolean>;
|
||||||
protected hasSingleEnterpriseOrg$: Observable<boolean>;
|
protected hasSingleEnterpriseOrg$: Observable<boolean>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private dialogService: DialogService,
|
private dialogService: DialogService,
|
||||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
|
||||||
private environmentService: EnvironmentService,
|
private environmentService: EnvironmentService,
|
||||||
private organizationService: OrganizationService,
|
private organizationService: OrganizationService,
|
||||||
private familiesPolicyService: FamiliesPolicyService,
|
private familiesPolicyService: FamiliesPolicyService,
|
||||||
@@ -48,13 +45,6 @@ export class MoreFromBitwardenPageV2Component {
|
|||||||
this.familySponsorshipAvailable$ = getUserId(this.accountService.activeAccount$).pipe(
|
this.familySponsorshipAvailable$ = getUserId(this.accountService.activeAccount$).pipe(
|
||||||
switchMap((userId) => this.organizationService.familySponsorshipAvailable$(userId)),
|
switchMap((userId) => this.organizationService.familySponsorshipAvailable$(userId)),
|
||||||
);
|
);
|
||||||
this.canAccessPremium$ = this.accountService.activeAccount$.pipe(
|
|
||||||
switchMap((account) =>
|
|
||||||
account
|
|
||||||
? this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id)
|
|
||||||
: of(false),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
this.hasSingleEnterpriseOrg$ = this.familiesPolicyService.hasSingleEnterpriseOrg$();
|
this.hasSingleEnterpriseOrg$ = this.familiesPolicyService.hasSingleEnterpriseOrg$();
|
||||||
this.isFreeFamilyPolicyEnabled$ = this.familiesPolicyService.isFreeFamilyPolicyEnabled$();
|
this.isFreeFamilyPolicyEnabled$ = this.familiesPolicyService.isFreeFamilyPolicyEnabled$();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,20 +3,20 @@
|
|||||||
>
|
>
|
||||||
<div class="tw-flex tw-justify-between tw-items-start tw-flex-grow">
|
<div class="tw-flex tw-justify-between tw-items-start tw-flex-grow">
|
||||||
<div>
|
<div>
|
||||||
<h2 bitTypography="h4" class="tw-font-medium !tw-mb-1">{{ title }}</h2>
|
<h2 *ngIf="title()" bitTypography="h4" class="tw-font-medium !tw-mb-1">{{ title() }}</h2>
|
||||||
<p
|
<p
|
||||||
*ngIf="subtitle"
|
*ngIf="subtitle()"
|
||||||
class="tw-text-main tw-mb-0"
|
class="tw-text-main tw-mb-0"
|
||||||
bitTypography="body2"
|
bitTypography="body2"
|
||||||
[innerHTML]="subtitle"
|
[innerHTML]="subtitle()"
|
||||||
></p>
|
></p>
|
||||||
<ng-content *ngIf="!subtitle"></ng-content>
|
<ng-content *ngIf="!subtitle()"></ng-content>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
bitIconButton="bwi-close"
|
bitIconButton="bwi-close"
|
||||||
size="small"
|
size="small"
|
||||||
*ngIf="!persistent"
|
*ngIf="!persistent()"
|
||||||
(click)="handleDismiss()"
|
(click)="handleDismiss()"
|
||||||
class="-tw-me-2"
|
class="-tw-me-2"
|
||||||
[label]="'close' | i18n"
|
[label]="'close' | i18n"
|
||||||
@@ -28,10 +28,10 @@
|
|||||||
bitButton
|
bitButton
|
||||||
type="button"
|
type="button"
|
||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
*ngIf="buttonText"
|
*ngIf="buttonText()"
|
||||||
(click)="handleButtonClick($event)"
|
(click)="handleButtonClick($event)"
|
||||||
>
|
>
|
||||||
{{ buttonText }}
|
{{ buttonText() }}
|
||||||
<i *ngIf="buttonIcon" [ngClass]="buttonIcon" class="bwi tw-ml-1" aria-hidden="true"></i>
|
<i *ngIf="buttonIcon()" [ngClass]="buttonIcon()" class="bwi tw-ml-1" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { CommonModule } from "@angular/common";
|
||||||
|
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||||
|
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||||
|
import { By } from "@angular/platform-browser";
|
||||||
|
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
|
||||||
|
import { SpotlightComponent } from "./spotlight.component";
|
||||||
|
|
||||||
|
describe("SpotlightComponent", () => {
|
||||||
|
let fixture: ComponentFixture<SpotlightComponent>;
|
||||||
|
let component: SpotlightComponent;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [SpotlightComponent],
|
||||||
|
providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(SpotlightComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
});
|
||||||
|
|
||||||
|
function detect(): void {
|
||||||
|
fixture.detectChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should create", () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rendering when inputs are null", () => {
|
||||||
|
it("should render without crashing when inputs are null/undefined", () => {
|
||||||
|
// Explicitly drive the inputs to null to exercise template null branches
|
||||||
|
fixture.componentRef.setInput("title", null);
|
||||||
|
fixture.componentRef.setInput("subtitle", null);
|
||||||
|
fixture.componentRef.setInput("buttonText", null);
|
||||||
|
fixture.componentRef.setInput("buttonIcon", null);
|
||||||
|
// persistent has a default, but drive it as well for coverage sanity
|
||||||
|
fixture.componentRef.setInput("persistent", false);
|
||||||
|
|
||||||
|
expect(() => detect()).not.toThrow();
|
||||||
|
|
||||||
|
const root = fixture.debugElement.nativeElement as HTMLElement;
|
||||||
|
expect(root).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("close button visibility based on persistent", () => {
|
||||||
|
it("should show the close button when persistent is false", () => {
|
||||||
|
fixture.componentRef.setInput("persistent", false);
|
||||||
|
detect();
|
||||||
|
|
||||||
|
// Assumes dismiss uses bitIconButton
|
||||||
|
const dismissButton = fixture.debugElement.query(By.css("button[bitIconButton]"));
|
||||||
|
|
||||||
|
expect(dismissButton).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should hide the close button when persistent is true", () => {
|
||||||
|
fixture.componentRef.setInput("persistent", true);
|
||||||
|
detect();
|
||||||
|
|
||||||
|
const dismissButton = fixture.debugElement.query(By.css("button[bitIconButton]"));
|
||||||
|
expect(dismissButton).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("event emission", () => {
|
||||||
|
it("should emit onButtonClick when CTA button is clicked", () => {
|
||||||
|
const clickSpy = jest.fn();
|
||||||
|
component.onButtonClick.subscribe(clickSpy);
|
||||||
|
|
||||||
|
fixture.componentRef.setInput("buttonText", "Click me");
|
||||||
|
detect();
|
||||||
|
|
||||||
|
const buttonDe = fixture.debugElement.query(By.css("button[bitButton]"));
|
||||||
|
expect(buttonDe).toBeTruthy();
|
||||||
|
|
||||||
|
const event = new MouseEvent("click");
|
||||||
|
buttonDe.triggerEventHandler("click", event);
|
||||||
|
|
||||||
|
expect(clickSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(clickSpy.mock.calls[0][0]).toBeInstanceOf(MouseEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should emit onDismiss when close button is clicked", () => {
|
||||||
|
const dismissSpy = jest.fn();
|
||||||
|
component.onDismiss.subscribe(dismissSpy);
|
||||||
|
|
||||||
|
fixture.componentRef.setInput("persistent", false);
|
||||||
|
detect();
|
||||||
|
|
||||||
|
const dismissButton = fixture.debugElement.query(By.css("button[bitIconButton]"));
|
||||||
|
expect(dismissButton).toBeTruthy();
|
||||||
|
|
||||||
|
dismissButton.triggerEventHandler("click", new MouseEvent("click"));
|
||||||
|
|
||||||
|
expect(dismissSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handleButtonClick should emit via onButtonClick()", () => {
|
||||||
|
const clickSpy = jest.fn();
|
||||||
|
component.onButtonClick.subscribe(clickSpy);
|
||||||
|
|
||||||
|
const event = new MouseEvent("click");
|
||||||
|
component.handleButtonClick(event);
|
||||||
|
|
||||||
|
expect(clickSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(clickSpy.mock.calls[0][0]).toBe(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handleDismiss should emit via onDismiss()", () => {
|
||||||
|
const dismissSpy = jest.fn();
|
||||||
|
component.onDismiss.subscribe(dismissSpy);
|
||||||
|
|
||||||
|
component.handleDismiss();
|
||||||
|
|
||||||
|
expect(dismissSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("content projection behavior", () => {
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
imports: [SpotlightComponent],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<bit-spotlight>
|
||||||
|
<span class="tw-text-sm">Projected content</span>
|
||||||
|
</bit-spotlight>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
class HostWithProjectionComponent {}
|
||||||
|
|
||||||
|
let hostFixture: ComponentFixture<HostWithProjectionComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
hostFixture = TestBed.createComponent(HostWithProjectionComponent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render projected content inside the spotlight", () => {
|
||||||
|
hostFixture.detectChanges();
|
||||||
|
|
||||||
|
const projected = hostFixture.debugElement.query(By.css(".tw-text-sm"));
|
||||||
|
expect(projected).toBeTruthy();
|
||||||
|
expect(projected.nativeElement.textContent.trim()).toBe("Projected content");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("boolean attribute transform for persistent", () => {
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, SpotlightComponent],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<!-- bare persistent attribute -->
|
||||||
|
<bit-spotlight *ngIf="mode === 'bare'" persistent></bit-spotlight>
|
||||||
|
|
||||||
|
<!-- no persistent attribute -->
|
||||||
|
<bit-spotlight *ngIf="mode === 'none'"></bit-spotlight>
|
||||||
|
|
||||||
|
<!-- explicit persistent="false" -->
|
||||||
|
<bit-spotlight *ngIf="mode === 'falseStr'" persistent="false"></bit-spotlight>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
class BooleanHostComponent {
|
||||||
|
mode: "bare" | "none" | "falseStr" = "bare";
|
||||||
|
}
|
||||||
|
|
||||||
|
let boolFixture: ComponentFixture<BooleanHostComponent>;
|
||||||
|
let boolHost: BooleanHostComponent;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
boolFixture = TestBed.createComponent(BooleanHostComponent);
|
||||||
|
boolHost = boolFixture.componentInstance;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getSpotlight(): SpotlightComponent {
|
||||||
|
const de = boolFixture.debugElement.query(By.directive(SpotlightComponent));
|
||||||
|
return de.componentInstance as SpotlightComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("treats bare 'persistent' attribute as true via booleanAttribute", () => {
|
||||||
|
boolHost.mode = "bare";
|
||||||
|
boolFixture.detectChanges();
|
||||||
|
|
||||||
|
const spotlight = getSpotlight();
|
||||||
|
expect(spotlight.persistent()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses default false when 'persistent' is omitted", () => {
|
||||||
|
boolHost.mode = "none";
|
||||||
|
boolFixture.detectChanges();
|
||||||
|
|
||||||
|
const spotlight = getSpotlight();
|
||||||
|
expect(spotlight.persistent()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats persistent="false" as false', () => {
|
||||||
|
boolHost.mode = "falseStr";
|
||||||
|
boolFixture.detectChanges();
|
||||||
|
|
||||||
|
const spotlight = getSpotlight();
|
||||||
|
expect(spotlight.persistent()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,43 +1,28 @@
|
|||||||
import { CommonModule } from "@angular/common";
|
import { CommonModule } from "@angular/common";
|
||||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
import { booleanAttribute, ChangeDetectionStrategy, Component, input, output } from "@angular/core";
|
||||||
|
|
||||||
import { ButtonModule, IconButtonModule, TypographyModule } from "@bitwarden/components";
|
import { ButtonModule, IconButtonModule, TypographyModule } from "@bitwarden/components";
|
||||||
import { I18nPipe } from "@bitwarden/ui-common";
|
import { I18nPipe } from "@bitwarden/ui-common";
|
||||||
|
|
||||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "bit-spotlight",
|
selector: "bit-spotlight",
|
||||||
templateUrl: "spotlight.component.html",
|
templateUrl: "spotlight.component.html",
|
||||||
imports: [ButtonModule, CommonModule, IconButtonModule, I18nPipe, TypographyModule],
|
imports: [ButtonModule, CommonModule, IconButtonModule, I18nPipe, TypographyModule],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class SpotlightComponent {
|
export class SpotlightComponent {
|
||||||
// The title of the component
|
// The title of the component
|
||||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
readonly title = input<string>();
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
|
||||||
@Input({ required: true }) title: string | null = null;
|
|
||||||
// The subtitle of the component
|
// The subtitle of the component
|
||||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
readonly subtitle = input<string>();
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
|
||||||
@Input() subtitle?: string | null = null;
|
|
||||||
// The text to display on the button
|
// The text to display on the button
|
||||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
readonly buttonText = input<string>();
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
// Whether the component can be dismissed, if true, the component will not show a close button
|
||||||
@Input() buttonText?: string;
|
readonly persistent = input(false, { transform: booleanAttribute });
|
||||||
// Wheter the component can be dismissed, if true, the component will not show a close button
|
|
||||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
|
||||||
@Input() persistent = false;
|
|
||||||
// Optional icon to display on the button
|
// Optional icon to display on the button
|
||||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
readonly buttonIcon = input<string>();
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
readonly onDismiss = output<void>();
|
||||||
@Input() buttonIcon: string | null = null;
|
readonly onButtonClick = output<MouseEvent>();
|
||||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
|
||||||
@Output() onDismiss = new EventEmitter<void>();
|
|
||||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
|
||||||
@Output() onButtonClick = new EventEmitter();
|
|
||||||
|
|
||||||
handleButtonClick(event: MouseEvent): void {
|
handleButtonClick(event: MouseEvent): void {
|
||||||
this.onButtonClick.emit(event);
|
this.onButtonClick.emit(event);
|
||||||
|
|||||||
Reference in New Issue
Block a user