[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:
Nick Krantz 2024-07-02 20:31:24 -05:00 committed by GitHub
parent 3041ddbf09
commit 781ef550c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 370 additions and 0 deletions

View File

@ -3558,5 +3558,17 @@
},
"filters": {
"message": "Filters"
},
"cardDetails": {
"message": "Card details"
},
"cardBrandDetails": {
"message": "$BRAND$ details",
"placeholders": {
"brand": {
"content": "$1",
"example": "Visa"
}
}
}
}

View File

@ -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"];
};
/**

View File

@ -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>

View File

@ -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");
});
});

View File

@ -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,
});
}
}

View File

@ -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>

View File

@ -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) => {