1
0
mirror of https://github.com/bitwarden/browser synced 2026-03-01 02:51:24 +00:00

[PM-31060] Product Update Notification (#19027)

* add feature flag

* temp

* add ping animation with filled info icon

* add ping animation to stop after 4 goes around

* add local state for autofill-icon

* add logic to avoid new accounts

* fix closing of popover

* fix strict typings

* remove `creationDate` logic from being considered for autofill notification

* remove "now," from the autofill description

* remove height and width in the svg
This commit is contained in:
Nick Krantz
2026-02-24 13:42:45 -06:00
committed by GitHub
parent 4bcdd08ab8
commit 0a1baa7e42
10 changed files with 309 additions and 3 deletions

View File

@@ -6151,6 +6151,15 @@
"searchResults": {
"message": "Search results"
},
"simplifiedAutofill": {
"message": "Simplified autofill"
},
"simplifiedAutofillDescription": {
"message": "When you click a suggested autofill item, it fills rather than taking you to details. You can still view these items from the More menu."
},
"openSimplifiedAutofillPopover": {
"message": "Open simplified autofill popover"
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},

View File

@@ -10,4 +10,6 @@
[disableDescriptionMargin]="showEmptyAutofillTip$ | async"
[groupByType]="groupByType()"
[primaryActionAutofill]="clickItemsToAutofillVaultView$ | async"
></app-vault-list-items-container>
>
<app-simplified-autofill-info slot="title-end"></app-simplified-autofill-info>
</app-vault-list-items-container>

View File

@@ -7,12 +7,13 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherViewLikeUtils } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { IconButtonModule, TypographyModule } from "@bitwarden/components";
import { TypographyModule } from "@bitwarden/components";
import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils";
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
import { VaultPopupItemsService } from "../../../services/vault-popup-items.service";
import { PopupCipherViewLike } from "../../../views/popup-cipher.view";
import { SimplifiedAutofillInfoComponent } from "../simplified-autofill-info/simplified-autofill-info.component";
import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -23,7 +24,7 @@ import { VaultListItemsContainerComponent } from "../vault-list-items-container/
TypographyModule,
VaultListItemsContainerComponent,
JslibModule,
IconButtonModule,
SimplifiedAutofillInfoComponent,
],
selector: "app-autofill-vault-list-items",
templateUrl: "autofill-vault-list-items.component.html",

View File

@@ -0,0 +1,40 @@
@if (shouldShowIcon$ | async) {
<button
type="button"
class="tw-block tw-border-none tw-p-0 tw-ms-0.5"
[bitPopoverTriggerFor]="popover"
[position]="'below-center'"
#triggerRef="popoverTrigger"
[appA11yTitle]="'openSimplifiedAutofillPopover' | i18n"
bitLink
>
<div class="tw-size-[0.825rem] tw-items-center tw-relative">
@if (shouldShowPingAnimation$ | async) {
<span
#pingElement
class="tw-content-[''] tw-absolute tw-inset-0 tw-rounded-full tw-bg-primary-600 motion-safe:tw-animate-[ping_1s_ease-in-out_4]"
></span>
}
<!-- :before element is to create a background that matches the page background, otherwise the ping would be visible behind the icon -->
<bit-svg
class="tw-items-center tw-relative tw-z-0 before:tw-content-[''] before:tw-absolute before:tw-inset-[15%] before:tw-size-[70%] before:tw-rounded-full before:tw-bg-background-alt before:tw-z-[-1]"
[content]="InfoFilledIcon"
aria-hidden="true"
></bit-svg>
</div>
</button>
<bit-popover [title]="'simplifiedAutofill' | i18n" #popover (closed)="onPopoverClose()">
<p>
{{ "simplifiedAutofillDescription" | i18n }}
</p>
<button
type="button"
bitButton
buttonType="primary"
(click)="triggerRef?.closePopover(); onPopoverClose()"
>
{{ "gotIt" | i18n }}
</button>
</bit-popover>
}

View File

@@ -0,0 +1,131 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { BehaviorSubject, of } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateProvider } from "@bitwarden/state";
import { SimplifiedAutofillInfoComponent } from "./simplified-autofill-info.component";
describe("SimplifiedAutofillInfoComponent", () => {
let fixture: ComponentFixture<SimplifiedAutofillInfoComponent>;
const getUserState$ = jest.fn().mockReturnValue(of(null));
const getFeatureFlag$ = jest.fn().mockReturnValue(of(true));
const activeAccount$ = new BehaviorSubject({ id: "test-user-id" });
beforeEach(async () => {
// Mock getAnimations for all span elements before any components are created
if (!HTMLSpanElement.prototype.getAnimations) {
HTMLSpanElement.prototype.getAnimations = jest.fn().mockReturnValue([]);
}
await TestBed.configureTestingModule({
imports: [SimplifiedAutofillInfoComponent],
providers: [
{ provide: I18nService, useValue: { t: (key: string) => key } },
{
provide: ConfigService,
useValue: { getFeatureFlag$ },
},
{
provide: AccountService,
useValue: { activeAccount$: activeAccount$ },
},
{
provide: StateProvider,
useValue: {
getUserState$,
getUser: jest.fn().mockReturnValue({
update: jest.fn().mockResolvedValue(undefined),
}),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(SimplifiedAutofillInfoComponent);
fixture.detectChanges();
});
it("sets pingElement to hidden when animation finishes", async () => {
const mockAnimation: Partial<Animation> & { animationName: string } = {
animationName: "tw-ping",
onfinish: null,
};
// Override the mock to return our specific animation
(HTMLSpanElement.prototype.getAnimations as jest.Mock).mockReturnValue([
mockAnimation as Animation,
]);
// Create a new fixture with fresh mocks that will show the ping animation
getUserState$.mockReturnValue(of({ hasSeen: false, hasDismissed: false }));
const newFixture = TestBed.createComponent(SimplifiedAutofillInfoComponent);
// Trigger change detection to render the template and run the effect
newFixture.detectChanges();
await newFixture.whenStable();
expect(mockAnimation.onfinish).toBeDefined();
expect(mockAnimation.onfinish).not.toBeNull();
const onfinishHandler = mockAnimation.onfinish;
await onfinishHandler.call(mockAnimation, null);
const newPingElement = newFixture.nativeElement.querySelector("span");
expect(newPingElement.hidden).toBe(true);
});
describe("shouldShowIcon$", () => {
it("renders the icon button when feature flag is enabled and not dismissed", async () => {
getUserState$.mockReturnValue(of({ hasSeen: false, hasDismissed: false }));
const newFixture = TestBed.createComponent(SimplifiedAutofillInfoComponent);
newFixture.detectChanges();
await newFixture.whenStable();
const button = newFixture.nativeElement.querySelector("button[type='button']");
expect(button).toBeTruthy();
});
it("does not render icon button when dismissed", async () => {
getFeatureFlag$.mockReturnValue(of(true));
getUserState$.mockReturnValue(of({ hasSeen: true, hasDismissed: true }));
const newFixture = TestBed.createComponent(SimplifiedAutofillInfoComponent);
newFixture.detectChanges();
await newFixture.whenStable();
const button = newFixture.nativeElement.querySelector("button[type='button']");
expect(button).toBeFalsy();
});
});
describe("shouldShowPingAnimation$", () => {
it("renders ping animation when not seen", async () => {
getUserState$.mockReturnValue(of({ hasSeen: false, hasDismissed: false }));
const newFixture = TestBed.createComponent(SimplifiedAutofillInfoComponent);
newFixture.detectChanges();
await newFixture.whenStable();
const pingElement = newFixture.nativeElement.querySelector("span.tw-bg-primary-600");
expect(pingElement).toBeTruthy();
});
it("does not render ping animation when already seen", async () => {
getUserState$.mockReturnValue(of({ hasSeen: true, hasDismissed: false }));
const newFixture = TestBed.createComponent(SimplifiedAutofillInfoComponent);
newFixture.detectChanges();
await newFixture.whenStable();
const pingElement = newFixture.nativeElement.querySelector("span.tw-bg-primary-600");
expect(pingElement).toBeFalsy();
});
});
});

View File

@@ -0,0 +1,110 @@
import { AsyncPipe } from "@angular/common";
import {
Component,
ChangeDetectionStrategy,
viewChild,
ElementRef,
inject,
effect,
} from "@angular/core";
import { combineLatest, firstValueFrom } from "rxjs";
import { map, switchMap } from "rxjs/operators";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { InfoFilledIcon } from "@bitwarden/assets/svg";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PopoverModule, IconModule, ButtonModule, SvgModule } from "@bitwarden/components";
import { StateProvider, UserKeyDefinition, VAULT_AUTOFILL_SIMPLIFIED_ICON } from "@bitwarden/state";
const VAULT_AUTOFILL_SIMPLIFIED_ICON_KEY = new UserKeyDefinition<{
hasSeen: boolean;
hasDismissed: boolean;
}>(VAULT_AUTOFILL_SIMPLIFIED_ICON, "vaultAutofillSimplifiedIcon", {
deserializer: (value) => value,
clearOn: [],
});
@Component({
selector: "app-simplified-autofill-info",
templateUrl: "./simplified-autofill-info.component.html",
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [JslibModule, PopoverModule, IconModule, ButtonModule, SvgModule, AsyncPipe],
})
export class SimplifiedAutofillInfoComponent {
private configService = inject(ConfigService);
private stateProvider = inject(StateProvider);
private accountService = inject(AccountService);
readonly pingElement = viewChild<ElementRef<HTMLSpanElement>>("pingElement");
protected readonly InfoFilledIcon = InfoFilledIcon;
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
private vaultAutofillSimplifiedIconState$ = this.userId$.pipe(
switchMap((userId) =>
this.stateProvider.getUserState$(VAULT_AUTOFILL_SIMPLIFIED_ICON_KEY, userId),
),
);
protected shouldShowPingAnimation$ = this.vaultAutofillSimplifiedIconState$.pipe(
map((state) => !state?.hasSeen),
);
/** Emits true when the icon should be shown to the user */
protected shouldShowIcon$ = combineLatest([
this.configService.getFeatureFlag$(FeatureFlag.PM31039ItemActionInExtension),
this.vaultAutofillSimplifiedIconState$,
]).pipe(
map(([isFeatureEnabled, state]) => {
if (!isFeatureEnabled) {
return false;
}
return !state?.hasDismissed;
}),
);
constructor() {
// Set up animation handler when ping element becomes available
effect(() => {
const pingElement = this.pingElement()?.nativeElement;
if (!pingElement) {
return;
}
const animation = pingElement
.getAnimations()
.find((a) => "animationName" in a && a.animationName === "tw-ping");
if (animation) {
animation.onfinish = () => {
// Set the ping element to hidden after the animation finishes to avoid any alignment issues with the icon.
pingElement.hidden = true;
void this.updateUserState({ hasSeen: true, hasDismissed: false });
};
}
});
}
/** Update the user state when the popover closes */
protected async onPopoverClose(): Promise<void> {
await this.updateUserState({ hasDismissed: true, hasSeen: true });
}
/** Updates the user's state for the simplified autofill icon */
private async updateUserState(newState: {
hasSeen: boolean;
hasDismissed: boolean;
}): Promise<void> {
const userId = await firstValueFrom(this.userId$);
const state = this.stateProvider.getUser(userId, VAULT_AUTOFILL_SIMPLIFIED_ICON_KEY);
await state.update((oldState) => ({
...oldState,
...newState,
}));
}
}

View File

@@ -44,6 +44,7 @@
(click)="onRefresh.emit()"
[label]="'refresh' | i18n"
></button>
<ng-content select="[slot=title-end]"></ng-content>
<span bitTypography="body2" slot="end">
<span
[ngClass]="{