diff --git a/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html b/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html index 47ef0284d6a..38d60233200 100644 --- a/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html +++ b/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html @@ -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" > diff --git a/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.html index be67869d3df..4df3c8a5c73 100644 --- a/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.html +++ b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.html @@ -8,18 +8,18 @@ > @if (!decryptionFailure) { - + @if (canAutofill && showAutofill()) { - - + } + @if (showViewOption()) { - + } diff --git a/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.ts index 8ed2699254e..ef4c4a111b6 100644 --- a/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.ts @@ -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$; diff --git a/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.html index 3dac158b8e1..e9e89776dde 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.html @@ -90,11 +90,11 @@ - + - - - - + @if (showFillTextOnHover()) { + + + {{ "fill" | i18n }} + + + } + @if (showAutofillBadge()) { + + + + } + @if (showLaunchButton() && CipherViewLikeUtils.canLaunch(cipher)) { + + + + } diff --git a/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.spec.ts b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.spec.ts new file mode 100644 index 00000000000..eda84265e90 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.spec.ts @@ -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; + let component: VaultListItemsContainerComponent; + + const featureFlag$ = new BehaviorSubject(false); + const currentTabIsOnBlocklist$ = new BehaviorSubject(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() }, + { provide: Router, useValue: { navigate: jest.fn() } }, + { provide: PlatformUtilsService, useValue: { getAutofillKeyboardShortcut: () => "" } }, + { provide: DialogService, useValue: mock() }, + { provide: PasswordRepromptService, useValue: mock() }, + ], + 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"); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.ts b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.ts index 469247f9692..fb8d20c5cf6 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.ts @@ -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([]); + readonly ciphers = input([]); /** * 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(false); + readonly groupByType = input(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(undefined); + readonly title = input(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(undefined); + readonly collapsibleKey = input(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(undefined); + + readonly description = input(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(undefined); + protected readonly autofillShortcutTooltip = signal(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); } /** diff --git a/apps/browser/src/vault/popup/settings/appearance.component.html b/apps/browser/src/vault/popup/settings/appearance.component.html index b58316a8d64..d87c0640f52 100644 --- a/apps/browser/src/vault/popup/settings/appearance.component.html +++ b/apps/browser/src/vault/popup/settings/appearance.component.html @@ -50,16 +50,18 @@ - + {{ "showQuickCopyActions" | i18n }} - - - - {{ "clickToAutofill" | i18n }} - - + @if (!simplifiedItemActionEnabled()) { + + + + {{ "clickToAutofill" | i18n }} + + + } diff --git a/apps/browser/src/vault/popup/settings/appearance.component.spec.ts b/apps/browser/src/vault/popup/settings/appearance.component.spec.ts index 41e89ec30e8..465b78e232d 100644 --- a/apps/browser/src/vault/popup/settings/appearance.component.spec.ts +++ b/apps/browser/src/vault/popup/settings/appearance.component.spec.ts @@ -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(true); const enableCompactMode$ = new BehaviorSubject(false); const showQuickCopyActions$ = new BehaviorSubject(false); - const clickItemsToAutofillVaultView$ = new BehaviorSubject(false); + const featureFlag$ = new BehaviorSubject(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.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => { + if (flag === FeatureFlag.PM31039ItemActionInExtension) { + return featureFlag$.asObservable(); + } + return of(false); + }); await TestBed.configureTestingModule({ imports: [AppearanceComponent], providers: [ - { provide: ConfigService, useValue: mock() }, + { provide: ConfigService, useValue: configService }, { provide: PlatformUtilsService, useValue: mock() }, { provide: MessagingService, useValue: mock() }, { 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(); + }); }); }); }); diff --git a/apps/browser/src/vault/popup/settings/appearance.component.ts b/apps/browser/src/vault/popup/settings/appearance.component.ts index bff51335192..47aa1804efc 100644 --- a/apps/browser/src/vault/popup/settings/appearance.component.ts +++ b/apps/browser/src/vault/popup/settings/appearance.component.ts @@ -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, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 1edbcc4e376..4db9ff37d42 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -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,