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:
parent
a1c5cc6dbf
commit
83d141c914
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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"];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user