Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/desktop/src/app/accounts/settings-dialog.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,21 @@ <h2 bitTypography="h6">{{ "sessionTimeoutHeader" | i18n }}</h2>
</bit-form-control>
}

@if (showQuickCopyActionsSetting()) {
<bit-form-control>
<input
type="checkbox"
bitCheckbox
formControlName="showQuickCopyActions"
(change)="saveQuickCopyActions()"
/>
<bit-label>
{{ "showQuickCopyActionsInVault" | i18n }}
<show-quick-copy-actions-details-popover slot="end" />
</bit-label>
</bit-form-control>
}

<bit-form-field>
<bit-label>{{ "theme" | i18n }}</bit-label>
<bit-select formControlName="theme" [items]="themeOptions" />
Expand Down
69 changes: 69 additions & 0 deletions apps/desktop/src/app/accounts/settings-dialog.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { DeviceType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
Expand All @@ -54,6 +55,7 @@ import { BiometricStateService, BiometricsStatus, KeyService } from "@bitwarden/
import { SessionTimeoutSettingsComponent } from "@bitwarden/key-management-ui";
// eslint-disable-next-line no-restricted-imports
import { SymmetricCryptoKey } from "@bitwarden/legacy-crypto";
import { VaultCopyButtonsService } from "@bitwarden/vault";

import { SetPinComponent } from "../../auth/components/set-pin.component";
import { SshAgentPromptType } from "../../autofill/models/ssh-agent-setting";
Expand Down Expand Up @@ -103,6 +105,7 @@ describe("SettingsDialogComponent", () => {
const billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
const configService = mock<ConfigService>();
const userVerificationService = mock<UserVerificationService>();
const vaultCopyButtonsService = mock<VaultCopyButtonsService>();

const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;

Expand Down Expand Up @@ -165,6 +168,7 @@ describe("SettingsDialogComponent", () => {
{ provide: ToastService, useValue: mock<ToastService>() },
{ provide: DesktopAutotypeMvpService, useValue: desktopAutotypeMvpService },
{ provide: BillingAccountProfileStateService, useValue: billingAccountProfileStateService },
{ provide: VaultCopyButtonsService, useValue: vaultCopyButtonsService },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
Expand Down Expand Up @@ -208,6 +212,7 @@ describe("SettingsDialogComponent", () => {
desktopAutotypeMvpService.autotypeKeyboardShortcut$ = of(["Control", "Alt", "B"]);
billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
configService.getFeatureFlag$.mockReturnValue(of(false));
vaultCopyButtonsService.showQuickCopyActions$ = of(false);

fixture = TestBed.createComponent(SettingsDialogComponent);
component = fixture.componentInstance;
Expand Down Expand Up @@ -950,6 +955,70 @@ describe("SettingsDialogComponent", () => {
});
});

describe("quick copy actions", () => {
/**
* `showQuickCopyActionsSetting` is a `toSignal()` initialized at class level, so the feature
* flag mock must be in place before the component is constructed.
*/
function createComponentWithFlag(enabled: boolean) {
configService.getFeatureFlag$.mockImplementation((flag) =>
of(flag === FeatureFlag.PM40435_QuickCopyIconSetting ? enabled : false),
);

fixture = TestBed.createComponent(SettingsDialogComponent);
component = fixture.componentInstance;
}

it("is not visible when the feature flag is disabled", async () => {
createComponentWithFlag(false);

await component.ngOnInit();
fixture.detectChanges();

const showQuickCopyActionsInput = fixture.debugElement.query(
By.css("input[formControlName='showQuickCopyActions']"),
);
expect(showQuickCopyActionsInput).toBeNull();
expect((component as any).showQuickCopyActionsSetting()).toBe(false);
});

it("is visible when the feature flag is enabled", async () => {
createComponentWithFlag(true);

await component.ngOnInit();
fixture.detectChanges();

const showQuickCopyActionsInput = fixture.debugElement.query(
By.css("input[formControlName='showQuickCopyActions']"),
);
expect(showQuickCopyActionsInput).not.toBeNull();
expect(showQuickCopyActionsInput.attributes).toMatchObject({
type: "checkbox",
});
expect((component as any).showQuickCopyActionsSetting()).toBe(true);
});

test.each([true, false])(
"initializes the form control from the stored setting when it is %s",
async (stored) => {
vaultCopyButtonsService.showQuickCopyActions$ = of(stored);

await component.ngOnInit();

expect(component["form"].controls.showQuickCopyActions.value).toBe(stored);
},
);

test.each([true, false])("saves the new value when set to %s", async (value) => {
await component.ngOnInit();

component["form"].controls.showQuickCopyActions.setValue(value);
await component.saveQuickCopyActions();

expect(vaultCopyButtonsService.setShowQuickCopyActions).toHaveBeenLastCalledWith(value);
});
});

describe("clearClipboard valueChanges", () => {
it("saves the new clear clipboard value when changed", async () => {
await component.ngOnInit();
Expand Down
24 changes: 23 additions & 1 deletion apps/desktop/src/app/accounts/settings-dialog.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ import {
import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management";
import { SessionTimeoutSettingsComponent } from "@bitwarden/key-management-ui";
import { I18nPipe } from "@bitwarden/ui-common";
import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault";
import {
PermitCipherDetailsPopoverComponent,
VaultCopyButtonsService,
ShowQuickCopyActionsDetailsPopoverComponent,
} from "@bitwarden/vault";

import { SetPinComponent } from "../../auth/components/set-pin.component";
import { AutotypeShortcutComponent } from "../../autofill/components/autotype-shortcut.component";
Expand Down Expand Up @@ -98,6 +102,7 @@ import { NativeMessagingManifestService } from "../services/native-messaging-man
SessionTimeoutSettingsComponent,
PermitCipherDetailsPopoverComponent,
PremiumBadgeComponent,
ShowQuickCopyActionsDetailsPopoverComponent,
],
})
export class SettingsDialogComponent implements OnInit {
Expand Down Expand Up @@ -127,6 +132,7 @@ export class SettingsDialogComponent implements OnInit {
private readonly validationService = inject(ValidationService);
private readonly billingAccountProfileStateService = inject(BillingAccountProfileStateService);
private readonly destroyRef = inject(DestroyRef);
private readonly vaultCopyButtonsService = inject(VaultCopyButtonsService);

protected readonly localeOptions: Option<string>[];
protected readonly themeOptions: Option<string>[];
Expand All @@ -151,6 +157,12 @@ export class SettingsDialogComponent implements OnInit {
protected readonly userHasMasterPassword = signal(false);
protected readonly userHasPinSet = signal(false);

/** Controls whether the quick copy actions setting is shown */
protected readonly showQuickCopyActionsSetting = toSignal(
this.configService.getFeatureFlag$(FeatureFlag.PM40435_QuickCopyIconSetting),
{ initialValue: false },
);

protected readonly pinEnabled = toSignal(
this.accountService.activeAccount$.pipe(
getUserId,
Expand All @@ -174,6 +186,7 @@ export class SettingsDialogComponent implements OnInit {
clearClipboard: [null],
minimizeOnCopyToClipboard: false,
enableFavicons: false,
showQuickCopyActions: false,
// App Settings
runInBackground: false,
openAtLogin: false,
Expand Down Expand Up @@ -273,6 +286,9 @@ export class SettingsDialogComponent implements OnInit {
clearClipboard: await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$),
minimizeOnCopyToClipboard: await firstValueFrom(this.desktopSettingsService.minimizeOnCopy$),
enableFavicons: await firstValueFrom(this.domainSettingsService.showFavicons$),
showQuickCopyActions: await firstValueFrom(
this.vaultCopyButtonsService.showQuickCopyActions$,
),
runInBackground: await firstValueFrom(this.desktopSettingsService.runInBackground$),
openAtLogin: await firstValueFrom(this.desktopSettingsService.openAtLogin$),
enableDuckDuckGoBrowserIntegration: await firstValueFrom(
Expand Down Expand Up @@ -532,6 +548,12 @@ export class SettingsDialogComponent implements OnInit {
this.messagingService.send("refreshCiphers");
}

async saveQuickCopyActions() {
await this.vaultCopyButtonsService.setShowQuickCopyActions(
this.form.value.showQuickCopyActions,
);
}

protected async saveRunInBackground() {
await this.desktopSettingsService.setRunInBackground(this.form.value.runInBackground);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@
</div>
</td>
@if (showOwner()) {
<td bitCell [ngClass]="RowHeightClass" class="tw-hidden @md:tw-table-cell">
<!-- Breakpoint must match the Owner header cell in vault-list.component.html. -->
<td bitCell [ngClass]="RowHeightClass" class="tw-hidden @xl:tw-table-cell">
<app-org-badge
[disabled]="disabled()"
[organizationId]="cipher().organizationId"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
</div>
</td>
@if (showOwner()) {
<td bitCell [ngClass]="RowHeightClass" class="tw-hidden @md:tw-table-cell">
<!-- Breakpoint must match the Owner header cell in vault-list.component.html. -->
<td bitCell [ngClass]="RowHeightClass" class="tw-hidden @xl:tw-table-cell">
<app-org-badge
[disabled]="disabled()"
[organizationId]="collection().organizationId"
Expand Down
18 changes: 10 additions & 8 deletions apps/desktop/src/vault/app/vault-v3/vault-list.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,26 +34,28 @@
/>
</th>
}
<!--
Owner and Options are sized in px so they keep room for their fixed-size contents as
the window shrinks; Name is the flexible column that absorbs the remainder and
truncates. Owner only appears once the table is wide enough that reserving it still
leaves Name a usable share.
-->
<!-- Individual or Organization vault -->
<th
bitCell
bitSortable="name"
[fn]="sortByName"
[class]="showExtraColumn ? 'tw-w-3/6' : 'tw-w-full'"
>
<th bitCell bitSortable="name" [fn]="sortByName" class="tw-w-full">
{{ "name" | i18n }}
</th>
@if (showOwner()) {
<th
bitCell
bitSortable="owner"
[fn]="sortByOwner"
class="tw-hidden tw-w-1/6 @md:tw-table-cell"
class="tw-hidden @xl:tw-table-cell"
[ngClass]="ownerColumnWidthClass"
>
{{ "owner" | i18n }}
</th>
}
<th bitCell class="tw-w-2/6 tw-text-right tw-font-medium">
<th bitCell class="tw-text-right tw-font-medium" [ngClass]="optionsColumnWidthClass()">
{{ "options" | i18n }}
</th>
</tr>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { optionsColumnWidthClass, OWNER_COLUMN_WIDTH_CLASS } from "./vault-list.component";

describe("vault list column widths", () => {
describe("optionsColumnWidthClass", () => {
it("reserves room for an icon per copyable field when quick copy actions are shown", () => {
// launch + 3 copy icons + overflow trigger + cell padding β‰ˆ 209px
expect(optionsColumnWidthClass(true)).toBe("tw-w-56");
});

it("reserves room for a single combined copy button otherwise", () => {
// launch + 1 copy button + overflow trigger + cell padding β‰ˆ 145px
expect(optionsColumnWidthClass(false)).toBe("tw-w-40");
});
});

describe("OWNER_COLUMN_WIDTH_CLASS", () => {
it("reserves room for the owner badge", () => {
// The badge is truncated to 13 characters, running to ~120px plus cell padding.
expect(OWNER_COLUMN_WIDTH_CLASS).toBe("tw-w-40");
});
});
});
57 changes: 54 additions & 3 deletions apps/desktop/src/vault/app/vault-v3/vault-list.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ import {
CheckboxModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { NewCipherMenuComponent, VaultBatchBarService, VaultItem } from "@bitwarden/vault";
import {
NewCipherMenuComponent,
VaultBatchBarService,
VaultCopyButtonsService,
VaultItem,
} from "@bitwarden/vault";

import { VaultCipherRowComponent } from "./vault-items/vault-cipher-row.component";
import { VaultCollectionRowComponent } from "./vault-items/vault-collection-row.component";
Expand All @@ -45,6 +50,35 @@ import { VaultItemEvent } from "./vault-items/vault-item-event";
// Fixed manual row height required due to how cdk-virtual-scroll works
export const RowHeight = 76.5;
export const RowHeightClass = `tw-h-[76.5px]`;

/**
* Width of the Options column, sized to the widest action strip a row can draw.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ‘ Documentation is appreciated!

*
* The strip is icon buttons, so its width doesn't scale with the window β€” but a fractional column
* width does. Under `table-layout: fixed` a px width holds even once the rest of the table has to
* give, whereas a fraction keeps shrinking past what the buttons need. The cell is
* `whitespace-nowrap`, so once it's too narrow the strip overflows to the right rather than
* wrapping, and the overflow menu trigger lands past the table's edge, off screen.
*
* Budget is 40px each for the launch and overflow triggers (`bitIconButton` at its default size),
* 32px per quick-copy icon (`size="small"`), ~4px per collapsed whitespace gap between them, and
* 12px of cell padding each side. Both cases round up to the next step on the spacing scale:
* - quick copy β€” launch + 3 copy icons + trigger: `40 + 3*32 + 40 + 2*4 + 24` β‰ˆ 209 β†’ `w-56`.
* - collapsed β€” launch + one combined copy button + trigger: `40 + 32 + 40 + 2*4 + 24` β‰ˆ 145 β†’ `w-40`.
*/
export const optionsColumnWidthClass = (showQuickCopyActions: boolean): string =>
showQuickCopyActions ? "tw-w-56" : "tw-w-40";

/**
* Width of the Owner column, sized to hold its badge.
*
* The badge is a chip truncated to 13 characters, which runs to ~120px β€” wider than a fraction of
* a narrow table leaves it. Sized as a px column for the same reason as
* {@link optionsColumnWidthClass}: otherwise the chip overflows into the Options column and the
* action buttons render on top of it.
*/
export const OWNER_COLUMN_WIDTH_CLASS = "tw-w-40";

type EmptyStateItem = {
title: string;
description: string;
Expand Down Expand Up @@ -102,6 +136,25 @@ export class VaultListComponent<C extends CipherViewLike> {
private batchBarService = inject<VaultBatchBarService<C>>(VaultBatchBarService, {
optional: true,
});
private vaultCopyButtonsService = inject(VaultCopyButtonsService);

/**
* Whether copy actions render as an icon per copyable field rather than a single combined menu.
* Mirrors {@link VaultCipherRowComponent}'s own check, so the column reserves what the rows draw.
*/
private readonly showQuickCopyActions = toSignal(
combineLatest([
this.configService.getFeatureFlag$(FeatureFlag.PM40435_QuickCopyIconSetting),
this.vaultCopyButtonsService.showQuickCopyActions$,
]).pipe(map(([flagEnabled, settingEnabled]) => flagEnabled && settingEnabled)),
{ initialValue: false },
);

protected readonly optionsColumnWidthClass = computed(() =>
optionsColumnWidthClass(this.showQuickCopyActions()),
);

protected readonly ownerColumnWidthClass = OWNER_COLUMN_WIDTH_CLASS;

protected readonly showBatchBar = toSignal(
combineLatest([
Expand Down Expand Up @@ -155,8 +208,6 @@ export class VaultListComponent<C extends CipherViewLike> {
});
}

protected readonly showExtraColumn = computed(() => this.showOwner());

protected event(event: VaultItemEvent<C>) {
this.onEvent.emit(event);
}
Expand Down
Loading