diff --git a/static/js/containers/pages/CheckoutPage.js b/static/js/containers/pages/CheckoutPage.js index cbd3aceaf2..e52d69a14c 100644 --- a/static/js/containers/pages/CheckoutPage.js +++ b/static/js/containers/pages/CheckoutPage.js @@ -2,19 +2,22 @@ import React from "react" import * as R from "ramda" import { connect } from "react-redux" -import { connectRequest, mutateAsync } from "redux-query" +import { mutateAsync, requestAsync } from "redux-query" import { compose } from "redux" +import queryString from "query-string" import queries from "../../lib/queries" import { calculateDiscount, calculatePrice, formatPrice, - formatRunTitle + formatRunTitle, + formatErrors } from "../../lib/ecommerce" import { createCyberSourceForm } from "../../lib/form" import type { Response } from "redux-query" +import type { Location } from "react-router" import type { BasketResponse, BasketPayload, @@ -50,6 +53,8 @@ export const calcSelectedRunIds = (item: BasketItem): { [number]: number } => { type Props = { basket: ?BasketResponse, checkout: () => Promise>, + fetchBasket: () => Promise<*>, + location: Location, updateBasket: (payload: BasketPayload) => Promise<*> } type State = { @@ -64,6 +69,30 @@ export class CheckoutPage extends React.Component { errors: null } + componentDidMount = async () => { + const { + fetchBasket, + location: { search } + } = this.props + const params = queryString.parse(search) + const productId = parseInt(params.product) + if (!productId) { + await fetchBasket() + return + } + + try { + await this.updateBasket({ items: [{ id: productId }] }) + + const couponCode = params.code + if (couponCode) { + await this.updateBasket({ coupons: [{ code: couponCode }] }) + } + } catch (_) { + // prevent complaints about unresolved promises + } + } + handleErrors = async (responsePromise: Promise<*>) => { const response = await responsePromise if (response.body.errors) { @@ -202,13 +231,14 @@ export class CheckoutPage extends React.Component { const { basket } = this.props const { couponCode, errors } = this.state - if (!basket) { - return null - } - - const item = basket.items[0] - if (!item) { - return
No item in basket
+ const item = basket && basket.items[0] + if (!basket || !item) { + return ( +
+ No item in basket + {formatErrors(errors)} +
+ ) } const coupon = basket.coupons.find(coupon => @@ -259,7 +289,7 @@ export class CheckoutPage extends React.Component { Apply - {errors ?
Error: {errors}
: null} + {formatErrors(errors)} @@ -302,15 +332,14 @@ const mapStateToProps = state => ({ }) const mapDispatchToProps = dispatch => ({ checkout: () => dispatch(mutateAsync(queries.ecommerce.checkoutMutation())), + fetchBasket: () => dispatch(requestAsync(queries.ecommerce.basketQuery())), updateBasket: payload => dispatch(mutateAsync(queries.ecommerce.basketMutation(payload))) }) -const mapPropsToConfigs = () => [queries.ecommerce.basketQuery()] export default compose( connect( mapStateToProps, mapDispatchToProps - ), - connectRequest(mapPropsToConfigs) + ) )(CheckoutPage) diff --git a/static/js/containers/pages/CheckoutPage_test.js b/static/js/containers/pages/CheckoutPage_test.js index 16c7549de3..5f5a3f50a5 100644 --- a/static/js/containers/pages/CheckoutPage_test.js +++ b/static/js/containers/pages/CheckoutPage_test.js @@ -18,7 +18,7 @@ import { formatPrice, formatRunTitle } from "../../lib/ecommerce" -import { assertRaises } from "../../lib/util" +import { assertRaises, wait } from "../../lib/util" import { PRODUCT_TYPE_COURSERUN, PRODUCT_TYPE_PROGRAM } from "../../constants" describe("CheckoutPage", () => { @@ -36,7 +36,9 @@ describe("CheckoutPage", () => { basket } }, - {} + { + location: {} + } ) }) @@ -102,6 +104,132 @@ describe("CheckoutPage", () => { assert.equal(inner.find("img").prop("alt"), basketItem.description) assert.equal(inner.find(".item-row .title").text(), basketItem.description) }) + ;[true, false].forEach(hasError => { + it(`updates the basket with a product id from the query parameter${ + hasError ? ", but an error is returned" : "" + }`, async () => { + const productId = 4567 + if (hasError) { + helper.handleRequestStub.withArgs("/api/basket/", "PATCH").returns({ + status: 400, + body: { + errors: "error" + } + }) + } + const { inner } = await renderPage( + {}, + { + location: { + search: `product=${productId}` + } + } + ) + + sinon.assert.calledWith( + helper.handleRequestStub, + "/api/basket/", + "PATCH", + { + body: { items: [{ id: productId }] }, + credentials: undefined, + headers: { + "X-CSRFTOKEN": null + } + } + ) + assert.equal(inner.state().errors, hasError ? "error" : null) + }) + }) + + // + ;[[true, false], [true, true], [false, false], [false, true]].forEach( + ([hasValidProductId, hasValidCoupon]) => { + it.only(`updates the basket with a ${ + hasValidProductId ? "" : "in" + }valid product id and a ${ + hasValidCoupon ? "" : "in" + }valid coupon code from the query parameter`, async () => { + const couponCode = "codeforcoupon" + const productId = 12345 + const couponPayload = { + body: { coupons: [{ code: couponCode }] }, + credentials: undefined, + headers: { + "X-CSRFTOKEN": null + } + } + const productPayload = { + body: { items: [{ id: productId }] }, + credentials: undefined, + headers: { + "X-CSRFTOKEN": null + } + } + + if (!hasValidProductId) { + helper.handleRequestStub + .withArgs("/api/basket/", "PATCH", productPayload) + .returns({ + status: 400, + body: { + errors: "product error" + } + }) + } + if (!hasValidCoupon) { + helper.handleRequestStub + .withArgs("/api/basket/", "PATCH", couponPayload) + .returns({ + status: 400, + body: { + errors: "coupon error" + } + }) + } + const { inner } = await renderPage( + {}, + { + location: { + search: `product=${productId}&code=${couponCode}` + } + } + ) + // wait for componentDidMount to resolve + await wait(15) + sinon.assert.calledWith( + helper.handleRequestStub, + "/api/basket/", + "PATCH", + productPayload + ) + if (hasValidProductId) { + sinon.assert.calledWith( + helper.handleRequestStub, + "/api/basket/", + "PATCH", + couponPayload + ) + } else { + sinon.assert.neverCalledWith( + helper.handleRequestStub, + "/api/basket/", + "PATCH", + couponPayload + ) + } + + assert.equal( + inner.state().errors, + !hasValidProductId + ? "product error" + : !hasValidCoupon + ? "coupon error" + : null + ) + }) + } + ) it("displays the coupon code", async () => { const { inner } = await renderPage() @@ -167,10 +295,7 @@ describe("CheckoutPage", () => { }) assert.equal(inner.state().errors, errors) - assert.equal( - inner.find(".enrollment-input .error").text(), - "Error: Unknown error" - ) + assert.equal(inner.find(".enrollment-input .error").text(), "Unknown error") assert.isTrue(inner.find(".enrollment-input input.error-border").exists()) }) diff --git a/static/js/lib/ecommerce.js b/static/js/lib/ecommerce.js index 87c6271471..3e330f91ef 100644 --- a/static/js/lib/ecommerce.js +++ b/static/js/lib/ecommerce.js @@ -1,4 +1,5 @@ // @flow +import React from "react" import Decimal from "decimal.js-light" import * as R from "ramda" import { equals } from "ramda" @@ -57,6 +58,24 @@ const formatDateForRun = (dateString: ?string) => export const formatRunTitle = (run: CourseRun) => `${formatDateForRun(run.start_date)} - ${formatDateForRun(run.end_date)}` +export const formatErrors = (errors: string | Object) => { + if (!errors) { + return null + } + + let errorString + if (typeof errors === "object") { + if (errors.items) { + errorString = errors.items[0] + } else { + errorString = errors[0] + } + } else { + errorString = errors + } + return
{errorString}
+} + export const isPromo = equals(COUPON_TYPE_PROMO) export const createProductMap = (