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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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]="{
|
||||
|
||||
Reference in New Issue
Block a user