diff --git a/src/order-history/OrderHistoryPage.jsx b/src/order-history/OrderHistoryPage.jsx index 28358784..2462c9fd 100644 --- a/src/order-history/OrderHistoryPage.jsx +++ b/src/order-history/OrderHistoryPage.jsx @@ -1,9 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { - getConfig, -} from '@edx/frontend-platform'; +import { getConfig } from '@edx/frontend-platform'; import { injectIntl, intlShape, @@ -157,55 +155,45 @@ class OrderHistoryPage extends React.Component { ); } - renderError() { + renderLoading() { return ( -
- {this.props.intl.formatMessage(messages['ecommerce.order.history.loading.error'], { - error: this.props.loadingError, - })} -
+ ); } - renderLoading() { - return ( - + renderOrders() { + const hasOrders = this.props.orders.length > 0; + + return hasOrders ? ( + <> + + {this.renderMobileOrdersTable()} + + + {this.renderOrdersTable()} + + {this.renderPagination()} + + ) : ( + this.renderEmptyMessage() ); } render() { - const { - loading, - loadingError, - orders, - } = this.props; - const loaded = !loading && !loadingError; - const hasOrders = orders.length > 0; - const heading = this.props.intl.formatMessage( + const { loading, intl, isB2CSubsEnabled } = this.props; + + const heading = intl.formatMessage( messages['ecommerce.order.history.page.heading'], ); return (
- {this.props.isB2CSubsEnabled ?

{heading}

:

{heading}

} -
- {loadingError ? this.renderError() : null} - {loaded && hasOrders ? ( - <> - - {this.renderMobileOrdersTable()} - - - {this.renderOrdersTable()} - - {this.renderPagination()} - - ) : null} - {loaded && !hasOrders ? this.renderEmptyMessage() : null} - {loading && !this.props.isB2CSubsEnabled - ? this.renderLoading() - : null} -
+ {isB2CSubsEnabled ?

{heading}

:

{heading}

} +
{loading ? this.renderLoading() : this.renderOrders()}
); } @@ -230,13 +218,11 @@ OrderHistoryPage.propTypes = { count: PropTypes.number, currentPage: PropTypes.number, loading: PropTypes.bool, - loadingError: PropTypes.string, fetchOrders: PropTypes.func.isRequired, }; OrderHistoryPage.defaultProps = { orders: [], - loadingError: null, loading: false, pageCount: 0, count: 0, diff --git a/src/order-history/OrderHistoryPage.messages.jsx b/src/order-history/OrderHistoryPage.messages.jsx index a3d1830f..e1a81b58 100644 --- a/src/order-history/OrderHistoryPage.messages.jsx +++ b/src/order-history/OrderHistoryPage.messages.jsx @@ -16,11 +16,6 @@ const messages = defineMessages({ defaultMessage: 'Loading orders...', description: 'Message when orders are being loaded', }, - 'ecommerce.order.history.loading.error': { - id: 'ecommerce.order.history.loading.error', - defaultMessage: 'Error: {error}', - description: 'Message when orders are fail to load', - }, 'ecommerce.order.history.view.order.detail': { id: 'ecommerce.order.history.view.order.detail', defaultMessage: 'View Order Details', diff --git a/src/order-history/OrderHistoryPage.test.jsx b/src/order-history/OrderHistoryPage.test.jsx index f5cd3431..c56a643a 100644 --- a/src/order-history/OrderHistoryPage.test.jsx +++ b/src/order-history/OrderHistoryPage.test.jsx @@ -17,7 +17,8 @@ const requiredOrderHistoryPageProps = { // Match all media queries. This will result in rendering // both the desktop and mobile views at the same time. -global.matchMedia = media => ({ // eslint-disable-line no-unused-vars +// eslint-disable-next-line no-unused-vars +global.matchMedia = (media) => ({ addListener: () => {}, removeListener: () => {}, matches: true, @@ -27,13 +28,63 @@ describe('', () => { describe('Renders correctly in various states', () => { it('renders orders table with pagination', () => { const tree = renderer - .create(( + .create( - - )) + , + ) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders empty orders', () => { + const storeMockWithoutOrders = { + ...storeMocks, + orderHistory: { + ...storeMocks.orders, + orders: [], + count: 0, + pageCount: 0, + currentPage: null, + }, + }; + + const tree = renderer + .create( + + + + + , + ) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders loading state', () => { + const storeMockWithLoading = { + ...storeMocks, + orderHistory: { + ...storeMocks.orders, + loading: true, + loadingError: false, + orders: [], + count: 0, + pageCount: 0, + currentPage: null, + }, + }; + + const tree = renderer + .create( + + + + + , + ) .toJSON(); expect(tree).toMatchSnapshot(); }); diff --git a/src/order-history/__snapshots__/OrderHistoryPage.test.jsx.snap b/src/order-history/__snapshots__/OrderHistoryPage.test.jsx.snap index b0717008..69b51133 100644 --- a/src/order-history/__snapshots__/OrderHistoryPage.test.jsx.snap +++ b/src/order-history/__snapshots__/OrderHistoryPage.test.jsx.snap @@ -1,5 +1,53 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[` Renders correctly in various states renders empty orders 1`] = ` +
+

+ Order History +

+
+

+ Orders you place with localhost will appear here. +

+
+
+`; + +exports[` Renders correctly in various states renders loading state 1`] = ` +
+

+ Order History +

+
+
+
+
+ + Loading orders... + +
+
+
+
+
+`; + exports[` Renders correctly in various states renders orders table with pagination 1`] = `
({ - type: FETCH_ORDERS.BASE, - payload: { pageToFetch }, -}); - -export const fetchOrdersBegin = () => ({ - type: FETCH_ORDERS.BEGIN, -}); - -export const fetchOrdersSuccess = result => ({ - type: FETCH_ORDERS.SUCCESS, - payload: result, -}); - -export const fetchOrdersReset = () => ({ - type: FETCH_ORDERS.RESET, -}); +// eslint-disable-next-line import/prefer-default-export +export const fetchOrders = createRoutine('FETCH_Orders'); diff --git a/src/order-history/reducer.js b/src/order-history/reducer.js index e80379e4..63bbb554 100644 --- a/src/order-history/reducer.js +++ b/src/order-history/reducer.js @@ -1,8 +1,8 @@ -import { FETCH_ORDERS } from './actions'; +import { fetchOrders } from './actions'; export const initialState = { loading: false, - loadingError: null, + loadingError: false, orders: [], count: 0, pageCount: 0, @@ -13,13 +13,13 @@ export const initialState = { const orderHistoryPage = (state = initialState, action = {}) => { switch (action.type) { - case FETCH_ORDERS.BEGIN: + case fetchOrders.TRIGGER: return { ...state, - loadingError: null, loading: true, + loadingError: false, }; - case FETCH_ORDERS.SUCCESS: + case fetchOrders.SUCCESS: return { ...state, orders: action.payload.orders, @@ -28,12 +28,15 @@ const orderHistoryPage = (state = initialState, action = {}) => { previous: action.payload.previous, pageCount: action.payload.pageCount, currentPage: action.payload.currentPage, - loading: false, }; - case FETCH_ORDERS.RESET: + case fetchOrders.FAILURE: + return { + ...state, + loadingError: true, + }; + case fetchOrders.FULFILL: return { ...state, - loadingError: null, loading: false, }; default: diff --git a/src/order-history/saga.js b/src/order-history/saga.js index ccdb6ffd..ce09e656 100644 --- a/src/order-history/saga.js +++ b/src/order-history/saga.js @@ -1,24 +1,12 @@ -import { call, put, takeEvery } from 'redux-saga/effects'; +import { takeEvery } from 'redux-saga/effects'; -// Actions -import { - FETCH_ORDERS, - fetchOrdersBegin, - fetchOrdersSuccess, - fetchOrdersReset, -} from './actions'; +import { createFetchHandler } from '../utils'; -// Services -import * as OrdersApiService from './service'; +import { fetchOrders } from './actions'; +import { getOrders } from './service'; -export function* handleFetchOrders(action) { - const { pageToFetch } = action.payload; - yield put(fetchOrdersBegin()); - const result = yield call(OrdersApiService.getOrders, pageToFetch); - yield put(fetchOrdersSuccess(result)); - yield put(fetchOrdersReset()); -} +const handleFetchOrders = createFetchHandler(fetchOrders, getOrders); export default function* orderHistorySaga() { - yield takeEvery(FETCH_ORDERS.BASE, handleFetchOrders); + yield takeEvery(fetchOrders.TRIGGER, handleFetchOrders); } diff --git a/src/orders-and-subscriptions/OrdersAndSubscriptionsPage.test.jsx b/src/orders-and-subscriptions/OrdersAndSubscriptionsPage.test.jsx index b52e0e11..dd0fdbd7 100644 --- a/src/orders-and-subscriptions/OrdersAndSubscriptionsPage.test.jsx +++ b/src/orders-and-subscriptions/OrdersAndSubscriptionsPage.test.jsx @@ -7,8 +7,13 @@ import configureMockStore from 'redux-mock-store'; import OrdersAndSubscriptionsPage from './OrdersAndSubscriptionsPage'; +jest.mock('@edx/frontend-platform/analytics', () => ({ + sendTrackEvent: jest.fn(), +})); + const mockStore = configureMockStore(); const storeMocks = require('../store/__mocks__/mockStore'); +const emptyStoreMocks = require('../store/__mocks__/mockEmptyStore'); describe('', () => { describe('Renders correctly in various states', () => { @@ -24,5 +29,53 @@ describe('', () => { .toJSON(); expect(tree).toMatchSnapshot(); }); + + it('renders alerts on errors', () => { + const storeMocksWithErrors = { + orderHistory: { + ...emptyStoreMocks.orderHistory, + loadingError: true, + }, + subscriptions: { + ...emptyStoreMocks.subscriptions, + loadingError: true, + }, + }; + + const tree = renderer + .create( + + + + + , + ) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders with loadingErrors', () => { + const storeMocksWithLoading = { + orderHistory: { + ...emptyStoreMocks.orderHistory, + loading: true, + }, + subscriptions: { + ...emptyStoreMocks.subscriptions, + loading: true, + }, + }; + + const tree = renderer + .create( + + + + + , + ) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); }); }); diff --git a/src/orders-and-subscriptions/__snapshots__/OrdersAndSubscriptionsPage.test.jsx.snap b/src/orders-and-subscriptions/__snapshots__/OrdersAndSubscriptionsPage.test.jsx.snap index a7f2e1b0..b12c491f 100644 --- a/src/orders-and-subscriptions/__snapshots__/OrdersAndSubscriptionsPage.test.jsx.snap +++ b/src/orders-and-subscriptions/__snapshots__/OrdersAndSubscriptionsPage.test.jsx.snap @@ -1,5 +1,181 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[` Renders correctly in various states renders alerts on errors 1`] = ` +
+
+
+ + + + + +
+
+
+ Something went wrong. +
+

+ Refresh this page and try again. If this problem persists, + + contact support + + . +

+
+
+
+

+ My orders and subscriptions +

+ + Manage your program subscriptions and view your order history. + +
+
+

+ Subscriptions +

+ + You do not have any active or previous subscriptions. + +
+
+
+

+ + New + + Monthly program subscriptions + — + more flexible, more affordable +

+

+ Now available for many popular programs, affordable monthly subscription pricing can help you manage your budget more effectively. Subscriptions start at $39/month USD per program, after a 7-day full access free trial. Cancel at any time. +

+
+
+ + +
+
+
+
+
+

+ Order History +

+
+

+ Orders you place with localhost will appear here. +

+
+
+
+`; + +exports[` Renders correctly in various states renders with loadingErrors 1`] = ` +
+
+

+ My orders and subscriptions +

+ + Manage your program subscriptions and view your order history. + +
+
+
+
+ + Loading orders and subscriptions... + +
+
+
+
+`; + exports[` Renders correctly in various states renders with orders and subscriptions 1`] = `
{ + const { formatMessage } = useIntl(); const dispatch = useDispatch(); const { stripeCustomerPortalURL, stripeError } = useSelector( subscriptionsSelector, @@ -23,7 +25,17 @@ const ManageSubscriptionsPage = () => { } }, [stripeCustomerPortalURL]); - return stripeError ? : ; + return stripeError ? ( + + ) : ( + + ); }; export default ManageSubscriptionsPage; diff --git a/src/subscriptions/ManageSubscriptionsPage.test.jsx b/src/subscriptions/ManageSubscriptionsPage.test.jsx index e467bb2b..4a4f7c29 100644 --- a/src/subscriptions/ManageSubscriptionsPage.test.jsx +++ b/src/subscriptions/ManageSubscriptionsPage.test.jsx @@ -1,6 +1,6 @@ /* eslint-disable global-require */ import React from 'react'; -import renderer from 'react-test-renderer'; +import renderer, { act } from 'react-test-renderer'; import { Provider } from 'react-redux'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import configureMockStore from 'redux-mock-store'; @@ -8,11 +8,46 @@ import configureMockStore from 'redux-mock-store'; import ManageSubscriptionsPage from './ManageSubscriptionsPage'; const mockStore = configureMockStore(); -const storeMocks = require('../store/__mocks__/mockStore'); +const storeMocks = require('../store/__mocks__/mockEmptyStore'); describe('', () => { describe('Renders correctly in various states', () => { - it('renders when url is fetched correctly', () => { + it('navigates when url is fetched correctly', () => { + const storeMocksWithURL = { + ...storeMocks, + subscriptions: { + ...storeMocks.subscriptions, + stripeCustomerPortalURL: 'http://edx.org', + }, + }; + + const mockHrefSetter = jest.fn(); + delete window.location; + window.location = { + ...window.location, + set href(url) { + mockHrefSetter(url); + }, + }; + + jest.useFakeTimers(); + const tree = renderer + .create( + + + + + , + ) + .toJSON(); + act(() => { + jest.runAllTimers(); + }); + expect(tree).toMatchSnapshot(); + expect(mockHrefSetter).toHaveBeenCalledWith('http://edx.org'); + }); + + it('renders loading when url is being fetched', () => { const tree = renderer .create( @@ -24,5 +59,26 @@ describe('', () => { .toJSON(); expect(tree).toMatchSnapshot(); }); + + it('renders error ui when fetching url fails', () => { + const storeMocksWithError = { + ...storeMocks, + subscriptions: { + ...storeMocks.subscriptions, + stripeError: true, + }, + }; + + const tree = renderer + .create( + + + + + , + ) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); }); }); diff --git a/src/subscriptions/__snapshots__/ManageSubscriptionsPage.test.jsx.snap b/src/subscriptions/__snapshots__/ManageSubscriptionsPage.test.jsx.snap index bef69b31..fe08673a 100644 --- a/src/subscriptions/__snapshots__/ManageSubscriptionsPage.test.jsx.snap +++ b/src/subscriptions/__snapshots__/ManageSubscriptionsPage.test.jsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` Renders correctly in various states renders when url is fetched correctly 1`] = ` +exports[` Renders correctly in various states navigates when url is fetched correctly 1`] = `
Renders correctly in various states renders whe
+ > + + Loading manage subscriptions... + +
+
+
+`; + +exports[` Renders correctly in various states renders error ui when fetching url fails 1`] = ` +
+

+ The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again. +

+
+`; + +exports[` Renders correctly in various states renders loading when url is being fetched 1`] = ` +
+
+
+ + Loading manage subscriptions... + +
`; diff --git a/src/utils/index.js b/src/utils/index.js index 212753c6..0de4ac06 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -48,39 +48,6 @@ export function keepKeys(data, whitelist) { return result; } -/** - * Helper class to save time when writing out action types for asynchronous methods. Also helps - * ensure that actions are namespaced. - * - * TODO: Put somewhere common to it can be used by other MFEs. - */ -export class AsyncActionType { - constructor(topic, name) { - this.topic = topic; - this.name = name; - } - - get BASE() { - return `${this.topic}__${this.name}`; - } - - get BEGIN() { - return `${this.topic}__${this.name}__BEGIN`; - } - - get SUCCESS() { - return `${this.topic}__${this.name}__SUCCESS`; - } - - get FAILURE() { - return `${this.topic}__${this.name}__FAILURE`; - } - - get RESET() { - return `${this.topic}__${this.name}__RESET`; - } -} - /** * A higher order helper function to create a redux-saga generator function * diff --git a/src/utils/utils.test.js b/src/utils/utils.test.js index b5d9e130..6b9d5c0d 100644 --- a/src/utils/utils.test.js +++ b/src/utils/utils.test.js @@ -1,5 +1,4 @@ import { - AsyncActionType, modifyObjectKeys, camelCaseObject, snakeCaseObject, @@ -109,16 +108,4 @@ describe('keepKeys', () => { 8: 'sneaky', }); }); - - describe('AsyncActionType', () => { - it('should return well formatted action strings', () => { - const actionType = new AsyncActionType('HOUSE_CATS', 'START_THE_RACE'); - - expect(actionType.BASE).toBe('HOUSE_CATS__START_THE_RACE'); - expect(actionType.BEGIN).toBe('HOUSE_CATS__START_THE_RACE__BEGIN'); - expect(actionType.SUCCESS).toBe('HOUSE_CATS__START_THE_RACE__SUCCESS'); - expect(actionType.FAILURE).toBe('HOUSE_CATS__START_THE_RACE__FAILURE'); - expect(actionType.RESET).toBe('HOUSE_CATS__START_THE_RACE__RESET'); - }); - }); });