[PM-8525] Edit Card (#9901)
* initial add of card details section * add card number * update card brand when the card number changes * add year and month fields * add security code field * hide number and security code by default * add `id` for all form fields * update select options to match existing options * make year input numerical * only display card details for card ciphers * use style to set input height * handle numerical values for year * update heading when a brand is available * remove unused ref * use cardview types for the form * fix numerical input type * disable card details when in partial-edit mode * remove hardcoded height * update types for formBuilder
This commit is contained in:
parent
3041ddbf09
commit
781ef550c1
|
@ -3558,5 +3558,17 @@
|
|||
},
|
||||
"filters": {
|
||||
"message": "Filters"
|
||||
},
|
||||
"cardDetails": {
|
||||
"message": "Card details"
|
||||
},
|
||||
"cardBrandDetails": {
|
||||
"message": "$BRAND$ details",
|
||||
"placeholders": {
|
||||
"brand": {
|
||||
"content": "$1",
|
||||
"example": "Visa"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { CardDetailsSectionComponent } from "./components/card-details-section/card-details-section.component";
|
||||
import { ItemDetailsSectionComponent } from "./components/item-details/item-details-section.component";
|
||||
|
||||
/**
|
||||
|
@ -8,6 +9,7 @@ import { ItemDetailsSectionComponent } from "./components/item-details/item-deta
|
|||
*/
|
||||
export type CipherForm = {
|
||||
itemDetails?: ItemDetailsSectionComponent["itemDetailsForm"];
|
||||
cardDetails?: CardDetailsSectionComponent["cardDetailsForm"];
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
<bit-section [formGroup]="cardDetailsForm">
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h5">
|
||||
<ng-container *ngIf="cardDetailsForm.value.brand; else defaultHeading">
|
||||
{{ "cardBrandDetails" | i18n: cardDetailsForm.value.brand }}
|
||||
</ng-container>
|
||||
<ng-template #defaultHeading>
|
||||
{{ "cardDetails" | i18n }}
|
||||
</ng-template>
|
||||
</h2>
|
||||
</bit-section-header>
|
||||
<bit-card>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "cardholderName" | i18n }}</bit-label>
|
||||
<input id="cardholderName" bitInput formControlName="cardholderName" />
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "number" | i18n }}</bit-label>
|
||||
<input id="cardNumber" bitInput formControlName="number" type="password" />
|
||||
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "brand" | i18n }}</bit-label>
|
||||
<bit-select id="cardBrand" formControlName="brand">
|
||||
<bit-option
|
||||
*ngFor="let brand of cardBrands"
|
||||
[value]="brand.value"
|
||||
[label]="brand.name"
|
||||
></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
|
||||
<div class="tw-flex tw-flex-wrap tw-gap-1">
|
||||
<bit-form-field class="tw-flex-1">
|
||||
<bit-label>{{ "expirationMonth" | i18n }}</bit-label>
|
||||
<bit-select id="cardExpMonth" formControlName="expMonth">
|
||||
<bit-option
|
||||
*ngFor="let month of expirationMonths"
|
||||
[value]="month.value"
|
||||
[label]="month.name"
|
||||
></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field class="tw-flex-1">
|
||||
<bit-label>{{ "expirationYear" | i18n }}</bit-label>
|
||||
<input id="cardExpYear" bitInput formControlName="expYear" type="number" />
|
||||
</bit-form-field>
|
||||
</div>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "securityCode" | i18n }}</bit-label>
|
||||
<input id="cardCode" bitInput formControlName="code" type="password" />
|
||||
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
|
||||
</bit-form-field>
|
||||
</bit-card>
|
||||
</bit-section>
|
|
@ -0,0 +1,125 @@
|
|||
import { CommonModule } from "@angular/common";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ReactiveFormsModule } from "@angular/forms";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { CipherFormContainer } from "../../cipher-form-container";
|
||||
|
||||
import { CardDetailsSectionComponent } from "./card-details-section.component";
|
||||
|
||||
describe("CardDetailsSectionComponent", () => {
|
||||
let component: CardDetailsSectionComponent;
|
||||
let fixture: ComponentFixture<CardDetailsSectionComponent>;
|
||||
let cipherFormProvider: MockProxy<CipherFormContainer>;
|
||||
let registerChildFormSpy: jest.SpyInstance;
|
||||
let patchCipherSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
cipherFormProvider = mock<CipherFormContainer>();
|
||||
registerChildFormSpy = jest.spyOn(cipherFormProvider, "registerChildForm");
|
||||
patchCipherSpy = jest.spyOn(cipherFormProvider, "patchCipher");
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CardDetailsSectionComponent, CommonModule, ReactiveFormsModule],
|
||||
providers: [
|
||||
{ provide: CipherFormContainer, useValue: cipherFormProvider },
|
||||
{ provide: I18nService, useValue: mock<I18nService>() },
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CardDetailsSectionComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.cardDetailsForm.reset({
|
||||
cardholderName: null,
|
||||
number: null,
|
||||
brand: null,
|
||||
expMonth: null,
|
||||
expYear: null,
|
||||
code: null,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("registers `cardDetailsForm` with `CipherFormContainer`", () => {
|
||||
expect(registerChildFormSpy).toHaveBeenCalledWith("cardDetails", component.cardDetailsForm);
|
||||
});
|
||||
|
||||
it("patches `cardDetailsForm` changes to cipherFormContainer", () => {
|
||||
component.cardDetailsForm.patchValue({
|
||||
cardholderName: "Ron Burgundy",
|
||||
number: "4242 4242 4242 4242",
|
||||
});
|
||||
|
||||
const cardView = new CardView();
|
||||
cardView.cardholderName = "Ron Burgundy";
|
||||
cardView.number = "4242 4242 4242 4242";
|
||||
cardView.brand = "Visa";
|
||||
|
||||
expect(patchCipherSpy).toHaveBeenCalledWith({
|
||||
card: cardView,
|
||||
});
|
||||
});
|
||||
|
||||
it("it converts the year integer to a string", () => {
|
||||
component.cardDetailsForm.patchValue({
|
||||
expYear: 2022,
|
||||
});
|
||||
|
||||
const cardView = new CardView();
|
||||
cardView.expYear = "2022";
|
||||
|
||||
expect(patchCipherSpy).toHaveBeenCalledWith({
|
||||
card: cardView,
|
||||
});
|
||||
});
|
||||
|
||||
it('disables `cardDetailsForm` when "disabled" is true', () => {
|
||||
component.disabled = true;
|
||||
|
||||
component.ngOnInit();
|
||||
|
||||
expect(component.cardDetailsForm.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("initializes `cardDetailsForm` with current values", () => {
|
||||
const cardholderName = "Ron Burgundy";
|
||||
const number = "4242 4242 4242 4242";
|
||||
const code = "619";
|
||||
|
||||
const cardView = new CardView();
|
||||
cardView.cardholderName = cardholderName;
|
||||
cardView.number = number;
|
||||
cardView.code = code;
|
||||
cardView.brand = "Visa";
|
||||
|
||||
component.originalCipherView = {
|
||||
card: cardView,
|
||||
} as CipherView;
|
||||
|
||||
component.ngOnInit();
|
||||
|
||||
expect(component.cardDetailsForm.value).toEqual({
|
||||
cardholderName,
|
||||
number,
|
||||
code,
|
||||
brand: cardView.brand,
|
||||
expMonth: null,
|
||||
expYear: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets brand based on number changes", () => {
|
||||
const numberInput = fixture.debugElement.query(By.css('input[formControlName="number"]'));
|
||||
numberInput.nativeElement.value = "4111 1111 1111 1111";
|
||||
numberInput.nativeElement.dispatchEvent(new Event("input"));
|
||||
|
||||
expect(component.cardDetailsForm.controls.brand.value).toBe("Visa");
|
||||
});
|
||||
});
|
|
@ -0,0 +1,161 @@
|
|||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
CardComponent,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
SectionComponent,
|
||||
SectionHeaderComponent,
|
||||
SelectModule,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { CipherFormContainer } from "../../cipher-form-container";
|
||||
|
||||
@Component({
|
||||
selector: "vault-card-details-section",
|
||||
templateUrl: "./card-details-section.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
CardComponent,
|
||||
SectionComponent,
|
||||
TypographyModule,
|
||||
FormFieldModule,
|
||||
ReactiveFormsModule,
|
||||
SelectModule,
|
||||
SectionHeaderComponent,
|
||||
IconButtonModule,
|
||||
JslibModule,
|
||||
CommonModule,
|
||||
],
|
||||
})
|
||||
export class CardDetailsSectionComponent implements OnInit {
|
||||
/** The original cipher */
|
||||
@Input() originalCipherView: CipherView;
|
||||
|
||||
/** True when all fields should be disabled */
|
||||
@Input() disabled: boolean;
|
||||
|
||||
/**
|
||||
* All form fields associated with the card details
|
||||
*
|
||||
* Note: `as` is used to assert the type of the form control,
|
||||
* leaving as just null gets inferred as `unknown`
|
||||
*/
|
||||
cardDetailsForm = this.formBuilder.group({
|
||||
cardholderName: null as string | null,
|
||||
number: null as string | null,
|
||||
brand: null as string | null,
|
||||
expMonth: null as string | null,
|
||||
expYear: null as string | number | null,
|
||||
code: null as string | null,
|
||||
});
|
||||
|
||||
/** Available Card Brands */
|
||||
readonly cardBrands = [
|
||||
{ name: "-- " + this.i18nService.t("select") + " --", value: null },
|
||||
{ name: "Visa", value: "Visa" },
|
||||
{ name: "Mastercard", value: "Mastercard" },
|
||||
{ name: "American Express", value: "Amex" },
|
||||
{ name: "Discover", value: "Discover" },
|
||||
{ name: "Diners Club", value: "Diners Club" },
|
||||
{ name: "JCB", value: "JCB" },
|
||||
{ name: "Maestro", value: "Maestro" },
|
||||
{ name: "UnionPay", value: "UnionPay" },
|
||||
{ name: "RuPay", value: "RuPay" },
|
||||
{ name: this.i18nService.t("other"), value: "Other" },
|
||||
];
|
||||
|
||||
/** Available expiration months */
|
||||
readonly expirationMonths = [
|
||||
{ name: "-- " + this.i18nService.t("select") + " --", value: null },
|
||||
{ name: "01 - " + this.i18nService.t("january"), value: "1" },
|
||||
{ name: "02 - " + this.i18nService.t("february"), value: "2" },
|
||||
{ name: "03 - " + this.i18nService.t("march"), value: "3" },
|
||||
{ name: "04 - " + this.i18nService.t("april"), value: "4" },
|
||||
{ name: "05 - " + this.i18nService.t("may"), value: "5" },
|
||||
{ name: "06 - " + this.i18nService.t("june"), value: "6" },
|
||||
{ name: "07 - " + this.i18nService.t("july"), value: "7" },
|
||||
{ name: "08 - " + this.i18nService.t("august"), value: "8" },
|
||||
{ name: "09 - " + this.i18nService.t("september"), value: "9" },
|
||||
{ name: "10 - " + this.i18nService.t("october"), value: "10" },
|
||||
{ name: "11 - " + this.i18nService.t("november"), value: "11" },
|
||||
{ name: "12 - " + this.i18nService.t("december"), value: "12" },
|
||||
];
|
||||
|
||||
/** Local CardView, either created empty or set to the existing card instance */
|
||||
private cardView: CardView;
|
||||
|
||||
constructor(
|
||||
private cipherFormContainer: CipherFormContainer,
|
||||
private formBuilder: FormBuilder,
|
||||
private i18nService: I18nService,
|
||||
) {
|
||||
this.cipherFormContainer.registerChildForm("cardDetails", this.cardDetailsForm);
|
||||
|
||||
this.cardDetailsForm.valueChanges
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe(({ cardholderName, number, brand, expMonth, expYear, code }) => {
|
||||
// The input[type="number"] is returning a number, convert it to a string
|
||||
// An empty field returns null, avoid casting `"null"` to a string
|
||||
const expirationYear = expYear !== null ? `${expYear}` : null;
|
||||
|
||||
const patchedCard = Object.assign(this.cardView, {
|
||||
cardholderName,
|
||||
number,
|
||||
brand,
|
||||
expMonth,
|
||||
expYear: expirationYear,
|
||||
code,
|
||||
});
|
||||
|
||||
this.cipherFormContainer.patchCipher({
|
||||
card: patchedCard,
|
||||
});
|
||||
});
|
||||
|
||||
this.cardDetailsForm.controls.number.valueChanges
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((number) => {
|
||||
const brand = CardView.getCardBrandByPatterns(number);
|
||||
|
||||
if (brand) {
|
||||
this.cardDetailsForm.controls.brand.setValue(brand);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
// If the original cipher has a card, use it. Otherwise, create a new card instance
|
||||
this.cardView = this.originalCipherView?.card ?? new CardView();
|
||||
|
||||
if (this.originalCipherView?.card) {
|
||||
this.setInitialValues();
|
||||
}
|
||||
|
||||
if (this.disabled) {
|
||||
this.cardDetailsForm.disable();
|
||||
}
|
||||
}
|
||||
|
||||
/** Set form initial form values from the current cipher */
|
||||
private setInitialValues() {
|
||||
const { cardholderName, number, brand, expMonth, expYear, code } = this.originalCipherView.card;
|
||||
|
||||
this.cardDetailsForm.setValue({
|
||||
cardholderName: cardholderName,
|
||||
number: number,
|
||||
brand: brand,
|
||||
expMonth: expMonth,
|
||||
expYear: expYear,
|
||||
code: code,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -6,6 +6,12 @@
|
|||
[originalCipherView]="originalCipherView"
|
||||
></vault-item-details-section>
|
||||
|
||||
<vault-card-details-section
|
||||
*ngIf="config.cipherType === CipherType.Card"
|
||||
[originalCipherView]="originalCipherView"
|
||||
[disabled]="config.mode === 'partial-edit'"
|
||||
></vault-card-details-section>
|
||||
|
||||
<!-- Attachments are only available for existing ciphers -->
|
||||
<ng-container *ngIf="config.mode == 'edit'">
|
||||
<ng-content select="[slot=attachment-button]"></ng-content>
|
||||
|
|
|
@ -16,6 +16,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
|||
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
|
@ -34,6 +35,7 @@ import { CipherFormConfig } from "../abstractions/cipher-form-config.service";
|
|||
import { CipherFormService } from "../abstractions/cipher-form.service";
|
||||
import { CipherForm, CipherFormContainer } from "../cipher-form-container";
|
||||
|
||||
import { CardDetailsSectionComponent } from "./card-details-section/card-details-section.component";
|
||||
import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component";
|
||||
|
||||
@Component({
|
||||
|
@ -56,6 +58,7 @@ import { ItemDetailsSectionComponent } from "./item-details/item-details-section
|
|||
ReactiveFormsModule,
|
||||
SelectModule,
|
||||
ItemDetailsSectionComponent,
|
||||
CardDetailsSectionComponent,
|
||||
NgIf,
|
||||
],
|
||||
})
|
||||
|
@ -106,6 +109,8 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
|||
protected updatedCipherView: CipherView | null;
|
||||
protected loading: boolean = true;
|
||||
|
||||
CipherType = CipherType;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (this.submitBtn) {
|
||||
this.bitSubmit.loading$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((loading) => {
|
||||
|
|
Loading…
Reference in New Issue