1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-14 07:13:32 +00:00

[PM-17820] - Browser/Web - update button and label state in username generator (#13189)

* add event handling for username generator

* fix specs. change function name to not be of an event type

* update specs

* rename function

* revert name change

* fix spec

* bubble algorithmSelected up to generator components. add disabled button tests

* add typeSelected event

* revert addition of onType.

* apply same logic in onAlgorithmSelected to web and desktop
This commit is contained in:
Jordan Aasen
2025-03-03 11:44:34 -08:00
committed by GitHub
parent d01f0c6bc4
commit 13213585b2
9 changed files with 205 additions and 97 deletions

View File

@@ -3,13 +3,14 @@
slot="header" slot="header"
[backAction]="close" [backAction]="close"
showBackButton showBackButton
[pageTitle]="title" [pageTitle]="titleKey | i18n"
></popup-header> ></popup-header>
<vault-cipher-form-generator <vault-cipher-form-generator
[type]="params.type" [type]="params.type"
[uri]="uri" [uri]="uri"
(valueGenerated)="onValueGenerated($event)" (valueGenerated)="onValueGenerated($event)"
(algorithmSelected)="onAlgorithmSelected($event)"
></vault-cipher-form-generator> ></vault-cipher-form-generator>
<popup-footer slot="footer"> <popup-footer slot="footer">
@@ -19,6 +20,7 @@
buttonType="primary" buttonType="primary"
(click)="selectValue()" (click)="selectValue()"
data-testid="select-button" data-testid="select-button"
[disabled]="!(selectButtonText && generatedValue)"
> >
{{ selectButtonText }} {{ selectButtonText }}
</button> </button>

View File

@@ -1,15 +1,18 @@
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
import { Component, EventEmitter, Input, Output } from "@angular/core"; import { Component, EventEmitter, Input, Output } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { AlgorithmInfo } from "@bitwarden/generator-core";
import { CipherFormGeneratorComponent } from "@bitwarden/vault"; import { CipherFormGeneratorComponent } from "@bitwarden/vault";
import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service"; import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service";
import { import {
GeneratorDialogAction,
GeneratorDialogParams, GeneratorDialogParams,
GeneratorDialogResult, GeneratorDialogResult,
VaultGeneratorDialogComponent, VaultGeneratorDialogComponent,
@@ -21,8 +24,9 @@ import {
standalone: true, standalone: true,
}) })
class MockCipherFormGenerator { class MockCipherFormGenerator {
@Input() type: "password" | "username"; @Input() type: "password" | "username" = "password";
@Input() uri: string; @Output() algorithmSelected: EventEmitter<AlgorithmInfo> = new EventEmitter();
@Input() uri: string = "";
@Output() valueGenerated = new EventEmitter<string>(); @Output() valueGenerated = new EventEmitter<string>();
} }
@@ -53,34 +57,87 @@ describe("VaultGeneratorDialogComponent", () => {
fixture = TestBed.createComponent(VaultGeneratorDialogComponent); fixture = TestBed.createComponent(VaultGeneratorDialogComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
});
it("should create", () => {
fixture.detectChanges(); fixture.detectChanges();
expect(component).toBeTruthy();
}); });
it("should use the appropriate text based on generator type", () => { it("should show password generator title", () => {
expect(component["title"]).toBe("passwordGenerator"); const header = fixture.debugElement.query(By.css("popup-header")).componentInstance;
expect(component["selectButtonText"]).toBe("useThisPassword"); expect(header.pageTitle).toBe("passwordGenerator");
dialogData.type = "username";
fixture = TestBed.createComponent(VaultGeneratorDialogComponent);
component = fixture.componentInstance;
expect(component["title"]).toBe("usernameGenerator");
expect(component["selectButtonText"]).toBe("useThisUsername");
}); });
it("should close the dialog with the generated value when the user selects it", () => { it("should pass type to cipher form generator", () => {
component["generatedValue"] = "generated-value"; const generator = fixture.debugElement.query(
By.css("vault-cipher-form-generator"),
).componentInstance;
expect(generator.type).toBe("password");
});
fixture.nativeElement.querySelector("button[data-testid='select-button']").click(); it("should enable select button when value is generated", () => {
component.onAlgorithmSelected({ useGeneratedValue: "Test" } as any);
component.onValueGenerated("test-password");
fixture.detectChanges();
const button = fixture.debugElement.query(
By.css("[data-testid='select-button']"),
).nativeElement;
expect(button.disabled).toBe(false);
});
it("should disable the button if no value has been generated", () => {
const generator = fixture.debugElement.query(
By.css("vault-cipher-form-generator"),
).componentInstance;
generator.algorithmSelected.emit({ useGeneratedValue: "Use Password" } as any);
fixture.detectChanges();
const button = fixture.debugElement.query(
By.css("[data-testid='select-button']"),
).nativeElement;
expect(button.disabled).toBe(true);
});
it("should disable the button if no algorithm is selected", () => {
const generator = fixture.debugElement.query(
By.css("vault-cipher-form-generator"),
).componentInstance;
generator.valueGenerated.emit("test-password");
fixture.detectChanges();
const button = fixture.debugElement.query(
By.css("[data-testid='select-button']"),
).nativeElement;
expect(button.disabled).toBe(true);
});
it("should update button text when algorithm is selected", () => {
component.onAlgorithmSelected({ useGeneratedValue: "Use This Password" } as any);
fixture.detectChanges();
const button = fixture.debugElement.query(
By.css("[data-testid='select-button']"),
).nativeElement;
expect(button.textContent.trim()).toBe("Use This Password");
});
it("should close with generated value when selected", () => {
component.onAlgorithmSelected({ useGeneratedValue: "Test" } as any);
component.onValueGenerated("test-password");
fixture.detectChanges();
fixture.debugElement.query(By.css("[data-testid='select-button']")).nativeElement.click();
expect(mockDialogRef.close).toHaveBeenCalledWith({ expect(mockDialogRef.close).toHaveBeenCalledWith({
action: "selected", action: GeneratorDialogAction.Selected,
generatedValue: "generated-value", generatedValue: "test-password",
});
});
it("should close with canceled action when dismissed", () => {
fixture.debugElement.query(By.css("popup-header")).componentInstance.backAction();
expect(mockDialogRef.close).toHaveBeenCalledWith({
action: GeneratorDialogAction.Canceled,
}); });
}); });
}); });

View File

@@ -7,6 +7,8 @@ import { Component, Inject } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonModule, DialogService } from "@bitwarden/components"; import { ButtonModule, DialogService } from "@bitwarden/components";
import { AlgorithmInfo } from "@bitwarden/generator-core";
import { I18nPipe } from "@bitwarden/ui-common";
import { CipherFormGeneratorComponent } from "@bitwarden/vault"; import { CipherFormGeneratorComponent } from "@bitwarden/vault";
import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component"; import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component";
@@ -39,13 +41,12 @@ export enum GeneratorDialogAction {
CommonModule, CommonModule,
CipherFormGeneratorComponent, CipherFormGeneratorComponent,
ButtonModule, ButtonModule,
I18nPipe,
], ],
}) })
export class VaultGeneratorDialogComponent { export class VaultGeneratorDialogComponent {
protected title = this.i18nService.t(this.isPassword ? "passwordGenerator" : "usernameGenerator"); protected selectButtonText: string | undefined;
protected selectButtonText = this.i18nService.t( protected titleKey = this.isPassword ? "passwordGenerator" : "usernameGenerator";
this.isPassword ? "useThisPassword" : "useThisUsername",
);
/** /**
* Whether the dialog is generating a password/passphrase. If false, it is generating a username. * Whether the dialog is generating a password/passphrase. If false, it is generating a username.
@@ -92,6 +93,16 @@ export class VaultGeneratorDialogComponent {
this.generatedValue = value; this.generatedValue = value;
} }
onAlgorithmSelected = (selected?: AlgorithmInfo) => {
if (selected) {
this.selectButtonText = selected.useGeneratedValue;
} else {
// default to email
this.selectButtonText = this.i18nService.t("useThisEmail");
}
this.generatedValue = undefined;
};
/** /**
* Opens the vault generator dialog in a full screen dialog. * Opens the vault generator dialog in a full screen dialog.
*/ */

View File

@@ -4,7 +4,7 @@
<vault-cipher-form-generator <vault-cipher-form-generator
[type]="data.type" [type]="data.type"
(valueGenerated)="onCredentialGenerated($event)" (valueGenerated)="onCredentialGenerated($event)"
[onAlgorithmSelected]="onAlgorithmSelected" (algorithmSelected)="onAlgorithmSelected($event)"
/> />
<bit-item> <bit-item>
<button <button

View File

@@ -3,6 +3,7 @@ import { CommonModule } from "@angular/common";
import { Component, Inject } from "@angular/core"; import { Component, Inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { import {
ButtonModule, ButtonModule,
DialogModule, DialogModule,
@@ -44,16 +45,17 @@ export class CredentialGeneratorDialogComponent {
constructor( constructor(
@Inject(DIALOG_DATA) protected data: CredentialGeneratorParams, @Inject(DIALOG_DATA) protected data: CredentialGeneratorParams,
private dialogService: DialogService, private dialogService: DialogService,
private i18nService: I18nService,
) {} ) {}
onAlgorithmSelected = (selected?: AlgorithmInfo) => { onAlgorithmSelected = (selected?: AlgorithmInfo) => {
if (selected) { if (selected) {
this.buttonLabel = selected.useGeneratedValue; this.buttonLabel = selected.useGeneratedValue;
} else { } else {
// clear the credential value when the user is // default to email
// selecting the credential generation algorithm this.buttonLabel = this.i18nService.t("useThisEmail");
this.credentialValue = undefined;
} }
this.credentialValue = undefined;
}; };
applyCredentials = () => { applyCredentials = () => {

View File

@@ -1,12 +1,13 @@
<bit-dialog dialogSize="default" background="alt"> <bit-dialog dialogSize="default" background="alt">
<span bitDialogTitle> <span bitDialogTitle>
{{ title }} {{ titleKey | i18n }}
</span> </span>
<ng-container bitDialogContent> <ng-container bitDialogContent>
<vault-cipher-form-generator <vault-cipher-form-generator
[type]="params.type" [type]="params.type"
[uri]="uri" [uri]="uri"
(valueGenerated)="onValueGenerated($event)" (valueGenerated)="onValueGenerated($event)"
(algorithmSelected)="onAlgorithmSelected($event)"
disableMargin disableMargin
></vault-cipher-form-generator> ></vault-cipher-form-generator>
</ng-container> </ng-container>
@@ -17,8 +18,9 @@
buttonType="primary" buttonType="primary"
(click)="selectValue()" (click)="selectValue()"
data-testid="select-button" data-testid="select-button"
[disabled]="!(buttonLabel && generatedValue)"
> >
{{ selectButtonText }} {{ buttonLabel }}
</button> </button>
</ng-container> </ng-container>
</bit-dialog> </bit-dialog>

View File

@@ -1,19 +1,19 @@
// FIXME: Update this file to be type safe and remove this and next line import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
// @ts-strict-ignore import { Component, Input, Output, EventEmitter } from "@angular/core";
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { AlgorithmInfo } from "@bitwarden/generator-core";
import { CipherFormGeneratorComponent } from "@bitwarden/vault"; import { CipherFormGeneratorComponent } from "@bitwarden/vault";
import { import {
WebVaultGeneratorDialogAction, WebVaultGeneratorDialogAction,
WebVaultGeneratorDialogComponent, WebVaultGeneratorDialogComponent,
WebVaultGeneratorDialogParams, WebVaultGeneratorDialogResult,
} from "./web-generator-dialog.component"; } from "./web-generator-dialog.component";
@Component({ @Component({
@@ -22,7 +22,8 @@ import {
standalone: true, standalone: true,
}) })
class MockCipherFormGenerator { class MockCipherFormGenerator {
@Input() type: "password" | "username"; @Input() type: "password" | "username" = "password";
@Output() algorithmSelected: EventEmitter<AlgorithmInfo> = new EventEmitter();
@Input() uri?: string; @Input() uri?: string;
@Output() valueGenerated = new EventEmitter<string>(); @Output() valueGenerated = new EventEmitter<string>();
} }
@@ -30,35 +31,20 @@ class MockCipherFormGenerator {
describe("WebVaultGeneratorDialogComponent", () => { describe("WebVaultGeneratorDialogComponent", () => {
let component: WebVaultGeneratorDialogComponent; let component: WebVaultGeneratorDialogComponent;
let fixture: ComponentFixture<WebVaultGeneratorDialogComponent>; let fixture: ComponentFixture<WebVaultGeneratorDialogComponent>;
let dialogRef: MockProxy<DialogRef<WebVaultGeneratorDialogResult>>;
let dialogRef: MockProxy<DialogRef<any>>;
let mockI18nService: MockProxy<I18nService>; let mockI18nService: MockProxy<I18nService>;
beforeEach(async () => { beforeEach(async () => {
dialogRef = mock<DialogRef<any>>(); dialogRef = mock<DialogRef<WebVaultGeneratorDialogResult>>();
mockI18nService = mock<I18nService>(); mockI18nService = mock<I18nService>();
const mockDialogData: WebVaultGeneratorDialogParams = { type: "password" };
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, WebVaultGeneratorDialogComponent], imports: [NoopAnimationsModule, WebVaultGeneratorDialogComponent],
providers: [ providers: [
{ { provide: DialogRef, useValue: dialogRef },
provide: DialogRef, { provide: DIALOG_DATA, useValue: { type: "password" } },
useValue: dialogRef, { provide: I18nService, useValue: mockI18nService },
}, { provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
{
provide: DIALOG_DATA,
useValue: mockDialogData,
},
{
provide: I18nService,
useValue: mockI18nService,
},
{
provide: PlatformUtilsService,
useValue: mock<PlatformUtilsService>(),
},
], ],
}) })
.overrideComponent(WebVaultGeneratorDialogComponent, { .overrideComponent(WebVaultGeneratorDialogComponent, {
@@ -72,38 +58,73 @@ describe("WebVaultGeneratorDialogComponent", () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it("initializes without errors", () => { it("should create", () => {
fixture.detectChanges();
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
it("closes the dialog with 'canceled' result when close is called", () => { it("should enable button when value and algorithm are selected", () => {
const closeSpy = jest.spyOn(dialogRef, "close"); const generator = fixture.debugElement.query(
By.css("vault-cipher-form-generator"),
).componentInstance;
(component as any).close(); generator.algorithmSelected.emit({ useGeneratedValue: "Use Password" } as any);
generator.valueGenerated.emit("test-password");
fixture.detectChanges();
expect(closeSpy).toHaveBeenCalledWith({ const button = fixture.debugElement.query(
By.css("[data-testid='select-button']"),
).nativeElement;
expect(button.disabled).toBe(false);
});
it("should disable the button if no value has been generated", () => {
const generator = fixture.debugElement.query(
By.css("vault-cipher-form-generator"),
).componentInstance;
generator.algorithmSelected.emit({ useGeneratedValue: "Use Password" } as any);
fixture.detectChanges();
const button = fixture.debugElement.query(
By.css("[data-testid='select-button']"),
).nativeElement;
expect(button.disabled).toBe(true);
});
it("should disable the button if no algorithm is selected", () => {
const generator = fixture.debugElement.query(
By.css("vault-cipher-form-generator"),
).componentInstance;
generator.valueGenerated.emit("test-password");
fixture.detectChanges();
const button = fixture.debugElement.query(
By.css("[data-testid='select-button']"),
).nativeElement;
expect(button.disabled).toBe(true);
});
it("should close with selected value when confirmed", () => {
const generator = fixture.debugElement.query(
By.css("vault-cipher-form-generator"),
).componentInstance;
generator.algorithmSelected.emit({ useGeneratedValue: "Use Password" } as any);
generator.valueGenerated.emit("test-password");
fixture.detectChanges();
fixture.debugElement.query(By.css("[data-testid='select-button']")).nativeElement.click();
expect(dialogRef.close).toHaveBeenCalledWith({
action: WebVaultGeneratorDialogAction.Selected,
generatedValue: "test-password",
});
});
it("should close with canceled action when dismissed", () => {
component["close"]();
expect(dialogRef.close).toHaveBeenCalledWith({
action: WebVaultGeneratorDialogAction.Canceled, action: WebVaultGeneratorDialogAction.Canceled,
}); });
}); });
it("closes the dialog with 'selected' result when selectValue is called", () => {
const closeSpy = jest.spyOn(dialogRef, "close");
const generatedValue = "generated-value";
component.onValueGenerated(generatedValue);
(component as any).selectValue();
expect(closeSpy).toHaveBeenCalledWith({
action: WebVaultGeneratorDialogAction.Selected,
generatedValue: generatedValue,
});
});
it("updates generatedValue when onValueGenerated is called", () => {
const generatedValue = "new-generated-value";
component.onValueGenerated(generatedValue);
expect((component as any).generatedValue).toBe(generatedValue);
});
}); });

View File

@@ -6,6 +6,8 @@ import { Component, Inject } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components";
import { AlgorithmInfo } from "@bitwarden/generator-core";
import { I18nPipe } from "@bitwarden/ui-common";
import { CipherFormGeneratorComponent } from "@bitwarden/vault"; import { CipherFormGeneratorComponent } from "@bitwarden/vault";
export interface WebVaultGeneratorDialogParams { export interface WebVaultGeneratorDialogParams {
@@ -27,13 +29,11 @@ export enum WebVaultGeneratorDialogAction {
selector: "web-vault-generator-dialog", selector: "web-vault-generator-dialog",
templateUrl: "./web-generator-dialog.component.html", templateUrl: "./web-generator-dialog.component.html",
standalone: true, standalone: true,
imports: [CommonModule, CipherFormGeneratorComponent, ButtonModule, DialogModule], imports: [CommonModule, CipherFormGeneratorComponent, ButtonModule, DialogModule, I18nPipe],
}) })
export class WebVaultGeneratorDialogComponent { export class WebVaultGeneratorDialogComponent {
protected title = this.i18nService.t(this.isPassword ? "passwordGenerator" : "usernameGenerator"); protected titleKey = this.isPassword ? "passwordGenerator" : "usernameGenerator";
protected selectButtonText = this.i18nService.t( protected buttonLabel: string | undefined;
this.isPassword ? "useThisPassword" : "useThisUsername",
);
/** /**
* Whether the dialog is generating a password/passphrase. If false, it is generating a username. * Whether the dialog is generating a password/passphrase. If false, it is generating a username.
@@ -80,6 +80,16 @@ export class WebVaultGeneratorDialogComponent {
this.generatedValue = value; this.generatedValue = value;
} }
onAlgorithmSelected = (selected?: AlgorithmInfo) => {
if (selected) {
this.buttonLabel = selected.useGeneratedValue;
} else {
// default to email
this.buttonLabel = this.i18nService.t("useThisEmail");
}
this.generatedValue = undefined;
};
/** /**
* Opens the vault generator dialog. * Opens the vault generator dialog.
*/ */

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Input, Output } from "@angular/core"; import { Component, EventEmitter, Input, Output } from "@angular/core";
@@ -18,9 +16,6 @@ import { AlgorithmInfo, GeneratedCredential } from "@bitwarden/generator-core";
imports: [CommonModule, GeneratorModule], imports: [CommonModule, GeneratorModule],
}) })
export class CipherFormGeneratorComponent { export class CipherFormGeneratorComponent {
@Input()
onAlgorithmSelected: (selected: AlgorithmInfo) => void;
@Input() @Input()
uri: string = ""; uri: string = "";
@@ -28,17 +23,25 @@ export class CipherFormGeneratorComponent {
* The type of generator form to show. * The type of generator form to show.
*/ */
@Input({ required: true }) @Input({ required: true })
type: "password" | "username"; type: "password" | "username" = "password";
/** Removes bottom margin of internal sections */ /** Removes bottom margin of internal sections */
@Input({ transform: coerceBooleanProperty }) disableMargin = false; @Input({ transform: coerceBooleanProperty }) disableMargin = false;
@Output()
algorithmSelected = new EventEmitter<AlgorithmInfo>();
/** /**
* Emits an event when a new value is generated. * Emits an event when a new value is generated.
*/ */
@Output() @Output()
valueGenerated = new EventEmitter<string>(); valueGenerated = new EventEmitter<string>();
/** Event handler for when an algorithm is selected */
onAlgorithmSelected = (selected: AlgorithmInfo) => {
this.algorithmSelected.emit(selected);
};
/** Event handler for both generation components */ /** Event handler for both generation components */
onCredentialGenerated = (generatedCred: GeneratedCredential) => { onCredentialGenerated = (generatedCred: GeneratedCredential) => {
this.valueGenerated.emit(generatedCred.credential); this.valueGenerated.emit(generatedCred.credential);