1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-22 16:29:09 +01:00

[PM-8803] Edit Custom Fields (#10054)

* initial add of custom fields

* add fields for custom field

* integrate custom field into cipher form service for text fields

* add hidden field type

* add boolean custom field

* add linked option type

* add testids for automated testing

* add edit option for each custom field

* update dialog component name to match add/edit nature

* add delete button for fields

* initial add of drag and drop

* collect tailwind styles from vault components

* add drag and drop functionality with announcement

* add reorder via keyboard

* update tests to match functionality

* account for partial edit of custom fields

* fix change detection for new fields

* add label's to the edit/reorder translations

* update dynamic heading to be inline

* add validation/required for field label

* disable toggle button on hidden fields when the user cannot view passwords

* remove the need for passing `updatedCipherView` by only using a single instance of `CustomFieldsComponent`

* lint fix

* use bitLink styles rather than manually defining tailwind classes

* use submit action, no duplicated button and allows for form submission via enter

* add documentation for `newField`
This commit is contained in:
Nick Krantz 2024-07-17 09:11:42 -05:00 committed by GitHub
parent a1c5cc6dbf
commit 83d141c914
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1223 additions and 4 deletions

View File

@ -3642,5 +3642,105 @@
}, },
"loading": { "loading": {
"message": "Loading" "message": "Loading"
},
"addField": {
"message": "Add field"
},
"add": {
"message": "Add"
},
"fieldType": {
"message": "Field type"
},
"fieldLabel": {
"message": "Field label"
},
"textHelpText": {
"message": "Use text fields for data like security questions"
},
"hiddenHelpText": {
"message": "Use hidden fields for sensitive data like a password"
},
"checkBoxHelpText":{
"message": "Use checkboxes if you'd like to auto-fill a form's checkbox, like a remember email"
},
"linkedHelpText": {
"message": "Use a linked field when you are experiencing auto-fill issues for a specific website."
},
"linkedLabelHelpText": {
"message": "Enter the the field's html id, name, aria-label, or placeholder."
},
"editField": {
"message": "Edit field"
},
"editFieldLabel": {
"message": "Edit $LABEL$",
"placeholders": {
"label": {
"content": "$1",
"example": "Custom field"
}
}
},
"deleteCustomField": {
"message": "Delete $LABEL$",
"placeholders": {
"label": {
"content": "$1",
"example": "Custom field"
}
}
},
"fieldAdded": {
"message": "$LABEL$ added",
"placeholders": {
"label": {
"content": "$1",
"example": "Custom field"
}
}
},
"reorderToggleButton": {
"message": "Reorder $LABEL$. Use arrow key to move item up or down.",
"placeholders": {
"label": {
"content": "$1",
"example": "Custom field"
}
}
},
"reorderFieldUp":{
"message": "$LABEL$ moved up, position $INDEX$ of $LENGTH$",
"placeholders": {
"label": {
"content": "$1",
"example": "Custom field"
},
"index": {
"content": "$2",
"example": "1"
},
"length": {
"content": "$3",
"example": "3"
}
}
},
"reorderFieldDown":{
"message": "$LABEL$ moved down, position $INDEX$ of $LENGTH$",
"placeholders": {
"label": {
"content": "$1",
"example": "Custom field"
},
"index": {
"content": "$2",
"example": "1"
},
"length": {
"content": "$3",
"example": "3"
}
}
} }
} }

View File

@ -6,6 +6,7 @@ config.content = [
"../../libs/components/src/**/*.{html,ts}", "../../libs/components/src/**/*.{html,ts}",
"../../libs/auth/src/**/*.{html,ts}", "../../libs/auth/src/**/*.{html,ts}",
"../../libs/angular/src/**/*.{html,ts}", "../../libs/angular/src/**/*.{html,ts}",
"../../libs/vault/src/**/*.{html,ts}",
]; ];
module.exports = config; module.exports = config;

View File

@ -3,6 +3,7 @@ import { CipherFormConfig } from "@bitwarden/vault";
import { AdditionalOptionsSectionComponent } from "./components/additional-options/additional-options-section.component"; import { AdditionalOptionsSectionComponent } from "./components/additional-options/additional-options-section.component";
import { CardDetailsSectionComponent } from "./components/card-details-section/card-details-section.component"; import { CardDetailsSectionComponent } from "./components/card-details-section/card-details-section.component";
import { CustomFieldsComponent } from "./components/custom-fields/custom-fields.component";
import { IdentitySectionComponent } from "./components/identity/identity.component"; import { IdentitySectionComponent } from "./components/identity/identity.component";
import { ItemDetailsSectionComponent } from "./components/item-details/item-details-section.component"; import { ItemDetailsSectionComponent } from "./components/item-details/item-details-section.component";
@ -15,6 +16,7 @@ export type CipherForm = {
additionalOptions?: AdditionalOptionsSectionComponent["additionalOptionsForm"]; additionalOptions?: AdditionalOptionsSectionComponent["additionalOptionsForm"];
cardDetails?: CardDetailsSectionComponent["cardDetailsForm"]; cardDetails?: CardDetailsSectionComponent["cardDetailsForm"];
identityDetails?: IdentitySectionComponent["identityForm"]; identityDetails?: IdentitySectionComponent["identityForm"];
customFields?: CustomFieldsComponent["customFieldsForm"];
}; };
/** /**

View File

@ -13,8 +13,17 @@
<bit-label>{{ "passwordPrompt" | i18n }}</bit-label> <bit-label>{{ "passwordPrompt" | i18n }}</bit-label>
</bit-form-control> </bit-form-control>
<!-- TODO: Add "+ Add Field" button for Custom Fields - PM-8803 --> <button
bitLink
type="button"
linkType="primary"
*ngIf="!hasCustomFields && !isPartialEdit"
(click)="addCustomField()"
>
<i class="bwi bwi-plus tw-font-bold" aria-hidden="true"></i>
{{ "addField" | i18n }}
</button>
</bit-card> </bit-card>
</bit-section> </bit-section>
<!-- TODO: Add Custom Fields section component - PM-8803 --> <vault-custom-fields (numberOfFieldsChange)="handleCustomFieldChange($event)"></vault-custom-fields>

View File

@ -1,3 +1,4 @@
import { Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject } from "rxjs";
@ -7,9 +8,17 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { PasswordRepromptService } from "../../../services/password-reprompt.service"; import { PasswordRepromptService } from "../../../services/password-reprompt.service";
import { CipherFormContainer } from "../../cipher-form-container"; import { CipherFormContainer } from "../../cipher-form-container";
import { CustomFieldsComponent } from "../custom-fields/custom-fields.component";
import { AdditionalOptionsSectionComponent } from "./additional-options-section.component"; import { AdditionalOptionsSectionComponent } from "./additional-options-section.component";
@Component({
standalone: true,
selector: "vault-custom-fields",
template: "",
})
class MockCustomFieldsComponent {}
describe("AdditionalOptionsSectionComponent", () => { describe("AdditionalOptionsSectionComponent", () => {
let component: AdditionalOptionsSectionComponent; let component: AdditionalOptionsSectionComponent;
let fixture: ComponentFixture<AdditionalOptionsSectionComponent>; let fixture: ComponentFixture<AdditionalOptionsSectionComponent>;
@ -31,7 +40,16 @@ describe("AdditionalOptionsSectionComponent", () => {
{ provide: PasswordRepromptService, useValue: passwordRepromptService }, { provide: PasswordRepromptService, useValue: passwordRepromptService },
{ provide: I18nService, useValue: mock<I18nService>() }, { provide: I18nService, useValue: mock<I18nService>() },
], ],
}).compileComponents(); })
.overrideComponent(AdditionalOptionsSectionComponent, {
remove: {
imports: [CustomFieldsComponent],
},
add: {
imports: [MockCustomFieldsComponent],
},
})
.compileComponents();
fixture = TestBed.createComponent(AdditionalOptionsSectionComponent); fixture = TestBed.createComponent(AdditionalOptionsSectionComponent);
component = fixture.componentInstance; component = fixture.componentInstance;

View File

@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core"; import { ChangeDetectorRef, Component, OnInit, ViewChild } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { shareReplay } from "rxjs"; import { shareReplay } from "rxjs";
@ -10,6 +10,7 @@ import {
CardComponent, CardComponent,
CheckboxModule, CheckboxModule,
FormFieldModule, FormFieldModule,
LinkModule,
SectionComponent, SectionComponent,
SectionHeaderComponent, SectionHeaderComponent,
TypographyModule, TypographyModule,
@ -17,12 +18,14 @@ import {
import { PasswordRepromptService } from "../../../services/password-reprompt.service"; import { PasswordRepromptService } from "../../../services/password-reprompt.service";
import { CipherFormContainer } from "../../cipher-form-container"; import { CipherFormContainer } from "../../cipher-form-container";
import { CustomFieldsComponent } from "../custom-fields/custom-fields.component";
@Component({ @Component({
selector: "vault-additional-options-section", selector: "vault-additional-options-section",
templateUrl: "./additional-options-section.component.html", templateUrl: "./additional-options-section.component.html",
standalone: true, standalone: true,
imports: [ imports: [
CommonModule,
SectionComponent, SectionComponent,
SectionHeaderComponent, SectionHeaderComponent,
TypographyModule, TypographyModule,
@ -32,9 +35,13 @@ import { CipherFormContainer } from "../../cipher-form-container";
ReactiveFormsModule, ReactiveFormsModule,
CheckboxModule, CheckboxModule,
CommonModule, CommonModule,
CustomFieldsComponent,
LinkModule,
], ],
}) })
export class AdditionalOptionsSectionComponent implements OnInit { export class AdditionalOptionsSectionComponent implements OnInit {
@ViewChild(CustomFieldsComponent) customFieldsComponent: CustomFieldsComponent;
additionalOptionsForm = this.formBuilder.group({ additionalOptionsForm = this.formBuilder.group({
notes: [null as string], notes: [null as string],
reprompt: [false], reprompt: [false],
@ -44,10 +51,17 @@ export class AdditionalOptionsSectionComponent implements OnInit {
shareReplay({ refCount: false, bufferSize: 1 }), shareReplay({ refCount: false, bufferSize: 1 }),
); );
/** When false when the add field button should be displayed in the Additional Options section */
hasCustomFields = false;
/** True when the form is in `partial-edit` mode */
isPartialEdit = false;
constructor( constructor(
private cipherFormContainer: CipherFormContainer, private cipherFormContainer: CipherFormContainer,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private passwordRepromptService: PasswordRepromptService, private passwordRepromptService: PasswordRepromptService,
private changeDetectorRef: ChangeDetectorRef,
) { ) {
this.cipherFormContainer.registerChildForm("additionalOptions", this.additionalOptionsForm); this.cipherFormContainer.registerChildForm("additionalOptions", this.additionalOptionsForm);
@ -70,6 +84,22 @@ export class AdditionalOptionsSectionComponent implements OnInit {
if (this.cipherFormContainer.config.mode === "partial-edit") { if (this.cipherFormContainer.config.mode === "partial-edit") {
this.additionalOptionsForm.disable(); this.additionalOptionsForm.disable();
this.isPartialEdit = true;
} }
} }
/** Opens the add custom field dialog */
addCustomField() {
this.customFieldsComponent.openAddEditCustomFieldDialog();
}
/** Update the local state when the number of fields changes */
handleCustomFieldChange(numberOfCustomFields: number) {
this.hasCustomFields = numberOfCustomFields > 0;
// The event that triggers `handleCustomFieldChange` can occur within
// the CustomFieldComponent `ngOnInit` lifecycle hook, so we need to
// manually trigger change detection to update the view.
this.changeDetectorRef.detectChanges();
}
} }

View File

@ -110,6 +110,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
* @protected * @protected
*/ */
protected updatedCipherView: CipherView | null; protected updatedCipherView: CipherView | null;
protected loading: boolean = true; protected loading: boolean = true;
CipherType = CipherType; CipherType = CipherType;

View File

@ -0,0 +1,48 @@
<form [formGroup]="customFieldForm" [bitSubmit]="submit">
<bit-dialog>
<span bitDialogTitle>
{{ (variant === "add" ? "addField" : "editField") | i18n }}
</span>
<div bitDialogContent>
<bit-form-field *ngIf="variant === 'add'">
<bit-label>{{ "fieldType" | i18n }}</bit-label>
<bit-select id="fieldType" formControlName="type">
<bit-option
*ngFor="let type of fieldTypeOptions"
[value]="type.value"
[label]="type.name"
></bit-option>
</bit-select>
<bit-hint>
{{ getTypeHint() }}
</bit-hint>
</bit-form-field>
<bit-form-field disableMargin>
<bit-label>{{ "fieldLabel" | i18n }}</bit-label>
<input bitInput id="fieldLabel" formControlName="label" type="text" />
<bit-hint *ngIf="customFieldForm.value.type === FieldType.Linked">
{{ "linkedLabelHelpText" | i18n }}
</bit-hint>
</bit-form-field>
</div>
<div bitDialogFooter class="tw-flex tw-gap-2 tw-w-full">
<button bitButton buttonType="primary" type="submit" [disabled]="customFieldForm.invalid">
{{ (variant === "add" ? "add" : "edit") | i18n }}
</button>
<button bitButton bitDialogClose buttonType="secondary" type="button">
{{ "cancel" | i18n }}
</button>
<button
*ngIf="variant === 'edit'"
type="button"
buttonType="danger"
class="tw-border-0 tw-ml-auto"
bitIconButton="bwi-trash"
[appA11yTitle]="'deleteCustomField' | i18n: customFieldForm.value.label"
(click)="removeField()"
></button>
</div>
</bit-dialog>
</form>

View File

@ -0,0 +1,72 @@
import { DIALOG_DATA } from "@angular/cdk/dialog";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { FieldType } from "@bitwarden/common/vault/enums";
import {
AddEditCustomFieldDialogComponent,
AddEditCustomFieldDialogData,
} from "./add-edit-custom-field-dialog.component";
describe("AddEditCustomFieldDialogComponent", () => {
let component: AddEditCustomFieldDialogComponent;
let fixture: ComponentFixture<AddEditCustomFieldDialogComponent>;
const addField = jest.fn();
const updateLabel = jest.fn();
const removeField = jest.fn();
const dialogData = {
addField,
updateLabel,
removeField,
} as AddEditCustomFieldDialogData;
beforeEach(async () => {
addField.mockClear();
updateLabel.mockClear();
removeField.mockClear();
await TestBed.configureTestingModule({
imports: [AddEditCustomFieldDialogComponent],
providers: [
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: DIALOG_DATA, useValue: dialogData },
],
}).compileComponents();
fixture = TestBed.createComponent(AddEditCustomFieldDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges;
});
it("creates", () => {
expect(component).toBeTruthy();
});
it("calls `addField` from DIALOG_DATA on with the type and label", () => {
component.customFieldForm.setValue({ type: FieldType.Text, label: "Test Label" });
component.submit();
expect(addField).toHaveBeenCalledWith(FieldType.Text, "Test Label");
});
it("calls `updateLabel` from DIALOG_DATA with the new label", () => {
component.variant = "edit";
dialogData.editLabelConfig = { index: 0, label: "Test Label" };
component.customFieldForm.setValue({ type: FieldType.Text, label: "Test Label 2" });
component.submit();
expect(updateLabel).toHaveBeenCalledWith(0, "Test Label 2");
});
it("calls `removeField` from DIALOG_DATA with the respective index", () => {
dialogData.editLabelConfig = { index: 2, label: "Test Label" };
component.removeField();
expect(removeField).toHaveBeenCalledWith(2);
});
});

View File

@ -0,0 +1,120 @@
import { DIALOG_DATA } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Inject } from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { FieldType } from "@bitwarden/common/vault/enums";
import {
AsyncActionsModule,
ButtonModule,
DialogModule,
FormFieldModule,
IconButtonModule,
SelectModule,
} from "@bitwarden/components";
export type AddEditCustomFieldDialogData = {
addField: (type: FieldType, label: string) => void;
updateLabel: (index: number, label: string) => void;
removeField: (index: number) => void;
/** When provided, dialog will display edit label variants */
editLabelConfig?: { index: number; label: string };
};
@Component({
standalone: true,
selector: "vault-add-edit-custom-field-dialog",
templateUrl: "./add-edit-custom-field-dialog.component.html",
imports: [
CommonModule,
JslibModule,
DialogModule,
ButtonModule,
FormFieldModule,
SelectModule,
ReactiveFormsModule,
IconButtonModule,
AsyncActionsModule,
],
})
export class AddEditCustomFieldDialogComponent {
variant: "add" | "edit";
customFieldForm = this.formBuilder.group({
type: FieldType.Text,
label: ["", Validators.required],
});
fieldTypeOptions = [
{ name: this.i18nService.t("cfTypeText"), value: FieldType.Text },
{ name: this.i18nService.t("cfTypeHidden"), value: FieldType.Hidden },
{ name: this.i18nService.t("cfTypeBoolean"), value: FieldType.Boolean },
{ name: this.i18nService.t("cfTypeLinked"), value: FieldType.Linked },
];
FieldType = FieldType;
constructor(
@Inject(DIALOG_DATA) private data: AddEditCustomFieldDialogData,
private formBuilder: FormBuilder,
private i18nService: I18nService,
) {
this.variant = data.editLabelConfig ? "edit" : "add";
if (this.variant === "edit") {
this.customFieldForm.controls.label.setValue(data.editLabelConfig.label);
this.customFieldForm.controls.type.disable();
}
}
getTypeHint(): string {
switch (this.customFieldForm.get("type")?.value) {
case FieldType.Text:
return this.i18nService.t("textHelpText");
case FieldType.Hidden:
return this.i18nService.t("hiddenHelpText");
case FieldType.Boolean:
return this.i18nService.t("checkBoxHelpText");
case FieldType.Linked:
return this.i18nService.t("linkedHelpText");
default:
return "";
}
}
/** Direct the form submission to the proper action */
submit = () => {
if (this.variant === "add") {
this.addField();
} else {
this.updateLabel();
}
};
/** Invoke the `addField` callback with the custom field details */
addField() {
if (this.customFieldForm.invalid) {
return;
}
const { type, label } = this.customFieldForm.value;
this.data.addField(type, label);
}
/** Invoke the `updateLabel` callback with the new label */
updateLabel() {
if (this.customFieldForm.invalid) {
return;
}
const { label } = this.customFieldForm.value;
this.data.updateLabel(this.data.editLabelConfig.index, label);
}
/** Invoke the `removeField` callback */
removeField() {
this.data.removeField(this.data.editLabelConfig.index);
}
}

View File

@ -0,0 +1,111 @@
<bit-section *ngIf="hasCustomFields">
<bit-section-header>
<h2 bitTypography="h5">{{ "customFields" | i18n }}</h2>
</bit-section-header>
<form [formGroup]="customFieldsForm">
<bit-card formArrayName="fields" cdkDropList (cdkDropListDropped)="drop($event)">
<div
*ngFor="let field of fields.controls; let i = index"
[formGroupName]="i"
class="tw-flex tw-p-3 -tw-mx-3 tw-gap-4 tw-bg-background tw-rounded-lg first:-tw-mt-3 last-of-type:tw-mb-3"
[ngClass]="{
'tw-items-center': field.value.type === FieldType.Boolean
}"
cdkDrag
#customFieldRow
>
<!-- Text Field -->
<bit-form-field *ngIf="field.value.type === FieldType.Text" class="tw-flex-1" disableMargin>
<bit-label>{{ field.value.name }}</bit-label>
<input bitInput formControlName="value" data-testid="custom-text-field" />
</bit-form-field>
<!-- Hidden Field -->
<bit-form-field
*ngIf="field.value.type === FieldType.Hidden"
class="tw-flex-1"
disableMargin
>
<bit-label>{{ field.value.name }}</bit-label>
<input
bitInput
formControlName="value"
type="password"
data-testid="custom-hidden-field"
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
data-testid="visibility-for-custom-hidden-field"
[disabled]="!canViewPasswords(i)"
></button>
</bit-form-field>
<!-- Boolean Field -->
<bit-form-control
*ngIf="field.value.type === FieldType.Boolean"
class="tw-flex-1"
disableMargin
>
<input
bitCheckbox
formControlName="value"
type="checkbox"
data-testid="custom-boolean-field"
/>
<bit-label>{{ field.value.name }}</bit-label>
</bit-form-control>
<!-- Linked Field -->
<bit-form-field
*ngIf="field.value.type === FieldType.Linked"
class="tw-flex-1"
disableMargin
>
<bit-label>{{ field.value.name }}</bit-label>
<bit-select formControlName="linkedId" data-testid="custom-linked-field">
<bit-option
*ngFor="let option of linkedFieldOptions"
[value]="option.value"
[label]="option.name"
></bit-option>
</bit-select>
</bit-form-field>
<button
type="button"
(click)="openAddEditCustomFieldDialog({ index: i, label: field.value.name })"
[appA11yTitle]="'editFieldLabel' | i18n: field.value.name"
bitIconButton="bwi-pencil-square"
class="tw-self-end"
data-testid="edit-custom-field-button"
*ngIf="!isPartialEdit"
></button>
<button
type="button"
bitIconButton="bwi-hamburger"
class="tw-self-end"
cdkDragHandle
[appA11yTitle]="'reorderToggleButton' | i18n: field.value.name"
(keydown)="handleKeyDown($event, field.value.name, i)"
data-testid="reorder-toggle-button"
*ngIf="!isPartialEdit"
></button>
</div>
<button
type="button"
bitLink
linkType="primary"
(click)="openAddEditCustomFieldDialog()"
*ngIf="!isPartialEdit"
>
<i class="bwi bwi-plus tw-font-bold" aria-hidden="true"></i>
{{ "addField" | i18n }}
</button>
</bit-card>
</form>
</bit-section>

View File

@ -0,0 +1,373 @@
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { DialogRef } from "@angular/cdk/dialog";
import { CdkDragDrop } from "@angular/cdk/drag-drop";
import { DebugElement } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherType, FieldType, LoginLinkedId } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { DialogService } from "@bitwarden/components";
import { BitPasswordInputToggleDirective } from "../../../../../components/src/form-field/password-input-toggle.directive";
import { CipherFormContainer } from "../../cipher-form-container";
import { CustomField, CustomFieldsComponent } from "./custom-fields.component";
const mockFieldViews = [
{ type: FieldType.Text, name: "text label", value: "text value" },
{ type: FieldType.Hidden, name: "hidden label", value: "hidden value" },
{ type: FieldType.Boolean, name: "boolean label", value: "true" },
{ type: FieldType.Linked, name: "linked label", value: null, linkedId: 1 },
] as FieldView[];
let originalCipherView: CipherView | null = new CipherView();
originalCipherView.type = CipherType.Login;
originalCipherView.login = new LoginView();
describe("CustomFieldsComponent", () => {
let component: CustomFieldsComponent;
let fixture: ComponentFixture<CustomFieldsComponent>;
let open: jest.Mock;
let announce: jest.Mock;
let patchCipher: jest.Mock;
beforeEach(async () => {
open = jest.fn();
announce = jest.fn().mockResolvedValue(null);
patchCipher = jest.fn();
originalCipherView = new CipherView();
originalCipherView.type = CipherType.Login;
originalCipherView.login = new LoginView();
await TestBed.configureTestingModule({
imports: [CustomFieldsComponent],
providers: [
{
provide: I18nService,
useValue: { t: (...keys: string[]) => keys.filter(Boolean).join(" ") },
},
{
provide: CipherFormContainer,
useValue: { patchCipher, originalCipherView, registerChildForm: jest.fn(), config: {} },
},
{
provide: LiveAnnouncer,
useValue: { announce },
},
],
})
.overrideProvider(DialogService, {
useValue: {
open,
},
})
.compileComponents();
fixture = TestBed.createComponent(CustomFieldsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
describe("initializing", () => {
it("populates linkedFieldOptions", () => {
originalCipherView.login.linkedFieldOptions = new Map([
[1, { i18nKey: "one-i18", propertyKey: "one" }],
[2, { i18nKey: "two-i18", propertyKey: "two" }],
]);
component.ngOnInit();
expect(component.linkedFieldOptions).toEqual([
{ value: 1, name: "one-i18" },
{ value: 2, name: "two-i18" },
]);
});
it("populates customFieldsForm", () => {
originalCipherView.fields = mockFieldViews;
component.ngOnInit();
expect(component.fields.value).toEqual([
{
linkedId: null,
name: "text label",
type: FieldType.Text,
value: "text value",
newField: false,
},
{
linkedId: null,
name: "hidden label",
type: FieldType.Hidden,
value: "hidden value",
newField: false,
},
{
linkedId: null,
name: "boolean label",
type: FieldType.Boolean,
value: true,
newField: false,
},
{ linkedId: 1, name: "linked label", type: FieldType.Linked, value: null, newField: false },
]);
});
it("forbids a user to view hidden fields when the cipher `viewPassword` is false", () => {
originalCipherView.viewPassword = false;
originalCipherView.fields = mockFieldViews;
component.ngOnInit();
fixture.detectChanges();
const button = fixture.debugElement.query(By.directive(BitPasswordInputToggleDirective));
expect(button.nativeElement.disabled).toBe(true);
});
});
describe("adding new field", () => {
let close: jest.Mock;
beforeEach(() => {
close = jest.fn();
component.dialogRef = { close } as unknown as DialogRef;
});
it("closes the add dialog", () => {
component.addField(FieldType.Text, "test label");
expect(close).toHaveBeenCalled();
});
it("adds a unselected boolean field", () => {
component.addField(FieldType.Boolean, "bool label");
expect(component.fields.value).toEqual([
{
linkedId: null,
name: "bool label",
type: FieldType.Boolean,
value: false,
newField: true,
},
]);
});
it("auto-selects the first linked field option", () => {
component.linkedFieldOptions = [
{ value: LoginLinkedId.Password, name: "one" },
{ value: LoginLinkedId.Username, name: "two" },
];
component.addField(FieldType.Linked, "linked label");
expect(component.fields.value).toEqual([
{
linkedId: LoginLinkedId.Password,
name: "linked label",
type: FieldType.Linked,
value: null,
newField: true,
},
]);
});
it("adds text field", () => {
component.addField(FieldType.Text, "text label");
expect(component.fields.value).toEqual([
{ linkedId: null, name: "text label", type: FieldType.Text, value: null, newField: true },
]);
});
it("adds hidden field", () => {
component.addField(FieldType.Hidden, "hidden label");
expect(component.fields.value).toEqual([
{
linkedId: null,
name: "hidden label",
type: FieldType.Hidden,
value: null,
newField: true,
},
]);
});
it("announces the new input field", () => {
component.addField(FieldType.Text, "text label 2");
fixture.detectChanges();
expect(announce).toHaveBeenCalledWith("fieldAdded text label 2", "polite");
});
it("allows a user to view hidden fields when the cipher `viewPassword` is false", () => {
originalCipherView.viewPassword = false;
component.addField(FieldType.Hidden, "Hidden label");
fixture.detectChanges();
const button = fixture.debugElement.query(By.directive(BitPasswordInputToggleDirective));
expect(button.nativeElement.disabled).toBe(false);
});
});
describe("updating a field", () => {
beforeEach(() => {
originalCipherView.fields = [mockFieldViews[0]];
component.ngOnInit();
});
it("updates the value", () => {
component.fields.at(0).patchValue({ value: "new text value" });
const fieldView = new FieldView();
fieldView.name = "text label";
fieldView.value = "new text value";
fieldView.type = FieldType.Text;
expect(patchCipher).toHaveBeenCalledWith({ fields: [fieldView] });
});
it("updates the label", () => {
component.updateLabel(0, "new text label");
const fieldView = new FieldView();
fieldView.name = "new text label";
fieldView.value = "text value";
fieldView.type = FieldType.Text;
expect(patchCipher).toHaveBeenCalledWith({ fields: [fieldView] });
});
});
describe("removing field", () => {
beforeEach(() => {
originalCipherView.fields = [mockFieldViews[0]];
component.ngOnInit();
});
it("removes the field", () => {
component.removeField(0);
expect(patchCipher).toHaveBeenCalledWith({ fields: [] });
});
});
describe("reordering fields", () => {
let toggleItems: DebugElement[];
beforeEach(() => {
originalCipherView.fields = mockFieldViews;
component.ngOnInit();
fixture.detectChanges();
toggleItems = fixture.debugElement.queryAll(
By.css('button[data-testid="reorder-toggle-button"]'),
);
});
it("reorders the fields when dropped", () => {
expect(component.fields.value.map((f: CustomField) => f.name)).toEqual([
"text label",
"hidden label",
"boolean label",
"linked label",
]);
// Move second field to first
component.drop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<HTMLDivElement>);
const latestCallParams = patchCipher.mock.lastCall[0];
expect(latestCallParams.fields.map((f: FieldView) => f.name)).toEqual([
"hidden label",
"text label",
"boolean label",
"linked label",
]);
});
it("moves an item down in order via keyboard", () => {
// Move 3rd item (boolean label) down to 4th
toggleItems[2].triggerEventHandler("keydown", {
key: "ArrowDown",
preventDefault: jest.fn(),
});
const latestCallParams = patchCipher.mock.lastCall[0];
expect(latestCallParams.fields.map((f: FieldView) => f.name)).toEqual([
"text label",
"hidden label",
"linked label",
"boolean label",
]);
});
it("moves an item up in order via keyboard", () => {
// Move 2nd item (hidden label) up to 1st
toggleItems[1].triggerEventHandler("keydown", { key: "ArrowUp", preventDefault: jest.fn() });
const latestCallParams = patchCipher.mock.lastCall[0];
expect(latestCallParams.fields.map((f: FieldView) => f.name)).toEqual([
"hidden label",
"text label",
"boolean label",
"linked label",
]);
});
it("does not move the first item up", () => {
patchCipher.mockClear();
toggleItems[0].triggerEventHandler("keydown", { key: "ArrowUp", preventDefault: jest.fn() });
expect(patchCipher).not.toHaveBeenCalled();
});
it("does not move the last item down", () => {
patchCipher.mockClear();
toggleItems[toggleItems.length - 1].triggerEventHandler("keydown", {
key: "ArrowDown",
preventDefault: jest.fn(),
});
expect(patchCipher).not.toHaveBeenCalled();
});
it("announces the reorder up", () => {
// Move 2nd item up to 1st
toggleItems[1].triggerEventHandler("keydown", { key: "ArrowUp", preventDefault: jest.fn() });
// "reorder hidden label to position 1 of 4"
expect(announce).toHaveBeenCalledWith("reorderFieldUp hidden label 1 4", "assertive");
});
it("announces the reorder down", () => {
// Move 3rd item down to 4th
toggleItems[2].triggerEventHandler("keydown", {
key: "ArrowDown",
preventDefault: jest.fn(),
});
// "reorder boolean label to position 4 of 4"
expect(announce).toHaveBeenCalledWith("reorderFieldDown boolean label 4 4", "assertive");
});
});
});

View File

@ -0,0 +1,334 @@
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { DialogRef } from "@angular/cdk/dialog";
import { CdkDragDrop, DragDropModule, moveItemInArray } from "@angular/cdk/drag-drop";
import { CommonModule } from "@angular/common";
import {
AfterViewInit,
Component,
DestroyRef,
ElementRef,
EventEmitter,
OnInit,
Output,
QueryList,
ViewChildren,
inject,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormArray, FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms";
import { Subject, switchMap, take } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FieldType, LinkedIdType } from "@bitwarden/common/vault/enums";
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
import {
DialogService,
SectionComponent,
SectionHeaderComponent,
FormFieldModule,
TypographyModule,
CardComponent,
IconButtonModule,
CheckboxModule,
SelectModule,
LinkModule,
} from "@bitwarden/components";
import { CipherFormContainer } from "../../cipher-form-container";
import {
AddEditCustomFieldDialogComponent,
AddEditCustomFieldDialogData,
} from "./add-edit-custom-field-dialog/add-edit-custom-field-dialog.component";
/** Attributes associated with each individual FormGroup within the FormArray */
export type CustomField = {
type: FieldType;
name: string;
value: string | boolean | null;
linkedId: LinkedIdType;
/**
* `newField` is set to true when the custom field is created.
*
* This is applicable when the user is adding a new field but
* the `viewPassword` property on the cipher is false. The
* user will still need the ability to set the value of the field
* they just created.
*
* See {@link CustomFieldsComponent.canViewPasswords} for implementation.
*/
newField: boolean;
};
@Component({
standalone: true,
selector: "vault-custom-fields",
templateUrl: "./custom-fields.component.html",
imports: [
JslibModule,
CommonModule,
FormsModule,
FormFieldModule,
ReactiveFormsModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
CardComponent,
IconButtonModule,
CheckboxModule,
SelectModule,
DragDropModule,
LinkModule,
],
})
export class CustomFieldsComponent implements OnInit, AfterViewInit {
@Output() numberOfFieldsChange = new EventEmitter<number>();
@ViewChildren("customFieldRow") customFieldRows: QueryList<ElementRef<HTMLDivElement>>;
customFieldsForm = this.formBuilder.group({
fields: new FormArray([]),
});
/** Reference to the add field dialog */
dialogRef: DialogRef;
/** Options for Linked Fields */
linkedFieldOptions: { name: string; value: LinkedIdType }[] = [];
/** True when edit/reorder toggles should be hidden based on partial-edit */
isPartialEdit: boolean;
/** True when there are custom fields available */
hasCustomFields = false;
/** Emits when a new custom field should be focused */
private focusOnNewInput$ = new Subject<void>();
destroyed$: DestroyRef;
FieldType = FieldType;
constructor(
private dialogService: DialogService,
private cipherFormContainer: CipherFormContainer,
private formBuilder: FormBuilder,
private i18nService: I18nService,
private liveAnnouncer: LiveAnnouncer,
) {
this.destroyed$ = inject(DestroyRef);
this.cipherFormContainer.registerChildForm("customFields", this.customFieldsForm);
this.customFieldsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((values) => {
this.updateCipher(values.fields);
});
}
/** Fields form array, referenced via a getter to avoid type-casting in multiple places */
get fields(): FormArray {
return this.customFieldsForm.controls.fields as FormArray;
}
ngOnInit() {
// Populate options for linked custom fields
this.linkedFieldOptions = Array.from(
this.cipherFormContainer.originalCipherView?.linkedFieldOptions?.entries() ?? [],
)
.map(([id, linkedFieldOption]) => ({
name: this.i18nService.t(linkedFieldOption.i18nKey),
value: id,
}))
.sort(Utils.getSortFunction(this.i18nService, "name"));
// Populate the form with the existing fields
this.cipherFormContainer.originalCipherView?.fields?.forEach((field) => {
let value: string | boolean = field.value;
if (field.type === FieldType.Boolean) {
value = field.value === "true" ? true : false;
}
this.fields.push(
this.formBuilder.group<CustomField>({
type: field.type,
name: field.name,
value: value,
linkedId: field.linkedId,
newField: false,
}),
);
});
// Disable the form if in partial-edit mode
// Must happen after the initial fields are populated
if (this.cipherFormContainer.config.mode === "partial-edit") {
this.isPartialEdit = true;
this.customFieldsForm.disable();
}
}
ngAfterViewInit(): void {
// Focus on the new input field when it is added
// This is done after the view is initialized to ensure the input is rendered
this.focusOnNewInput$
.pipe(
takeUntilDestroyed(this.destroyed$),
// QueryList changes are emitted after the view is updated
switchMap(() => this.customFieldRows.changes.pipe(take(1))),
)
.subscribe(() => {
const input =
this.customFieldRows.last.nativeElement.querySelector<HTMLInputElement>("input");
const label = document.querySelector(`label[for="${input.id}"]`).textContent.trim();
// Focus the input after the announcement element is added to the DOM,
// this should stop the announcement from being cut off by the "focus" event.
void this.liveAnnouncer
.announce(this.i18nService.t("fieldAdded", label), "polite")
.then(() => {
input.focus();
});
});
}
/** Opens the add/edit custom field dialog */
openAddEditCustomFieldDialog(editLabelConfig?: AddEditCustomFieldDialogData["editLabelConfig"]) {
this.dialogRef = this.dialogService.open<unknown, AddEditCustomFieldDialogData>(
AddEditCustomFieldDialogComponent,
{
data: {
addField: this.addField.bind(this),
updateLabel: this.updateLabel.bind(this),
removeField: this.removeField.bind(this),
editLabelConfig,
},
},
);
}
/** Returns true when the user has permission to view passwords for the individual cipher */
canViewPasswords(index: number) {
if (this.cipherFormContainer.originalCipherView === null) {
return true;
}
return (
this.cipherFormContainer.originalCipherView.viewPassword ||
this.fields.at(index).value.newField
);
}
/** Updates label for an individual field */
updateLabel(index: number, label: string) {
this.fields.at(index).patchValue({ name: label });
this.dialogRef?.close();
}
/** Removes an individual field at a specific index */
removeField(index: number) {
this.fields.removeAt(index);
this.dialogRef?.close();
}
/** Adds a new field to the form */
addField(type: FieldType, label: string) {
this.dialogRef?.close();
let value = null;
let linkedId = null;
if (type === FieldType.Boolean) {
// Default to false for boolean fields
value = false;
}
if (type === FieldType.Linked && this.linkedFieldOptions.length > 0) {
// Default to the first linked field option
linkedId = this.linkedFieldOptions[0].value;
}
this.fields.push(
this.formBuilder.group<CustomField>({
type,
name: label,
value,
linkedId,
newField: true,
}),
);
// Trigger focus on the new input field
this.focusOnNewInput$.next();
}
/** Reorder the controls to match the new order after a "drop" event */
drop(event: CdkDragDrop<HTMLDivElement>) {
// Alter the order of the fields array in place
moveItemInArray(this.fields.controls, event.previousIndex, event.currentIndex);
this.updateCipher(this.fields.controls.map((control) => control.value));
}
/** Move a custom field up or down in the list order */
async handleKeyDown(event: KeyboardEvent, label: string, index: number) {
if (event.key === "ArrowUp" && index !== 0) {
event.preventDefault();
const currentIndex = index - 1;
this.drop({ previousIndex: index, currentIndex } as CdkDragDrop<HTMLDivElement>);
await this.liveAnnouncer.announce(
this.i18nService.t("reorderFieldUp", label, currentIndex + 1, this.fields.length),
"assertive",
);
// Refocus the button after the reorder
// Angular re-renders the list when moving an item up which causes the focus to be lost
// Wait for the next tick to ensure the button is rendered before focusing
setTimeout(() => {
(event.target as HTMLButtonElement).focus();
});
}
if (event.key === "ArrowDown" && index !== this.fields.length - 1) {
event.preventDefault();
const currentIndex = index + 1;
this.drop({ previousIndex: index, currentIndex } as CdkDragDrop<HTMLDivElement>);
await this.liveAnnouncer.announce(
this.i18nService.t("reorderFieldDown", label, currentIndex + 1, this.fields.length),
"assertive",
);
}
}
/** Create `FieldView` from the form objects and update the cipher */
private updateCipher(fields: CustomField[]) {
const newFields = fields.map((field: CustomField) => {
let value: string;
if (typeof field.value === "number") {
value = `${field.value}`;
} else if (typeof field.value === "boolean") {
value = field.value ? "true" : "false";
} else {
value = field.value;
}
const fieldView = new FieldView();
fieldView.type = field.type;
fieldView.name = field.name;
fieldView.value = value;
fieldView.linkedId = field.linkedId;
return fieldView;
});
this.hasCustomFields = newFields.length > 0;
this.numberOfFieldsChange.emit(newFields.length);
this.cipherFormContainer.patchCipher({
fields: newFields,
});
}
}