1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 13:23:34 +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:
Jordan Aasen
2025-11-24 13:49:05 -08:00
committed by GitHub
parent 883ff8968e
commit 43fd99b002
9 changed files with 537 additions and 63 deletions

View File

@@ -3,20 +3,20 @@
>
<div class="tw-flex tw-justify-between tw-items-start tw-flex-grow">
<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
*ngIf="subtitle"
*ngIf="subtitle()"
class="tw-text-main tw-mb-0"
bitTypography="body2"
[innerHTML]="subtitle"
[innerHTML]="subtitle()"
></p>
<ng-content *ngIf="!subtitle"></ng-content>
<ng-content *ngIf="!subtitle()"></ng-content>
</div>
<button
type="button"
bitIconButton="bwi-close"
size="small"
*ngIf="!persistent"
*ngIf="!persistent()"
(click)="handleDismiss()"
class="-tw-me-2"
[label]="'close' | i18n"
@@ -28,10 +28,10 @@
bitButton
type="button"
buttonType="primary"
*ngIf="buttonText"
*ngIf="buttonText()"
(click)="handleButtonClick($event)"
>
{{ buttonText }}
<i *ngIf="buttonIcon" [ngClass]="buttonIcon" class="bwi tw-ml-1" aria-hidden="true"></i>
{{ buttonText() }}
<i *ngIf="buttonIcon()" [ngClass]="buttonIcon()" class="bwi tw-ml-1" aria-hidden="true"></i>
</button>
</div>

View File

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

View File

@@ -1,43 +1,28 @@
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 { 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({
selector: "bit-spotlight",
templateUrl: "spotlight.component.html",
imports: [ButtonModule, CommonModule, IconButtonModule, I18nPipe, TypographyModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SpotlightComponent {
// The title of the component
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ required: true }) title: string | null = null;
readonly title = input<string>();
// The subtitle of the component
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() subtitle?: string | null = null;
readonly subtitle = input<string>();
// The text to display on the button
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() buttonText?: string;
// 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;
readonly buttonText = input<string>();
// Whether the component can be dismissed, if true, the component will not show a close button
readonly persistent = input(false, { transform: booleanAttribute });
// Optional icon to display on the button
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() buttonIcon: string | null = null;
// 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();
readonly buttonIcon = input<string>();
readonly onDismiss = output<void>();
readonly onButtonClick = output<MouseEvent>();
handleButtonClick(event: MouseEvent): void {
this.onButtonClick.emit(event);