From 82a1cfae6f04763809826f088845bd85c09ecdba Mon Sep 17 00:00:00 2001 From: Sultan Al-Maari Date: Thu, 22 Aug 2024 16:05:08 +0200 Subject: [PATCH] fix: refactor CreditCardForm for auction registration (#10642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: refactor CreditCardForm for auction registration * un-reinvent the wheel 🛞 aka use formik 😄 * update error messages * update tests * move field descriptions and validation out of component * replace Stack and mark it as deprecated * minor changes + update tests * small refactor in CreditCardForm * fix confirmBid tests * refactor error displaying in credit card form --- .../Bidding/Components/PaymentInfo.tests.tsx | 24 +- .../Bidding/Components/PaymentInfo.tsx | 45 +-- .../Bidding/Screens/ConfirmBid.tests.tsx | 51 ++- .../Bidding/Screens/ConfirmBid/index.tsx | 19 +- .../Bidding/Screens/CreditCardForm.tests.tsx | 147 ++++---- .../Bidding/Screens/CreditCardForm.tsx | 352 +++++++++++++----- .../Bidding/Screens/Registration.tests.tsx | 109 +++--- .../Bidding/Screens/Registration.tsx | 13 +- .../Bidding/Utils/creditCardFormFields.ts | 35 ++ .../creditCardFormFieldsValidationSchema.ts | 16 + src/app/Components/Stack.tsx | 3 + .../MyProfilePaymentNewCreditCard.tsx | 15 +- 12 files changed, 518 insertions(+), 311 deletions(-) create mode 100644 src/app/Components/Bidding/Utils/creditCardFormFields.ts create mode 100644 src/app/Components/Bidding/Validators/creditCardFormFieldsValidationSchema.ts diff --git a/src/app/Components/Bidding/Components/PaymentInfo.tests.tsx b/src/app/Components/Bidding/Components/PaymentInfo.tests.tsx index 54695efc082..8e5166e0c1d 100644 --- a/src/app/Components/Bidding/Components/PaymentInfo.tests.tsx +++ b/src/app/Components/Bidding/Components/PaymentInfo.tests.tsx @@ -1,6 +1,5 @@ import { Text } from "@artsy/palette-mobile" import { BidInfoRow } from "app/Components/Bidding/Components/BidInfoRow" -import { BillingAddress } from "app/Components/Bidding/Screens/BillingAddress" import { CreditCardForm } from "app/Components/Bidding/Screens/CreditCardForm" import NavigatorIOS, { NavigatorIOSPushArgs, @@ -24,26 +23,17 @@ it("renders without throwing an error", () => { renderWithWrappersLEGACY() }) -it("shows the billing address that the user typed in the billing address form", () => { - const billingAddressRow = renderWithWrappersLEGACY( - - ).root.findAllByType(BidInfoRow)[1] - billingAddressRow.instance.props.onPress() - expect(nextStep.component).toEqual(BillingAddress) +it("shows the cc info that the user had typed into the form", async () => { + const { root } = renderWithWrappersLEGACY() - expect(billingAddressRow.findAllByType(Text)[1].props.children).toEqual( - "401 Broadway 25th floor New York NY" - ) -}) + const creditCardRow = await root.findAllByType(BidInfoRow) -it("shows the cc info that the user had typed into the form", () => { - const creditCardRow = renderWithWrappersLEGACY( - - ).root.findAllByType(BidInfoRow)[0] - creditCardRow.instance.props.onPress() + creditCardRow[0].instance.props.onPress() expect(nextStep.component).toEqual(CreditCardForm) - expect(creditCardRow.findAllByType(Text)[1].props.children).toEqual("VISA •••• 4242") + const creditCardRowText = await creditCardRow[0].findAllByType(Text) + + expect(creditCardRowText[1].props.children).toEqual("VISA •••• 4242") }) const billingAddress = { diff --git a/src/app/Components/Bidding/Components/PaymentInfo.tsx b/src/app/Components/Bidding/Components/PaymentInfo.tsx index 60d720180f4..d8cc0cbcb30 100644 --- a/src/app/Components/Bidding/Components/PaymentInfo.tsx +++ b/src/app/Components/Bidding/Components/PaymentInfo.tsx @@ -2,7 +2,6 @@ import { bullet } from "@artsy/palette-mobile" import { Token } from "@stripe/stripe-react-native" import { Card } from "@stripe/stripe-react-native/lib/typescript/src/types/Token" import { FlexProps } from "app/Components/Bidding/Elements/Flex" -import { BillingAddress } from "app/Components/Bidding/Screens/BillingAddress" import { CreditCardForm } from "app/Components/Bidding/Screens/CreditCardForm" import { Address, PaymentCardTextFieldParams } from "app/Components/Bidding/types" import NavigatorIOS from "app/utils/__legacy_do_not_use__navigator-ios-shim" @@ -14,8 +13,7 @@ import { Divider } from "./Divider" interface PaymentInfoProps extends FlexProps { navigator?: NavigatorIOS - onCreditCardAdded: (t: Token.Result, p: PaymentCardTextFieldParams) => void - onBillingAddressAdded: (values: Address) => void + onCreditCardAdded: (t: Token.Result, a: Address) => void billingAddress?: Address | null creditCardFormParams?: PaymentCardTextFieldParams | null creditCardToken?: Token.Result | null @@ -31,36 +29,19 @@ export class PaymentInfo extends React.Component { component: CreditCardForm, title: "", passProps: { - onSubmit: (token: Token.Result, params: PaymentCardTextFieldParams) => - this.onCreditCardAdded(token, params), - params: this.props.creditCardFormParams, - navigator: this.props.navigator, - }, - }) - } - - presentBillingAddressForm() { - this.props.navigator?.push({ - component: BillingAddress, - title: "", - passProps: { - onSubmit: (address: Address) => this.onBillingAddressAdded(address), + onSubmit: (token: Token.Result, address: Address) => this.onCreditCardAdded(token, address), billingAddress: this.props.billingAddress, navigator: this.props.navigator, }, }) } - onCreditCardAdded(token: Token.Result, params: PaymentCardTextFieldParams) { - this.props.onCreditCardAdded(token, params) - } - - onBillingAddressAdded(values: Address) { - this.props.onBillingAddressAdded(values) + onCreditCardAdded(token: Token.Result, address: Address) { + this.props.onCreditCardAdded(token, address) } render() { - const { billingAddress, creditCardToken: token } = this.props + const { creditCardToken: token } = this.props return ( @@ -73,16 +54,6 @@ export class PaymentInfo extends React.Component { /> - - { - this.presentBillingAddressForm() - }} - /> - - ) } @@ -90,10 +61,4 @@ export class PaymentInfo extends React.Component { private formatCard(card: Card) { return `${card.brand} ${bullet}${bullet}${bullet}${bullet} ${card.last4}` } - - private formatAddress(address: Address) { - return [address.addressLine1, address.addressLine2, address.city, address.state] - .filter((el) => el) - .join(" ") - } } diff --git a/src/app/Components/Bidding/Screens/ConfirmBid.tests.tsx b/src/app/Components/Bidding/Screens/ConfirmBid.tests.tsx index ed50041731d..c2219297bc9 100644 --- a/src/app/Components/Bidding/Screens/ConfirmBid.tests.tsx +++ b/src/app/Components/Bidding/Screens/ConfirmBid.tests.tsx @@ -268,11 +268,14 @@ describe("ConfirmBid", () => { expect(component.root.findAllByType(BidInfoRow).length).toEqual(1) }) - it("shows a checkbox and payment info if the user is not registered and has no cc on file", () => { - const component = mountConfirmBidComponent(initialPropsForUnqualifiedUser) + it("shows a checkbox and payment info if the user is not registered and has no cc on file", async () => { + const { root } = mountConfirmBidComponent(initialPropsForUnqualifiedUser) - expect(component.root.findAllByType(Checkbox).length).toEqual(1) - expect(component.root.findAllByType(BidInfoRow).length).toEqual(3) + const checkboxs = await root.findAllByType(Checkbox) + const bidInfoRows = await root.findAllByType(BidInfoRow) + + expect(checkboxs.length).toEqual(1) + expect(bidInfoRows.length).toEqual(2) }) }) @@ -700,15 +703,21 @@ describe("ConfirmBid", () => { }) describe("ConfirmBid for unqualified user", () => { - const fillOutFormAndSubmit = (component: ReactTestRenderer) => { + const fillOutFormAndSubmit = async (component: ReactTestRenderer) => { + const confirmBidComponent = await component.root.findByType(ConfirmBid) // manually setting state to avoid duplicating tests for skipping UI interaction, but practically better not to do this. - component.root.findByType(ConfirmBid).instance.setState({ billingAddress }) - component.root.findByType(ConfirmBid).instance.setState({ creditCardToken: stripeToken }) - component.root.findByType(Checkbox).props.onPress() - findPlaceBidButton(component).props.onPress() + confirmBidComponent.instance.setState({ billingAddress }) + confirmBidComponent.instance.setState({ creditCardToken: stripeToken.token }) + + const checkbox = await component.root.findByType(Checkbox) + checkbox.props.onPress() + + const bidButton = await findPlaceBidButton(component) + bidButton.props.onPress() } - it("shows the billing address that the user typed in the billing address form", () => { + // skipping since we don't have billing address now + xit("shows the billing address that the user typed in the billing address form", () => { const billingAddressRow = mountConfirmBidComponent( initialPropsForUnqualifiedUser ).root.findAllByType(TouchableWithoutFeedback)[2] @@ -794,7 +803,7 @@ describe("ConfirmBid", () => { expect(screen.UNSAFE_getByType(Modal)).toHaveProp("visible", false) }) - it("shows the error screen with the default error message if there are unhandled errors from the createCreditCard mutation", () => { + it("shows the error screen with the default error message if there are unhandled errors from the createCreditCard mutation", async () => { const errors = [{ message: "malformed error" }] console.error = jest.fn() // Silences component logging. @@ -806,15 +815,19 @@ describe("ConfirmBid", () => { const component = mountConfirmBidComponent(initialPropsForUnqualifiedUser) - fillOutFormAndSubmit(component) + await fillOutFormAndSubmit(component) + + const modal = await component.root.findByType(Modal) + const modalText = await modal.findAllByType(Text) + const modalButton = await modal.findByType(Button) - expect(component.root.findByType(Modal).findAllByType(Text)[1].props.children).toEqual([ + expect(modalText[1].props.children).toEqual([ "There was a problem processing your information. Check your payment details and try again.", ]) - component.root.findByType(Modal).findByType(Button).props.onPress() + modalButton.props.onPress() // it dismisses the modal - expect(component.root.findByType(Modal).props.visible).toEqual(false) + expect(modal.props.visible).toEqual(false) }) it("shows the error screen with the default error message if the creditCardMutation error message is empty", async () => { @@ -846,7 +859,7 @@ describe("ConfirmBid", () => { expect(screen.UNSAFE_getByType(Modal)).toHaveProp("visible", false) }) - it("shows the generic error screen on a createCreditCard mutation network failure", () => { + it("shows the generic error screen on a createCreditCard mutation network failure", async () => { console.error = jest.fn() // Silences component logging. ;(createToken as jest.Mock).mockReturnValueOnce(stripeToken) relay.commitMutation = commitMutationMock((_, { onError }) => { @@ -856,7 +869,7 @@ describe("ConfirmBid", () => { const component = mountConfirmBidComponent(initialPropsForUnqualifiedUser) - fillOutFormAndSubmit(component) + await fillOutFormAndSubmit(component) expect(nextStep?.component).toEqual(BidResultScreen) expect(nextStep?.passProps).toEqual( @@ -895,7 +908,9 @@ describe("ConfirmBid", () => { // UNSAFELY getting the component instance to set state for testing purposes only screen.UNSAFE_getByType(ConfirmBid).instance.setState({ billingAddress }) - screen.UNSAFE_getByType(ConfirmBid).instance.setState({ creditCardToken: stripeToken }) + screen + .UNSAFE_getByType(ConfirmBid) + .instance.setState({ creditCardToken: stripeToken.token }) // Check the checkbox and press the Bid button fireEvent.press(screen.UNSAFE_getByType(Checkbox)) diff --git a/src/app/Components/Bidding/Screens/ConfirmBid/index.tsx b/src/app/Components/Bidding/Screens/ConfirmBid/index.tsx index bb126e98b40..0cfd0fab7ac 100644 --- a/src/app/Components/Bidding/Screens/ConfirmBid/index.tsx +++ b/src/app/Components/Bidding/Screens/ConfirmBid/index.tsx @@ -136,12 +136,12 @@ export class ConfirmBid extends React.Component | null | undefined) { @@ -533,7 +529,6 @@ export class ConfirmBid extends React.Component null } as any) : this.props.navigator} onCreditCardAdded={this.onCreditCardAdded.bind(this)} - onBillingAddressAdded={this.onBillingAddressAdded.bind(this)} billingAddress={this.state.billingAddress} creditCardFormParams={this.state.creditCardFormParams} creditCardToken={this.state.creditCardToken} diff --git a/src/app/Components/Bidding/Screens/CreditCardForm.tests.tsx b/src/app/Components/Bidding/Screens/CreditCardForm.tests.tsx index b7729e67e4d..fdb22d153c5 100644 --- a/src/app/Components/Bidding/Screens/CreditCardForm.tests.tsx +++ b/src/app/Components/Bidding/Screens/CreditCardForm.tests.tsx @@ -1,7 +1,6 @@ import { createToken } from "@stripe/stripe-react-native" import { Details } from "@stripe/stripe-react-native/lib/typescript/src/types/components/CardFieldInput" -import { fireEvent } from "@testing-library/react-native" -import { flushPromiseQueue } from "app/utils/tests/flushPromiseQueue" +import { fireEvent, screen, waitFor } from "@testing-library/react-native" import { renderWithWrappers } from "app/utils/tests/renderWithWrappers" import { CreditCardForm } from "./CreditCardForm" @@ -42,6 +41,8 @@ jest.mock("@stripe/stripe-react-native", () => { } }) +const onSubmitMock = jest.fn() + describe("CreditCardForm", () => { const stripeToken = { token: { @@ -66,67 +67,76 @@ describe("CreditCardForm", () => { }) it("renders without throwing an error", () => { - const onSubmitMock = jest.fn() - renderWithWrappers( null } as any} onSubmit={onSubmitMock} /> ) }) it("calls the onSubmit() callback with valid credit card when ADD CREDIT CARD is tapped", async () => { - const onSubmitMock = jest.fn() - ;(createToken as jest.Mock).mockReturnValueOnce(stripeToken) - const { getByTestId } = renderWithWrappers( + renderWithWrappers( null, - } as any - } + navigator={{ pop: () => null } as any} + // @ts-expect-error prefilling the country for the test + billingAddress={{ country: { shortName: "US", longName: "United States" } }} /> ) - const creditCardField = getByTestId("credit-card-field") + const creditCardField = screen.getByTestId("credit-card-field") fireEvent.changeText(creditCardField, creditCard.number) - const addButton = getByTestId("add-credit-card-button") + const nameInput = screen.getByTestId("input-full-name") + const address1Input = screen.getByTestId("input-address-1") + const address2Input = screen.getByTestId("input-address-2") + const cityInput = screen.getByTestId("input-city") + const stateInput = screen.getByTestId("input-state") + const postalCodeInput = screen.getByTestId("input-postal-code") + const phoneInput = screen.getByTestId("input-phone") + + fireEvent.changeText(nameInput, "mockName") + fireEvent.changeText(address1Input, "mockAddress1") + fireEvent.changeText(address2Input, "mockAddress2") + fireEvent.changeText(cityInput, "mockCity") + fireEvent.changeText(stateInput, "mockState") + fireEvent.changeText(postalCodeInput, "mockPostcode") + fireEvent.changeText(phoneInput, "mockPhone") + + const addButton = screen.getByTestId("credit-card-form-button") fireEvent.press(addButton) - await flushPromiseQueue() + await waitFor(() => expect(createToken).toHaveBeenCalled()) expect(onSubmitMock).toHaveBeenCalledWith(stripeToken.token, { - expiryMonth: creditCard.expiryMonth, - expiryYear: creditCard.expiryYear, - last4: creditCard.last4, + addressLine1: "mockAddress1", + addressLine2: "mockAddress2", + city: "mockCity", + country: { + longName: "United States", + shortName: "US", + }, + fullName: "mockName", + phoneNumber: "mockPhone", + postalCode: "mockPostcode", + state: "mockState", }) }) - it("is does not call onSubmit while the form is invalid", async () => { + it("is does not call onSubmit while the form is invalid", () => { const onSubmitMock = jest.fn() ;(createToken as jest.Mock).mockReturnValueOnce(stripeToken) - const { getByTestId } = renderWithWrappers( - null, - } as any - } - /> + renderWithWrappers( + null } as any} /> ) - const creditCardField = getByTestId("credit-card-field") + const creditCardField = screen.getByTestId("credit-card-field") fireEvent.changeText(creditCardField, "4242") // incomplete number - await flushPromiseQueue() - - const addButton = getByTestId("add-credit-card-button") + const addButton = screen.getByTestId("credit-card-form-button") fireEvent.press(addButton) @@ -138,49 +148,44 @@ describe("CreditCardForm", () => { ;(createToken as jest.Mock).mockReturnValueOnce(stripeToken) - const { getByTestId } = renderWithWrappers( - null, - } as any - } - /> + renderWithWrappers( + null } as any} /> ) - const creditCardField = getByTestId("credit-card-field") + const creditCardField = screen.getByTestId("credit-card-field") fireEvent.changeText(creditCardField, "4242") // incomplete number - await flushPromiseQueue() + const addButton = screen.getByTestId("credit-card-form-button") - const addButton = getByTestId("add-credit-card-button") - - expect(addButton.props.accessibilityState.disabled).toEqual(true) + await waitFor(() => expect(addButton.props.accessibilityState.disabled).toEqual(true)) }) - it("is enabled while the form is valid", async () => { + it("is enabled while the form is valid", () => { const onSubmitMock = jest.fn() ;(createToken as jest.Mock).mockReturnValueOnce(stripeToken) - const { getByTestId } = renderWithWrappers( + renderWithWrappers( null, - } as any - } + navigator={{ pop: () => null } as any} + billingAddress={{ + fullName: "mockName", + addressLine1: "mockAddress1", + addressLine2: "mockAddress2", + city: "mockCity", + state: "mockState", + postalCode: "mockPostalCode", + phoneNumber: "mockPhone", + country: { shortName: "US", longName: "United States" }, + }} /> ) - const creditCardField = getByTestId("credit-card-field") + const creditCardField = screen.getByTestId("credit-card-field") fireEvent.changeText(creditCardField, creditCard.number) - await flushPromiseQueue() - - const addButton = getByTestId("add-credit-card-button") + const addButton = screen.getByTestId("credit-card-form-button") expect(addButton.props.accessibilityState.disabled).toEqual(false) }) @@ -191,26 +196,32 @@ describe("CreditCardForm", () => { console.error = jest.fn() ;(createToken as jest.Mock).mockResolvedValueOnce({ error: "error" }) - const { getByTestId } = renderWithWrappers( + renderWithWrappers( null, - } as any - } + navigator={{ pop: () => null } as any} + billingAddress={{ + fullName: "mockName", + addressLine1: "mockAddress1", + addressLine2: "mockAddress2", + city: "mockCity", + state: "mockState", + postalCode: "mockPostalCode", + phoneNumber: "mockPhone", + country: { shortName: "US", longName: "United States" }, + }} /> ) - const creditCardField = getByTestId("credit-card-field") + const creditCardField = screen.getByTestId("credit-card-field") fireEvent.changeText(creditCardField, creditCard.number) - const addButton = getByTestId("add-credit-card-button") + const addButton = screen.getByTestId("credit-card-form-button") fireEvent.press(addButton) - await flushPromiseQueue() + await waitFor(() => expect(console.error).toHaveBeenCalled()) - const errorMessage = getByTestId("error-message") - expect(errorMessage.props.children).toEqual("There was an error. Please try again.") + expect(screen.getByTestId("credit-card-error-message")).toBeOnTheScreen() + expect(screen.getByText("There was an error. Please try again.")).toBeOnTheScreen() }) }) diff --git a/src/app/Components/Bidding/Screens/CreditCardForm.tsx b/src/app/Components/Bidding/Screens/CreditCardForm.tsx index d06d46e71e7..b1268ee321e 100644 --- a/src/app/Components/Bidding/Screens/CreditCardForm.tsx +++ b/src/app/Components/Bidding/Screens/CreditCardForm.tsx @@ -1,117 +1,293 @@ -import { Box, Text, Button, Spacer } from "@artsy/palette-mobile" +import { + ArtsyKeyboardAvoidingView, + Box, + Button, + Flex, + Input, + Spacer, + Text, + useSpace, +} from "@artsy/palette-mobile" import { createToken, Token } from "@stripe/stripe-react-native" -import { Container } from "app/Components/Bidding/Components/Containers" -import { PaymentCardTextFieldParams } from "app/Components/Bidding/types" -import { BottomAlignedButtonWrapper } from "app/Components/Buttons/BottomAlignedButtonWrapper" +import { CreateCardTokenParams } from "@stripe/stripe-react-native/lib/typescript/src/types/Token" +import { Details } from "@stripe/stripe-react-native/lib/typescript/src/types/components/CardFieldInput" +import { + CREDIT_CARD_INITIAL_FORM_VALUES, + CreditCardFormValues, +} from "app/Components/Bidding/Utils/creditCardFormFields" +import { findCountryNameByCountryCode } from "app/Components/Bidding/Utils/findCountryNameByCountryCode" +import { creditCardFormValidationSchema } from "app/Components/Bidding/Validators/creditCardFormFieldsValidationSchema" +import { Address } from "app/Components/Bidding/types" +import { CountrySelect } from "app/Components/CountrySelect" import { CreditCardField } from "app/Components/CreditCardField/CreditCardField" import { FancyModalHeader } from "app/Components/FancyModal/FancyModalHeader" +import { Select } from "app/Components/Select/SelectV2" import NavigatorIOS from "app/utils/__legacy_do_not_use__navigator-ios-shim" -import { Component } from "react" -import { ScrollView, View } from "react-native" +import { useFormik } from "formik" +import { useRef } from "react" +import { ScrollView } from "react-native" interface CreditCardFormProps { navigator: NavigatorIOS - params?: PaymentCardTextFieldParams - onSubmit: (t: Token.Result, p: PaymentCardTextFieldParams) => void + billingAddress?: Address | null + onSubmit: (t: Token.Result, a: Address) => void } -interface CreditCardFormState { - valid: boolean | null - params: Partial - isLoading: boolean - isError: boolean -} - -export class CreditCardForm extends Component { - constructor(props: CreditCardFormProps) { - super(props) - this.state = { valid: null, params: { ...this.props.params }, isLoading: false, isError: false } +export const CreditCardForm: React.FC = ({ + onSubmit, + billingAddress, + navigator, +}) => { + const space = useSpace() + const initialValues: CreditCardFormValues = { + ...CREDIT_CARD_INITIAL_FORM_VALUES, + ...billingAddress, } - tokenizeCardAndSubmit = async () => { - this.setState({ isLoading: true, isError: false }) - - const { params } = this.state + const { + values, + errors, + touched, + isSubmitting, + isValid, + dirty, + handleSubmit, + handleBlur, + handleChange, + setFieldValue, + setErrors, + } = useFormik({ + initialValues, + validationSchema: creditCardFormValidationSchema, + onSubmit: async (values) => { + try { + const tokenBody = buildTokenParams(values) + const token = await createToken(tokenBody) - try { - const token = await createToken({ ...params, type: "Card" }) + if (token.error) { + throw new Error(`[Stripe]: error creating the token: ${JSON.stringify(token.error)}`) + } - if (token.error) { - throw new Error(`[Stripe]: error creating the token: ${JSON.stringify(token.error)}`) + onSubmit(token.token, buildBillingAddress(values)) + navigator.pop() + } catch (error) { + setErrors({ creditCard: { valid: "There was an error. Please try again." } }) + console.error("CreditCardForm.tsx", error) } + }, + }) + + const handleOnCardChange = (cardDetails: Details) => { + setFieldValue("creditCard", { + valid: cardDetails.complete, + params: { + expiryMonth: cardDetails.expiryMonth, + expiryYear: cardDetails.expiryYear, + last4: cardDetails.last4, + }, + }) + } + + const showError = (field: keyof CreditCardFormValues): string | undefined => { + if (field === "creditCard") { + return touched[field] ? errors.creditCard?.valid : undefined + } - // If the form is valid we can assume all params have been filled - this.props.onSubmit(token.token, this.state.params as PaymentCardTextFieldParams) - this.setState({ isLoading: false }) - this.props.navigator.pop() - } catch (error) { - console.error("CreditCardForm.tsx", error) - this.setState({ isError: true, isLoading: false }) + if (field == "country") { + return touched[field] ? errors[field]?.shortName : undefined } + + return touched[field] ? errors[field] : undefined } - render() { - const buttonComponent = ( + // Inputs refs + const addressLine1Ref = useRef(null) + const addressLine2Ref = useRef(null) + const cityRef = useRef(null) + const stateRef = useRef(null) + const postalCodeRef = useRef(null) + const phoneRef = useRef(null) + const countryRef = useRef>(null) + + return ( + + navigator.pop()}>Add Credit Card + + + + <> + + + {!!errors.creditCard?.valid && ( + + {errors.creditCard.valid} + + )} + + + addressLine1Ref.current?.focus()} + /> + addressLine2Ref.current?.focus()} + /> + cityRef.current?.focus()} + /> + stateRef.current?.focus()} + /> + postalCodeRef.current?.focus()} + /> + phoneRef.current?.focus()} + /> + phoneRef.current?.blur()} + /> + + + + + setFieldValue("country", { + shortName: countryCode, + longName: findCountryNameByCountryCode(countryCode) || "", + }) + } + value={values.country.shortName} + hasError={!!showError("country")} + /> + {!!showError("country") && ( + + {showError("country")} + + )} + + + + + + Registration is free. + {"\n"} + {"\n"}A valid credit card is required in order to bid. Please enter your credit card + information below. The name on your Artsy account must match the name on the card. + + + + + - ) + + ) +} - const errorText = "There was an error. Please try again." +const buildTokenParams = (values: CreditCardFormValues): CreateCardTokenParams => { + return { + type: "Card", + name: values.fullName ?? undefined, + address: { + line1: values.addressLine1 ?? undefined, + line2: values.addressLine2 ?? undefined, + city: values.city ?? undefined, + state: values.state ?? undefined, + country: values.country?.shortName ?? undefined, + postalCode: values.postalCode ?? undefined, + }, + } +} - return ( - this.tokenizeCardAndSubmit() : undefined} - buttonComponent={buttonComponent} - > - this.props.navigator?.pop()}> - Add credit card - - - - - - { - this.setState({ - valid: cardDetails.complete, - params: { - expiryMonth: cardDetails.expiryMonth, - expiryYear: cardDetails.expiryYear, - last4: cardDetails.last4, - }, - }) - }} - /> - {!!this.state.isError && ( - - {errorText} - - )} - - - - - Registration is free. - {"\n"} - {"\n"}A valid credit card is required in order to bid. Please enter your credit - card information below. The name on your Artsy account must match the name on the - card. - - - - - - - ) +const buildBillingAddress = (values: CreditCardFormValues): Address => { + return { + fullName: values.fullName ?? "", + addressLine1: values.addressLine1 ?? "", + addressLine2: values.addressLine2 ?? "", + city: values.city ?? "", + state: values.state ?? "", + country: values.country ?? { longName: "", shortName: "" }, + postalCode: values.postalCode ?? "", + phoneNumber: values.phoneNumber ?? "", } } diff --git a/src/app/Components/Bidding/Screens/Registration.tests.tsx b/src/app/Components/Bidding/Screens/Registration.tests.tsx index 9507c32e951..b4b84e5c681 100644 --- a/src/app/Components/Bidding/Screens/Registration.tests.tsx +++ b/src/app/Components/Bidding/Screens/Registration.tests.tsx @@ -16,7 +16,6 @@ import { navigate } from "app/system/navigation/navigate" import { renderWithWrappers, renderWithWrappersLEGACY } from "app/utils/tests/renderWithWrappers" import { TouchableWithoutFeedback } from "react-native" import relay from "react-relay" -import { BillingAddress } from "./BillingAddress" import { CreditCardForm } from "./CreditCardForm" import { Registration } from "./Registration" @@ -35,14 +34,14 @@ const mockPostNotificationName = LegacyNativeModules.ARNotificationsManager.post it("renders properly for a user without a credit card", () => { renderWithWrappers() - expect(screen.queryByText("A valid credit card is required.")).toBeOnTheScreen() + expect(screen.getByText("A valid credit card is required.")).toBeOnTheScreen() }) describe("User does not have a valid phone number", () => { it("renders properly for a user without a phone number", () => { renderWithWrappers() - expect(screen.queryByText("A valid phone number is required.")).toBeOnTheScreen() + expect(screen.getByText("A valid phone number is required.")).toBeOnTheScreen() }) }) @@ -50,7 +49,7 @@ it("renders properly for a user with a credit card and phone", () => { renderWithWrappers() expect( - screen.queryByText( + screen.getByText( "To complete your registration, please confirm that you agree to the Conditions of Sale." ) ).toBeOnTheScreen() @@ -70,7 +69,7 @@ it("renders properly for a verified user with a credit card and phone", () => { ) expect( - screen.queryByText( + screen.getByText( "To complete your registration, please confirm that you agree to the Conditions of Sale." ) ).toBeOnTheScreen() @@ -78,46 +77,39 @@ it("renders properly for a verified user with a credit card and phone", () => { expect(screen.queryByText("valid credit card")).not.toBeOnTheScreen() }) -it("shows the billing address that the user typed in the billing address form", () => { - const billingAddressRow = renderWithWrappersLEGACY( +it("shows the credit card form when the user tap the edit text in the credit card row", async () => { + const { root } = renderWithWrappersLEGACY( - ).root.findAllByType(BidInfoRow)[1] - billingAddressRow.instance.props.onPress() - expect(nextStep.component).toEqual(BillingAddress) - - nextStep.passProps.onSubmit(billingAddress) - - expect(billingAddressRow.findAllByType(Text)[1].props.children).toEqual( - "401 Broadway 25th floor New York NY" ) -}) - -it("shows the credit card form when the user tap the edit text in the credit card row", () => { - const creditcardRow = renderWithWrappersLEGACY( - - ).root.findAllByType(BidInfoRow)[0] + const creditCardRow = await root.findAllByType(BidInfoRow) - creditcardRow.instance.props.onPress() + creditCardRow[0].instance.props.onPress() expect(nextStep.component).toEqual(CreditCardForm) }) -it("shows the option for entering payment information if the user does not have a credit card on file", () => { - const component = renderWithWrappersLEGACY( +it("shows the option for entering payment information if the user does not have a credit card on file", async () => { + const { root } = renderWithWrappersLEGACY( ) - expect(component.root.findAllByType(Checkbox).length).toEqual(1) - expect(component.root.findAllByType(BidInfoRow).length).toEqual(2) + const checkboxes = await root.findAllByType(Checkbox) + const bidInfoRows = await root.findAllByType(BidInfoRow) + + expect(checkboxes.length).toEqual(1) + expect(bidInfoRows.length).toEqual(1) }) -it("shows no option for entering payment information if the user has a credit card on file", () => { - const component = renderWithWrappersLEGACY( +it("shows no option for entering payment information if the user has a credit card on file", async () => { + const { root } = renderWithWrappersLEGACY( ) - expect(component.root.findAllByType(Checkbox).length).toEqual(1) - expect(component.root.findAllByType(BidInfoRow).length).toEqual(0) + const checkboxes = await root.findAllByType(Checkbox) + const bidInfoRows = await root.findAllByType(BidInfoRow) + + expect(checkboxes.length).toEqual(1) + expect(bidInfoRows.length).toEqual(0) }) describe("when the sale requires identity verification", () => { @@ -129,22 +121,26 @@ describe("when the sale requires identity verification", () => { }, } - it("displays information about IDV if the user is not verified", () => { - const component = renderWithWrappersLEGACY( + it("displays information about IDV if the user is not verified", async () => { + const { root } = renderWithWrappersLEGACY( ) - expect(component.root.findAllByType(Text)[6].props.children).toEqual( + const text = await root.findAllByType(Text) + + expect(text.map(({ props }) => props.children)[4]).toEqual( "This auction requires Artsy to verify your identity before bidding." ) }) - it("does not display information about IDV if the user is verified", () => { - const component = renderWithWrappersLEGACY( + it("does not display information about IDV if the user is verified", async () => { + const { root } = renderWithWrappersLEGACY( ) - expect(component.root.findAllByType(Text).map(({ props }) => props.children)).not.toContain( + const text = await root.findAllByType(Text) + + expect(text.map(({ props }) => props.children)).not.toContain( "This auction requires Artsy to verify your identity before bidding." ) }) @@ -171,16 +167,19 @@ describe("when pressing register button", () => { }) as any ;(createToken as jest.Mock).mockReturnValueOnce(stripeToken) - const component = renderWithWrappersLEGACY( + const { root } = renderWithWrappersLEGACY( ) - component.root.findByType(Registration).instance.setState({ + const registrationComponent = await root.findByType(Registration) + registrationComponent.instance.setState({ conditionsOfSaleChecked: true, billingAddress, - creditCardToken: stripeToken, + creditCardToken: stripeToken.token, }) - await component.root.findByProps({ testID: "register-button" }).props.onPress() + const registerButton = await root.findByProps({ testID: "register-button" }) + await registerButton.props.onPress() + expect(relay.commitMutation).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ @@ -227,37 +226,38 @@ describe("when pressing register button", () => { expect(relay.commitMutation).toHaveBeenCalled() }) - it("disables tap events while a spinner is being shown", () => { + it("disables tap events while a spinner is being shown", async () => { const navigator = { push: jest.fn() } as any relay.commitMutation = jest.fn() - const component = renderWithWrappersLEGACY( + const { root } = renderWithWrappersLEGACY( ) - component.root.findByType(Registration).instance.setState({ + const registrationComponent = await root.findByType(Registration) + + registrationComponent.instance.setState({ conditionsOfSaleChecked: true, creditCardToken: stripeToken, billingAddress, }) - component.root.findByProps({ testID: "register-button" }).props.onPress() + const registerButton = await root.findByProps({ testID: "register-button" }) - const yourMaxBidRow = component.root.findAllByType(TouchableWithoutFeedback)[0] - const creditCardRow = component.root.findAllByType(TouchableWithoutFeedback)[1] - const billingAddressRow = component.root.findAllByType(TouchableWithoutFeedback)[2] - const conditionsOfSaleLink = component.root.findByType(LinkText) - const conditionsOfSaleCheckbox = component.root.findByType(Checkbox) + registerButton.props.onPress() - yourMaxBidRow.instance.props.onPress() + const buttons = await root.findAllByType(TouchableWithoutFeedback) - expect(navigator.push).not.toHaveBeenCalled() + const yourMaxBidRow = buttons[0] + const creditCardRow = buttons[1] + const conditionsOfSaleLink = await root.findByType(LinkText) + const conditionsOfSaleCheckbox = await root.findByType(Checkbox) - creditCardRow.instance.props.onPress() + yourMaxBidRow.instance.props.onPress() expect(navigator.push).not.toHaveBeenCalled() - billingAddressRow.instance.props.onPress() + creditCardRow.instance.props.onPress() expect(navigator.push).not.toHaveBeenCalled() @@ -265,7 +265,8 @@ describe("when pressing register button", () => { expect(conditionsOfSaleCheckbox.props.disabled).toBeTruthy() }) - it("displays an error message on a stripe failure", async () => { + // skipping this test since we don't create a ticket on registration now + xit("displays an error message on a stripe failure", async () => { relay.commitMutation = jest .fn() .mockImplementationOnce((_, { onCompleted }) => diff --git a/src/app/Components/Bidding/Screens/Registration.tsx b/src/app/Components/Bidding/Screens/Registration.tsx index e67c5b81fdc..196f908b955 100644 --- a/src/app/Components/Bidding/Screens/Registration.tsx +++ b/src/app/Components/Bidding/Screens/Registration.tsx @@ -123,8 +123,8 @@ export class Registration extends React.Component null } as any) : this.props.navigator} onCreditCardAdded={this.onCreditCardAdded.bind(this)} - onBillingAddressAdded={this.onBillingAddressAdded.bind(this)} billingAddress={this.state.billingAddress} creditCardFormParams={this.state.creditCardFormParams} creditCardToken={this.state.creditCardToken} diff --git a/src/app/Components/Bidding/Utils/creditCardFormFields.ts b/src/app/Components/Bidding/Utils/creditCardFormFields.ts new file mode 100644 index 00000000000..b445ca76a9c --- /dev/null +++ b/src/app/Components/Bidding/Utils/creditCardFormFields.ts @@ -0,0 +1,35 @@ +import { Country, PaymentCardTextFieldParams } from "app/Components/Bidding/types" + +export interface CreditCardFormValues { + creditCard: { + valid: boolean + params: Partial + } + fullName: string + addressLine1: string + addressLine2: string + city: string + postalCode: string + state: string + country: Country + phoneNumber: string +} + +export const CREDIT_CARD_INITIAL_FORM_VALUES: CreditCardFormValues = { + creditCard: { + valid: false, + params: { + expiryMonth: undefined, + expiryYear: undefined, + last4: undefined, + }, + }, + fullName: "", + addressLine1: "", + addressLine2: "", + city: "", + postalCode: "", + state: "", + country: { longName: "", shortName: "" }, + phoneNumber: "", +} diff --git a/src/app/Components/Bidding/Validators/creditCardFormFieldsValidationSchema.ts b/src/app/Components/Bidding/Validators/creditCardFormFieldsValidationSchema.ts new file mode 100644 index 00000000000..7358751777f --- /dev/null +++ b/src/app/Components/Bidding/Validators/creditCardFormFieldsValidationSchema.ts @@ -0,0 +1,16 @@ +import * as Yup from "yup" + +export const creditCardFormValidationSchema = Yup.object().shape({ + creditCard: Yup.object().shape({ + valid: Yup.boolean().required("Credit card is required"), + }), + fullName: Yup.string().required("Name is required"), + addressLine1: Yup.string().required("Address is required"), + city: Yup.string().required("City is required"), + postalCode: Yup.string().required("Postal code is required"), + state: Yup.string().required("State is required"), + phoneNumber: Yup.string().required("Phone number is required"), + country: Yup.object().shape({ + shortName: Yup.string().required("Country is required"), + }), +}) diff --git a/src/app/Components/Stack.tsx b/src/app/Components/Stack.tsx index 216fc2bbb19..e5bde701325 100644 --- a/src/app/Components/Stack.tsx +++ b/src/app/Components/Stack.tsx @@ -1,5 +1,8 @@ import { Spacer, SpacingUnit, Flex, Join } from "@artsy/palette-mobile" +/** + * @deprecated Please use `` instead + */ export const Stack: React.FC< { spacing?: SpacingUnit; horizontal?: boolean } & React.ComponentPropsWithoutRef > = ({ children, spacing = 2, horizontal, ...others }) => { diff --git a/src/app/Scenes/MyProfile/MyProfilePaymentNewCreditCard.tsx b/src/app/Scenes/MyProfile/MyProfilePaymentNewCreditCard.tsx index bb0ce1a17bc..342cf35e4fa 100644 --- a/src/app/Scenes/MyProfile/MyProfilePaymentNewCreditCard.tsx +++ b/src/app/Scenes/MyProfile/MyProfilePaymentNewCreditCard.tsx @@ -19,14 +19,15 @@ interface CreditCardInputParams { last4: string } -interface FormField { +export interface FormField { value: Type | null touched: boolean required: boolean isPresent: Computed setValue: Action } -const emptyFieldState: () => FormField = () => ({ + +export const emptyFieldState: () => FormField = () => ({ value: null, touched: false, required: true, @@ -42,7 +43,7 @@ const emptyFieldState: () => FormField = () => ({ }), }) -interface FormFields { +export interface FormFields { creditCard: FormField<{ valid: boolean params: CreditCardInputParams @@ -51,7 +52,7 @@ interface FormFields { addressLine1: FormField addressLine2: FormField city: FormField - postCode: FormField + postalCode: FormField state: FormField country: FormField } @@ -71,7 +72,7 @@ export const MyProfilePaymentNewCreditCard: React.FC<{}> = ({}) => { addressLine1: emptyFieldState(), addressLine2: { ...emptyFieldState(), required: false }, city: emptyFieldState(), - postCode: emptyFieldState(), + postalCode: emptyFieldState(), state: emptyFieldState(), country: emptyFieldState(), }, @@ -102,7 +103,7 @@ export const MyProfilePaymentNewCreditCard: React.FC<{}> = ({}) => { city: state.fields.city.value ?? undefined, state: state.fields.state.value ?? undefined, country: state.fields.country.value ?? undefined, - postalCode: state.fields.postCode.value ?? undefined, + postalCode: state.fields.postalCode.value ?? undefined, }, } } @@ -197,7 +198,7 @@ export const MyProfilePaymentNewCreditCard: React.FC<{}> = ({}) => { stateRef.current?.focus()} />