1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-12 14:34:02 +00:00

[PM-26703] - Update Item Action Behavior for Extension (#18921)

* Revert "Revert "[PM-26703]- Browser - Update autofill Behavior (#18467)" (#18723)"

This reverts commit 5d17d9ee71.

* fix title in non-autofill list

* add feature flag

* add old logic. add specs

* revert changes

* remove comments

* update language in spec

* update appearance spec

* revert change to security-tasks

* fix logic for blocked uri. add deprecated notice.

* fix test

* fix type error
This commit is contained in:
Jordan Aasen
2026-02-11 16:54:05 -08:00
committed by GitHub
parent 11e2b25ede
commit 396286ff9a
10 changed files with 558 additions and 118 deletions

View File

@@ -5,8 +5,9 @@
[showRefresh]="showRefresh"
(onRefresh)="refreshCurrentTab()"
[description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : undefined"
showAutofillButton
isAutofillList
[disableDescriptionMargin]="showEmptyAutofillTip$ | async"
[primaryActionAutofill]="clickItemsToAutofillVaultView$ | async"
[groupByType]="groupByType()"
[showAutofillButton]="(clickItemsToAutofillVaultView$ | async) === false"
[primaryActionAutofill]="clickItemsToAutofillVaultView$ | async"
></app-vault-list-items-container>

View File

@@ -8,18 +8,18 @@
></button>
<bit-menu #moreOptions>
@if (!decryptionFailure) {
<ng-container *ngIf="canAutofill && !hideAutofillOptions">
@if (canAutofill && showAutofill()) {
<ng-container *ngIf="autofillAllowed$ | async">
<button type="button" bitMenuItem (click)="doAutofill()">
{{ "autofill" | i18n }}
</button>
</ng-container>
</ng-container>
<ng-container *ngIf="showViewOption">
}
@if (showViewOption()) {
<button type="button" bitMenuItem (click)="onView()">
{{ "view" | i18n }}
</button>
</ng-container>
}
<button type="button" bitMenuItem (click)="toggleFavorite()">
{{ favoriteText | i18n }}
</button>

View File

@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common";
import { booleanAttribute, Component, Input } from "@angular/core";
import { booleanAttribute, Component, input, Input } from "@angular/core";
import { Router, RouterModule } from "@angular/router";
import { BehaviorSubject, combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs";
import { filter } from "rxjs/operators";
@@ -76,22 +76,17 @@ export class ItemMoreOptionsComponent {
}
/**
* Flag to show view item menu option. Used when something else is
* assigned as the primary action for the item, such as autofill.
* Flag to show the autofill menu option.
* When true, the "Autofill" option appears in the menu.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ transform: booleanAttribute })
showViewOption = false;
readonly showAutofill = input(false, { transform: booleanAttribute });
/**
* Flag to hide the autofill menu options. Used for items that are
* already in the autofill list suggestion.
* Flag to show the view menu option.
* When true, the "View" option appears in the menu.
* Used when the primary action is autofill (so users can view without autofilling).
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ transform: booleanAttribute })
hideAutofillOptions = false;
readonly showViewOption = input(false, { transform: booleanAttribute });
protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$;

View File

@@ -90,11 +90,11 @@
</ng-container>
<cdk-virtual-scroll-viewport [itemSize]="itemHeight$ | async" bitScrollLayout>
<bit-item *cdkVirtualFor="let cipher of group.ciphers">
<bit-item *cdkVirtualFor="let cipher of group.ciphers" class="tw-group/vault-item">
<button
bit-item-content
type="button"
(click)="primaryActionOnSelect(cipher)"
(click)="onCipherSelect(cipher)"
(dblclick)="launchCipher(cipher)"
[appA11yTitle]="
cipherItemTitleKey()(cipher)
@@ -125,32 +125,45 @@
</button>
<ng-container slot="end">
<bit-item-action *ngIf="!hideAutofillButton()">
<button
type="button"
bitBadge
variant="primary"
(click)="doAutofill(cipher)"
[title]="autofillShortcutTooltip() ?? ('autofillTitle' | i18n: cipher.name)"
[attr.aria-label]="'autofillTitle' | i18n: cipher.name"
>
{{ "fill" | i18n }}
</button>
</bit-item-action>
<bit-item-action *ngIf="!showAutofillButton() && CipherViewLikeUtils.canLaunch(cipher)">
<button
type="button"
bitIconButton="bwi-external-link"
size="small"
(click)="launchCipher(cipher)"
[label]="'launchWebsiteName' | i18n: cipher.name"
></button>
</bit-item-action>
@if (showFillTextOnHover()) {
<bit-item-action>
<span
class="tw-opacity-0 tw-text-sm tw-text-primary-600 tw-px-2 group-hover/vault-item:tw-opacity-100 group-focus-within/vault-item:tw-opacity-100 tw-cursor-pointer"
>
{{ "fill" | i18n }}
</span>
</bit-item-action>
}
@if (showAutofillBadge()) {
<bit-item-action>
<button
type="button"
bitBadge
variant="primary"
(click)="doAutofill(cipher)"
[title]="autofillShortcutTooltip() ?? ('autofillTitle' | i18n: cipher.name)"
[attr.aria-label]="'autofillTitle' | i18n: cipher.name"
>
{{ "fill" | i18n }}
</button>
</bit-item-action>
}
@if (showLaunchButton() && CipherViewLikeUtils.canLaunch(cipher)) {
<bit-item-action>
<button
type="button"
bitIconButton="bwi-external-link"
size="small"
(click)="launchCipher(cipher)"
[label]="'launchWebsiteName' | i18n: cipher.name"
></button>
</bit-item-action>
}
<app-item-copy-actions [cipher]="cipher"></app-item-copy-actions>
<app-item-more-options
[cipher]="cipher"
[hideAutofillOptions]="hideAutofillMenuOptions()"
[showViewOption]="primaryActionAutofill()"
[showAutofill]="showAutofillInMenu()"
[showViewOption]="showViewInMenu()"
></app-item-more-options>
</ng-container>
</bit-item>

View File

@@ -0,0 +1,332 @@
import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { Router } from "@angular/router";
import { mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CompactModeService, DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
import { VaultPopupSectionService } from "../../../services/vault-popup-section.service";
import { PopupCipherViewLike } from "../../../views/popup-cipher.view";
import { VaultListItemsContainerComponent } from "./vault-list-items-container.component";
describe("VaultListItemsContainerComponent", () => {
let fixture: ComponentFixture<VaultListItemsContainerComponent>;
let component: VaultListItemsContainerComponent;
const featureFlag$ = new BehaviorSubject<boolean>(false);
const currentTabIsOnBlocklist$ = new BehaviorSubject<boolean>(false);
const mockCipher = {
id: "cipher-1",
name: "Test Login",
type: CipherType.Login,
login: {
username: "user@example.com",
uris: [{ uri: "https://example.com", match: null }],
},
favorite: false,
reprompt: 0,
organizationId: null,
collectionIds: [],
edit: true,
viewPassword: true,
} as any;
const configService = {
getFeatureFlag$: jest.fn().mockImplementation((flag: FeatureFlag) => {
if (flag === FeatureFlag.PM31039ItemActionInExtension) {
return featureFlag$.asObservable();
}
return of(false);
}),
};
const vaultPopupAutofillService = {
currentTabIsOnBlocklist$: currentTabIsOnBlocklist$.asObservable(),
doAutofill: jest.fn(),
};
const compactModeService = {
enabled$: of(false),
};
const vaultPopupSectionService = {
getOpenDisplayStateForSection: jest.fn().mockReturnValue(() => true),
updateSectionOpenStoredState: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
featureFlag$.next(false);
currentTabIsOnBlocklist$.next(false);
await TestBed.configureTestingModule({
imports: [VaultListItemsContainerComponent, NoopAnimationsModule],
providers: [
{ provide: ConfigService, useValue: configService },
{ provide: VaultPopupAutofillService, useValue: vaultPopupAutofillService },
{ provide: CompactModeService, useValue: compactModeService },
{ provide: VaultPopupSectionService, useValue: vaultPopupSectionService },
{ provide: I18nService, useValue: { t: (k: string) => k } },
{ provide: AccountService, useValue: { activeAccount$: of({ id: "UserId" }) } },
{ provide: CipherService, useValue: mock<CipherService>() },
{ provide: Router, useValue: { navigate: jest.fn() } },
{ provide: PlatformUtilsService, useValue: { getAutofillKeyboardShortcut: () => "" } },
{ provide: DialogService, useValue: mock<DialogService>() },
{ provide: PasswordRepromptService, useValue: mock<PasswordRepromptService>() },
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(VaultListItemsContainerComponent);
component = fixture.componentInstance;
});
describe("Updated item action feature flag", () => {
describe("when feature flag is OFF", () => {
beforeEach(() => {
featureFlag$.next(false);
fixture.detectChanges();
});
it("should not show fill text on hover", () => {
fixture.componentRef.setInput("isAutofillList", true);
fixture.detectChanges();
expect(component.showFillTextOnHover()).toBe(false);
});
it("should show autofill badge when showAutofillButton is true and primaryActionAutofill is false", () => {
fixture.componentRef.setInput("showAutofillButton", true);
fixture.componentRef.setInput("primaryActionAutofill", false);
fixture.detectChanges();
expect(component.showAutofillBadge()).toBe(true);
});
it("should hide autofill badge when primaryActionAutofill is true", () => {
fixture.componentRef.setInput("showAutofillButton", true);
fixture.componentRef.setInput("primaryActionAutofill", true);
fixture.detectChanges();
expect(component.showAutofillBadge()).toBe(false);
});
it("should show launch button when showAutofillButton is false", () => {
fixture.componentRef.setInput("showAutofillButton", false);
fixture.detectChanges();
expect(component.showLaunchButton()).toBe(true);
});
it("should hide launch button when showAutofillButton is true", () => {
fixture.componentRef.setInput("showAutofillButton", true);
fixture.detectChanges();
expect(component.showLaunchButton()).toBe(false);
});
it("should show autofill in menu when showAutofillButton is false", () => {
fixture.componentRef.setInput("showAutofillButton", false);
fixture.detectChanges();
expect(component.showAutofillInMenu()).toBe(true);
});
it("should hide autofill in menu when showAutofillButton is true", () => {
fixture.componentRef.setInput("showAutofillButton", true);
fixture.detectChanges();
expect(component.showAutofillInMenu()).toBe(false);
});
it("should show view in menu when primaryActionAutofill is true", () => {
fixture.componentRef.setInput("primaryActionAutofill", true);
fixture.detectChanges();
expect(component.showViewInMenu()).toBe(true);
});
it("should hide view in menu when primaryActionAutofill is false", () => {
fixture.componentRef.setInput("primaryActionAutofill", false);
fixture.detectChanges();
expect(component.showViewInMenu()).toBe(false);
});
it("should autofill on select when primaryActionAutofill is true", () => {
fixture.componentRef.setInput("primaryActionAutofill", true);
fixture.detectChanges();
expect(component.canAutofill()).toBe(true);
});
it("should not autofill on select when primaryActionAutofill is false", () => {
fixture.componentRef.setInput("primaryActionAutofill", false);
fixture.detectChanges();
expect(component.canAutofill()).toBe(false);
});
});
describe("when feature flag is ON", () => {
beforeEach(() => {
featureFlag$.next(true);
fixture.detectChanges();
});
it("should show fill text on hover for autofill list items", () => {
fixture.componentRef.setInput("isAutofillList", true);
fixture.detectChanges();
expect(component.showFillTextOnHover()).toBe(true);
});
it("should not show fill text on hover for non-autofill list items", () => {
fixture.componentRef.setInput("isAutofillList", false);
fixture.detectChanges();
expect(component.showFillTextOnHover()).toBe(false);
});
it("should not show autofill badge", () => {
fixture.componentRef.setInput("isAutofillList", true);
fixture.componentRef.setInput("showAutofillButton", true);
fixture.detectChanges();
expect(component.showAutofillBadge()).toBe(false);
});
it("should hide launch button for autofill list items", () => {
fixture.componentRef.setInput("isAutofillList", true);
fixture.detectChanges();
expect(component.showLaunchButton()).toBe(false);
});
it("should show launch button for non-autofill list items", () => {
fixture.componentRef.setInput("isAutofillList", false);
fixture.detectChanges();
expect(component.showLaunchButton()).toBe(true);
});
it("should show autofill in menu for non-autofill list items", () => {
fixture.componentRef.setInput("isAutofillList", false);
fixture.detectChanges();
expect(component.showAutofillInMenu()).toBe(true);
});
it("should hide autofill in menu for autofill list items", () => {
fixture.componentRef.setInput("isAutofillList", true);
fixture.detectChanges();
expect(component.showAutofillInMenu()).toBe(false);
});
it("should show view in menu for autofill list items", () => {
fixture.componentRef.setInput("isAutofillList", true);
fixture.detectChanges();
expect(component.showViewInMenu()).toBe(true);
});
it("should hide view in menu for non-autofill list items", () => {
fixture.componentRef.setInput("isAutofillList", false);
fixture.detectChanges();
expect(component.showViewInMenu()).toBe(false);
});
it("should autofill on select for autofill list items", () => {
fixture.componentRef.setInput("isAutofillList", true);
fixture.detectChanges();
expect(component.canAutofill()).toBe(true);
});
it("should not autofill on select for non-autofill list items", () => {
fixture.componentRef.setInput("isAutofillList", false);
fixture.detectChanges();
expect(component.canAutofill()).toBe(false);
});
});
describe("when current URI is blocked", () => {
beforeEach(() => {
currentTabIsOnBlocklist$.next(true);
fixture.detectChanges();
});
it("should not autofill on select even when feature flag is ON and isAutofillList is true", () => {
featureFlag$.next(true);
fixture.componentRef.setInput("isAutofillList", true);
fixture.detectChanges();
expect(component.canAutofill()).toBe(false);
});
it("should not autofill on select even when primaryActionAutofill is true", () => {
featureFlag$.next(false);
fixture.componentRef.setInput("primaryActionAutofill", true);
fixture.detectChanges();
expect(component.canAutofill()).toBe(false);
});
});
});
describe("cipherItemTitleKey", () => {
it("should return autofillTitle when canAutofill is true", () => {
featureFlag$.next(true);
fixture.componentRef.setInput("isAutofillList", true);
fixture.detectChanges();
const titleKeyFn = component.cipherItemTitleKey();
const result = titleKeyFn(mockCipher);
expect(result).toBe("autofillTitleWithField");
});
it("should return viewItemTitle when canAutofill is false", () => {
featureFlag$.next(true);
fixture.componentRef.setInput("isAutofillList", false);
fixture.detectChanges();
const titleKeyFn = component.cipherItemTitleKey();
const result = titleKeyFn(mockCipher);
expect(result).toBe("viewItemTitleWithField");
});
it("should return title without WithField when cipher has no username", () => {
featureFlag$.next(true);
fixture.componentRef.setInput("isAutofillList", false);
fixture.detectChanges();
const cipherWithoutUsername = {
...mockCipher,
login: { ...mockCipher.login, username: null },
} as PopupCipherViewLike;
const titleKeyFn = component.cipherItemTitleKey();
const result = titleKeyFn(cipherWithoutUsername);
expect(result).toBe("viewItemTitle");
});
});
});

View File

@@ -21,6 +21,8 @@ import { firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
@@ -88,8 +90,15 @@ import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options
export class VaultListItemsContainerComponent implements AfterViewInit {
private compactModeService = inject(CompactModeService);
private vaultPopupSectionService = inject(VaultPopupSectionService);
private configService = inject(ConfigService);
protected CipherViewLikeUtils = CipherViewLikeUtils;
/** Signal for the feature flag that controls simplified item action behavior */
protected readonly simplifiedItemActionEnabled = toSignal(
this.configService.getFeatureFlag$(FeatureFlag.PM31039ItemActionInExtension),
{ initialValue: false },
);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(CdkVirtualScrollViewport, { static: false }) viewPort!: CdkVirtualScrollViewport;
@@ -136,24 +145,18 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
*/
private viewCipherTimeout?: number;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
ciphers = input<PopupCipherViewLike[]>([]);
readonly ciphers = input<PopupCipherViewLike[]>([]);
/**
* If true, we will group ciphers by type (Login, Card, Identity)
* within subheadings in a single container, converted to a WritableSignal.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
groupByType = input<boolean | undefined>(false);
readonly groupByType = input<boolean | undefined>(false);
/**
* Computed signal for a grouped list of ciphers with an optional header
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
cipherGroups = computed<
readonly cipherGroups = computed<
{
subHeaderKey?: string;
ciphers: PopupCipherViewLike[];
@@ -195,9 +198,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/**
* Title for the vault list item section.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
title = input<string | undefined>(undefined);
readonly title = input<string | undefined>(undefined);
/**
* Optionally allow the items to be collapsed.
@@ -205,24 +206,20 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
* The key must be added to the state definition in `vault-popup-section.service.ts` since the
* collapsed state is stored locally.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
collapsibleKey = input<keyof PopupSectionOpen | undefined>(undefined);
readonly collapsibleKey = input<keyof PopupSectionOpen | undefined>(undefined);
/**
* Optional description for the vault list item section. Will be shown below the title even when
* no ciphers are available.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
description = input<string | undefined>(undefined);
readonly description = input<string | undefined>(undefined);
/**
* Option to show a refresh button in the section header.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
showRefresh = input(false, { transform: booleanAttribute });
readonly showRefresh = input(false, { transform: booleanAttribute });
/**
* Event emitted when the refresh button is clicked.
@@ -235,71 +232,124 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/**
* Flag indicating that the current tab location is blocked
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
currentURIIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$);
readonly currentUriIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$);
/**
* Resolved i18n key to use for suggested cipher items
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
cipherItemTitleKey = computed(() => {
readonly cipherItemTitleKey = computed(() => {
return (cipher: CipherViewLike) => {
const login = CipherViewLikeUtils.getLogin(cipher);
const hasUsername = login?.username != null;
const key =
this.primaryActionAutofill() && !this.currentURIIsBlocked()
? "autofillTitle"
: "viewItemTitle";
// Use autofill title when autofill is the primary action
const key = this.canAutofill() ? "autofillTitle" : "viewItemTitle";
return hasUsername ? `${key}WithField` : key;
};
});
/**
* @deprecated - To be removed when PM31039ItemActionInExtension is fully rolled out
* Option to show the autofill button for each item.
* Used when feature flag is disabled.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
showAutofillButton = input(false, { transform: booleanAttribute });
readonly showAutofillButton = input(false, { transform: booleanAttribute });
/**
* Flag indicating whether the suggested cipher item autofill button should be shown or not
* @deprecated - To be removed when PM31039ItemActionInExtension is fully rolled out
* Whether to show the autofill badge button (old behavior).
* Only shown when feature flag is disabled AND conditions are met.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
hideAutofillButton = computed(
() => !this.showAutofillButton() || this.currentURIIsBlocked() || this.primaryActionAutofill(),
readonly showAutofillBadge = computed(
() => !this.simplifiedItemActionEnabled() && !this.hideAutofillButton(),
);
/**
* Flag indicating whether the cipher item autofill menu options should be shown or not
* @deprecated - To be removed when PM31039ItemActionInExtension is fully rolled out
* Flag indicating whether the cipher item autofill menu options should be shown or not.
* Used when feature flag is disabled.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
hideAutofillMenuOptions = computed(() => this.currentURIIsBlocked() || this.showAutofillButton());
readonly hideAutofillMenuOptions = computed(
() => this.currentUriIsBlocked() || this.showAutofillButton(),
);
/**
* @deprecated - To be removed when PM31039ItemActionInExtension is fully rolled out
* Option to perform autofill operation as the primary action for autofill suggestions.
* Used when feature flag is disabled.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
primaryActionAutofill = input(false, { transform: booleanAttribute });
readonly primaryActionAutofill = input(false, { transform: booleanAttribute });
/**
* @deprecated - To be removed when PM31039ItemActionInExtension is fully rolled out
* Flag indicating whether the suggested cipher item autofill button should be shown or not.
* Used when feature flag is disabled.
*/
readonly hideAutofillButton = computed(
() => !this.showAutofillButton() || this.currentUriIsBlocked() || this.primaryActionAutofill(),
);
/**
* Option to mark this container as an autofill list.
*/
readonly isAutofillList = input(false, { transform: booleanAttribute });
/**
* Computed property whether the cipher action may perform autofill.
* When feature flag is enabled, uses isAutofillList.
* When feature flag is disabled, uses primaryActionAutofill.
*/
readonly canAutofill = computed(() => {
if (this.currentUriIsBlocked()) {
return false;
}
return this.isAutofillList()
? this.simplifiedItemActionEnabled()
: this.primaryActionAutofill();
});
/**
* Whether to show the "Fill" text on hover.
* Only shown when feature flag is enabled AND this is an autofill list.
*/
readonly showFillTextOnHover = computed(
() => this.simplifiedItemActionEnabled() && this.canAutofill(),
);
/**
* Whether to show the launch button.
*/
readonly showLaunchButton = computed(() =>
this.simplifiedItemActionEnabled() ? !this.isAutofillList() : !this.showAutofillButton(),
);
/**
* Whether to show the "Autofill" option in the more options menu.
* New behavior: show for non-autofill list items.
* Old behavior: show when not hidden by hideAutofillMenuOptions.
*/
readonly showAutofillInMenu = computed(() =>
this.simplifiedItemActionEnabled() ? !this.canAutofill() : !this.hideAutofillMenuOptions(),
);
/**
* Whether to show the "View" option in the more options menu.
* New behavior: show for autofill list items (since click = autofill).
* Old behavior: show when primary action is autofill.
*/
readonly showViewInMenu = computed(() =>
this.simplifiedItemActionEnabled() ? this.isAutofillList() : this.primaryActionAutofill(),
);
/**
* Remove the bottom margin from the bit-section in this component
* (used for containers at the end of the page where bottom margin is not needed)
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
disableSectionMargin = input(false, { transform: booleanAttribute });
readonly disableSectionMargin = input(false, { transform: booleanAttribute });
/**
* Remove the description margin
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
disableDescriptionMargin = input(false, { transform: booleanAttribute });
readonly disableDescriptionMargin = input(false, { transform: booleanAttribute });
/**
* The tooltip text for the organization icon for ciphers that belong to an organization.
@@ -313,9 +363,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
return collections[0]?.name;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
protected autofillShortcutTooltip = signal<string | undefined>(undefined);
protected readonly autofillShortcutTooltip = signal<string | undefined>(undefined);
constructor(
private i18nService: I18nService,
@@ -340,10 +388,8 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
}
}
primaryActionOnSelect(cipher: PopupCipherViewLike) {
return this.primaryActionAutofill() && !this.currentURIIsBlocked()
? this.doAutofill(cipher)
: this.onViewCipher(cipher);
onCipherSelect(cipher: PopupCipherViewLike) {
return this.canAutofill() ? this.doAutofill(cipher) : this.onViewCipher(cipher);
}
/**

View File

@@ -50,16 +50,18 @@
<vault-permit-cipher-details-popover></vault-permit-cipher-details-popover>
</bit-label>
</bit-form-control>
<bit-form-control>
<bit-form-control [disableMargin]="simplifiedItemActionEnabled()">
<input bitCheckbox formControlName="showQuickCopyActions" type="checkbox" />
<bit-label>{{ "showQuickCopyActions" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control disableMargin>
<input bitCheckbox formControlName="clickItemsToAutofillVaultView" type="checkbox" />
<bit-label>
{{ "clickToAutofill" | i18n }}
</bit-label>
</bit-form-control>
@if (!simplifiedItemActionEnabled()) {
<bit-form-control disableMargin>
<input bitCheckbox formControlName="clickItemsToAutofillVaultView" type="checkbox" />
<bit-label>
{{ "clickToAutofill" | i18n }}
</bit-label>
</bit-form-control>
}
</bit-card>
</form>
</popup-page>

View File

@@ -1,10 +1,12 @@
import { Component, Input } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { BehaviorSubject, of } from "rxjs";
import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -59,7 +61,7 @@ describe("AppearanceComponent", () => {
const enableRoutingAnimation$ = new BehaviorSubject<boolean>(true);
const enableCompactMode$ = new BehaviorSubject<boolean>(false);
const showQuickCopyActions$ = new BehaviorSubject<boolean>(false);
const clickItemsToAutofillVaultView$ = new BehaviorSubject<boolean>(false);
const featureFlag$ = new BehaviorSubject<boolean>(false);
const setSelectedTheme = jest.fn().mockResolvedValue(undefined);
const setShowFavicons = jest.fn().mockResolvedValue(undefined);
const setEnableBadgeCounter = jest.fn().mockResolvedValue(undefined);
@@ -78,11 +80,20 @@ describe("AppearanceComponent", () => {
setShowFavicons.mockClear();
setEnableBadgeCounter.mockClear();
setEnableRoutingAnimation.mockClear();
setClickItemsToAutofillVaultView.mockClear();
const configService = mock<ConfigService>();
configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => {
if (flag === FeatureFlag.PM31039ItemActionInExtension) {
return featureFlag$.asObservable();
}
return of(false);
});
await TestBed.configureTestingModule({
imports: [AppearanceComponent],
providers: [
{ provide: ConfigService, useValue: mock<ConfigService>() },
{ provide: ConfigService, useValue: configService },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
{ provide: MessagingService, useValue: mock<MessagingService>() },
{ provide: I18nService, useValue: { t: (key: string) => key } },
@@ -114,7 +125,7 @@ describe("AppearanceComponent", () => {
{
provide: VaultSettingsService,
useValue: {
clickItemsToAutofillVaultView$,
clickItemsToAutofillVaultView$: of(false),
setClickItemsToAutofillVaultView,
},
},
@@ -193,11 +204,40 @@ describe("AppearanceComponent", () => {
expect(mockWidthService.setWidth).toHaveBeenCalledWith("wide");
});
});
it("updates the click items to autofill vault view setting", () => {
component.appearanceForm.controls.clickItemsToAutofillVaultView.setValue(true);
describe("PM31039ItemActionInExtension feature flag", () => {
describe("when set to OFF", () => {
it("should show clickItemsToAutofillVaultView checkbox", () => {
featureFlag$.next(false);
fixture.detectChanges();
expect(setClickItemsToAutofillVaultView).toHaveBeenCalledWith(true);
const checkbox = fixture.debugElement.query(
By.css('input[formControlName="clickItemsToAutofillVaultView"]'),
);
expect(checkbox).not.toBeNull();
});
it("should update the clickItemsToAutofillVaultView setting when changed", () => {
featureFlag$.next(false);
fixture.detectChanges();
component.appearanceForm.controls.clickItemsToAutofillVaultView.setValue(true);
expect(setClickItemsToAutofillVaultView).toHaveBeenCalledWith(true);
});
});
describe("when set to ON", () => {
it("should hide clickItemsToAutofillVaultView checkbox", () => {
featureFlag$.next(true);
fixture.detectChanges();
const checkbox = fixture.debugElement.query(
By.css('input[formControlName="clickItemsToAutofillVaultView"]'),
);
expect(checkbox).toBeNull();
});
});
});
});

View File

@@ -2,14 +2,16 @@
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums";
@@ -57,6 +59,13 @@ export class AppearanceComponent implements OnInit {
private copyButtonsService = inject(VaultPopupCopyButtonsService);
private popupSizeService = inject(PopupSizeService);
private i18nService = inject(I18nService);
private configService = inject(ConfigService);
/** Signal for the feature flag that controls simplified item action behavior */
protected readonly simplifiedItemActionEnabled = toSignal(
this.configService.getFeatureFlag$(FeatureFlag.PM31039ItemActionInExtension),
{ initialValue: false },
);
appearanceForm = this.formBuilder.group({
enableFavicon: false,

View File

@@ -72,6 +72,7 @@ export enum FeatureFlag {
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk",
PM31039ItemActionInExtension = "pm-31039-item-action-in-extension",
/* Platform */
ContentScriptIpcChannelFramework = "content-script-ipc-channel-framework",
@@ -117,6 +118,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.WindowsDesktopAutotype]: FALSE,
[FeatureFlag.WindowsDesktopAutotypeGA]: FALSE,
[FeatureFlag.SSHAgentV2]: FALSE,
[FeatureFlag.PM31039ItemActionInExtension]: FALSE,
/* Tools */
[FeatureFlag.UseSdkPasswordGenerators]: FALSE,