From 781ef550c1f813e1b383709a1bf01ae954f1ed8e Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 2 Jul 2024 20:31:24 -0500 Subject: [PATCH] [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 --- apps/browser/src/_locales/en/messages.json | 12 ++ .../src/cipher-form/cipher-form-container.ts | 2 + .../card-details-section.component.html | 59 +++++++ .../card-details-section.component.spec.ts | 125 ++++++++++++++ .../card-details-section.component.ts | 161 ++++++++++++++++++ .../components/cipher-form.component.html | 6 + .../components/cipher-form.component.ts | 5 + 7 files changed, 370 insertions(+) create mode 100644 libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.html create mode 100644 libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.spec.ts create mode 100644 libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 95db94832b..4030e6c94e 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3558,5 +3558,17 @@ }, "filters": { "message": "Filters" + }, + "cardDetails": { + "message": "Card details" + }, + "cardBrandDetails": { + "message": "$BRAND$ details", + "placeholders": { + "brand": { + "content": "$1", + "example": "Visa" + } + } } } diff --git a/libs/vault/src/cipher-form/cipher-form-container.ts b/libs/vault/src/cipher-form/cipher-form-container.ts index 2fdad061fc..8c6c232629 100644 --- a/libs/vault/src/cipher-form/cipher-form-container.ts +++ b/libs/vault/src/cipher-form/cipher-form-container.ts @@ -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"]; }; /** diff --git a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.html b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.html new file mode 100644 index 0000000000..7fb15d4775 --- /dev/null +++ b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.html @@ -0,0 +1,59 @@ + + +

+ + {{ "cardBrandDetails" | i18n: cardDetailsForm.value.brand }} + + + {{ "cardDetails" | i18n }} + +

+
+ + + {{ "cardholderName" | i18n }} + + + + + {{ "number" | i18n }} + + + + + + {{ "brand" | i18n }} + + + + + +
+ + {{ "expirationMonth" | i18n }} + + + + + + + {{ "expirationYear" | i18n }} + + +
+ + + {{ "securityCode" | i18n }} + + + +
+
diff --git a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.spec.ts new file mode 100644 index 0000000000..3ad13e1dbe --- /dev/null +++ b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.spec.ts @@ -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; + let cipherFormProvider: MockProxy; + let registerChildFormSpy: jest.SpyInstance; + let patchCipherSpy: jest.SpyInstance; + + beforeEach(async () => { + cipherFormProvider = mock(); + 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() }, + ], + }).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"); + }); +}); diff --git a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts new file mode 100644 index 0000000000..b8bc4f35c4 --- /dev/null +++ b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts @@ -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, + }); + } +} diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.html b/libs/vault/src/cipher-form/components/cipher-form.component.html index 78b8278ddf..d57681dad6 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.html +++ b/libs/vault/src/cipher-form/components/cipher-form.component.html @@ -6,6 +6,12 @@ [originalCipherView]="originalCipherView" > + + diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.ts b/libs/vault/src/cipher-form/components/cipher-form.component.ts index b307550803..b47dd509ea 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.ts @@ -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) => {