[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": {
|
||||
"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/auth/src/**/*.{html,ts}",
|
||||
"../../libs/angular/src/**/*.{html,ts}",
|
||||
"../../libs/vault/src/**/*.{html,ts}",
|
||||
];
|
||||
|
||||
module.exports = config;
|
||||
|
|
|
@ -3,6 +3,7 @@ import { CipherFormConfig } from "@bitwarden/vault";
|
|||
|
||||
import { AdditionalOptionsSectionComponent } from "./components/additional-options/additional-options-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 { ItemDetailsSectionComponent } from "./components/item-details/item-details-section.component";
|
||||
|
||||
|
@ -15,6 +16,7 @@ export type CipherForm = {
|
|||
additionalOptions?: AdditionalOptionsSectionComponent["additionalOptionsForm"];
|
||||
cardDetails?: CardDetailsSectionComponent["cardDetailsForm"];
|
||||
identityDetails?: IdentitySectionComponent["identityForm"];
|
||||
customFields?: CustomFieldsComponent["customFieldsForm"];
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -13,8 +13,17 @@
|
|||
<bit-label>{{ "passwordPrompt" | i18n }}</bit-label>
|
||||
</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-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 { mock, MockProxy } from "jest-mock-extended";
|
||||
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 { CipherFormContainer } from "../../cipher-form-container";
|
||||
import { CustomFieldsComponent } from "../custom-fields/custom-fields.component";
|
||||
|
||||
import { AdditionalOptionsSectionComponent } from "./additional-options-section.component";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "vault-custom-fields",
|
||||
template: "",
|
||||
})
|
||||
class MockCustomFieldsComponent {}
|
||||
|
||||
describe("AdditionalOptionsSectionComponent", () => {
|
||||
let component: AdditionalOptionsSectionComponent;
|
||||
let fixture: ComponentFixture<AdditionalOptionsSectionComponent>;
|
||||
|
@ -31,7 +40,16 @@ describe("AdditionalOptionsSectionComponent", () => {
|
|||
{ provide: PasswordRepromptService, useValue: passwordRepromptService },
|
||||
{ provide: I18nService, useValue: mock<I18nService>() },
|
||||
],
|
||||
}).compileComponents();
|
||||
})
|
||||
.overrideComponent(AdditionalOptionsSectionComponent, {
|
||||
remove: {
|
||||
imports: [CustomFieldsComponent],
|
||||
},
|
||||
add: {
|
||||
imports: [MockCustomFieldsComponent],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AdditionalOptionsSectionComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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 { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||
import { shareReplay } from "rxjs";
|
||||
|
@ -10,6 +10,7 @@ import {
|
|||
CardComponent,
|
||||
CheckboxModule,
|
||||
FormFieldModule,
|
||||
LinkModule,
|
||||
SectionComponent,
|
||||
SectionHeaderComponent,
|
||||
TypographyModule,
|
||||
|
@ -17,12 +18,14 @@ import {
|
|||
|
||||
import { PasswordRepromptService } from "../../../services/password-reprompt.service";
|
||||
import { CipherFormContainer } from "../../cipher-form-container";
|
||||
import { CustomFieldsComponent } from "../custom-fields/custom-fields.component";
|
||||
|
||||
@Component({
|
||||
selector: "vault-additional-options-section",
|
||||
templateUrl: "./additional-options-section.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
SectionComponent,
|
||||
SectionHeaderComponent,
|
||||
TypographyModule,
|
||||
|
@ -32,9 +35,13 @@ import { CipherFormContainer } from "../../cipher-form-container";
|
|||
ReactiveFormsModule,
|
||||
CheckboxModule,
|
||||
CommonModule,
|
||||
CustomFieldsComponent,
|
||||
LinkModule,
|
||||
],
|
||||
})
|
||||
export class AdditionalOptionsSectionComponent implements OnInit {
|
||||
@ViewChild(CustomFieldsComponent) customFieldsComponent: CustomFieldsComponent;
|
||||
|
||||
additionalOptionsForm = this.formBuilder.group({
|
||||
notes: [null as string],
|
||||
reprompt: [false],
|
||||
|
@ -44,10 +51,17 @@ export class AdditionalOptionsSectionComponent implements OnInit {
|
|||
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(
|
||||
private cipherFormContainer: CipherFormContainer,
|
||||
private formBuilder: FormBuilder,
|
||||
private passwordRepromptService: PasswordRepromptService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
) {
|
||||
this.cipherFormContainer.registerChildForm("additionalOptions", this.additionalOptionsForm);
|
||||
|
||||
|
@ -70,6 +84,22 @@ export class AdditionalOptionsSectionComponent implements OnInit {
|
|||
|
||||
if (this.cipherFormContainer.config.mode === "partial-edit") {
|
||||
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 updatedCipherView: CipherView | null;
|
||||
|
||||
protected loading: boolean = true;
|
||||
|
||||
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