From 735adb21d5afe403d195c70033249f14cbea6165 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Tue, 14 Mar 2017 16:36:09 +0200 Subject: [PATCH] Initial client side data loading for ListingPage --- src/Routes.js | 90 ++++++++++++++----- .../ListingPage/ListingPage.duck.js | 55 ++++++++++++ src/containers/ListingPage/ListingPage.js | 52 +++++------ .../ListingPage/ListingPage.test.js | 49 +++++++++- src/containers/reducers.js | 5 +- src/routesConfiguration.js | 1 + 6 files changed, 198 insertions(+), 54 deletions(-) create mode 100644 src/containers/ListingPage/ListingPage.duck.js diff --git a/src/Routes.js b/src/Routes.js index 8b5ade8d..60aa7a06 100644 --- a/src/Routes.js +++ b/src/Routes.js @@ -1,4 +1,4 @@ -import React, { PropTypes } from 'react'; +import React, { Component, PropTypes } from 'react'; import { compose } from 'redux'; import { connect } from 'react-redux'; import { Switch, Route, withRouter } from 'react-router-dom'; @@ -7,29 +7,80 @@ import { NamedRedirect } from './components'; import { withFlattenedRoutes } from './util/routes'; import * as propTypes from './util/propTypes'; -const Routes = props => { - const { isAuthenticated, flattenedRoutes, staticContext } = props; +const { bool, arrayOf, object, func, shape, string, any } = PropTypes; - const renderComponent = (route, matchProps) => { - const { auth, component: RouteComponent } = route; - const { match, location } = matchProps; - const canShowComponent = !auth || isAuthenticated; - if (!canShowComponent) { +class RouteComponentRenderer extends Component { + constructor(props) { + super(props); + this.canShowComponent = this.canShowComponent.bind(this); + } + componentDidMount() { + const { match, route, dispatch } = this.props; + const { loadData, name } = route; + const shouldLoadData = typeof loadData === 'function' && this.canShowComponent(); + + if (shouldLoadData) { + dispatch(loadData(match.params, {})) + .then(() => { + // eslint-disable-next-line no-console + console.log(`loadData success for ${name} route`); + }) + .catch(e => { + // eslint-disable-next-line no-console + console.error(`loadData error for ${name} route`, e); + }); + } + } + canShowComponent() { + const { isAuthenticated, route } = this.props; + const { auth } = route; + return !auth || isAuthenticated; + } + render() { + const { route, match, location, staticContext } = this.props; + const { component: RouteComponent } = route; + const canShow = this.canShowComponent(); + if (!canShow) { staticContext.forbidden = true; } - return canShowComponent + return canShow ? : ; - }; + } +} - const toRouteComponent = route => ( - renderComponent(route, matchProps)} - /> - ); +RouteComponentRenderer.propTypes = { + isAuthenticated: bool.isRequired, + route: propTypes.route.isRequired, + match: shape({ + params: object.isRequired, + url: string.isRequired, + }).isRequired, + location: any.isRequired, + staticContext: object.isRequired, + dispatch: func.isRequired, +}; + +const Routes = props => { + const { isAuthenticated, flattenedRoutes, staticContext, dispatch } = props; + + const toRouteComponent = route => { + const renderProps = { isAuthenticated, route, staticContext, dispatch }; + return ( + ( + + )} + /> + ); + }; return ( @@ -41,12 +92,11 @@ const Routes = props => { Routes.defaultProps = { staticContext: {} }; -const { bool, arrayOf, object } = PropTypes; - Routes.propTypes = { isAuthenticated: bool.isRequired, flattenedRoutes: arrayOf(propTypes.route).isRequired, staticContext: object, + dispatch: func.isRequired, }; const mapStateToProps = state => ({ isAuthenticated: state.Auth.isAuthenticated }); diff --git a/src/containers/ListingPage/ListingPage.duck.js b/src/containers/ListingPage/ListingPage.duck.js new file mode 100644 index 00000000..121b9bff --- /dev/null +++ b/src/containers/ListingPage/ListingPage.duck.js @@ -0,0 +1,55 @@ +import { showListingsSuccess } from '../../ducks/sdk.duck'; + +// ================ Action types ================ // + +export const SHOW_LISTING_REQUEST = 'app/ListingPage/SHOW_LISTING_REQUEST'; +export const SHOW_LISTING_ERROR = 'app/ListingPage/SHOW_LISTING_ERROR'; + +// ================ Reducer ================ // + +const initialState = { + id: null, + showListingError: null, +}; + +const listingPageReducer = (state = initialState, action = {}) => { + const { type, payload } = action; + switch (type) { + case SHOW_LISTING_REQUEST: + return { id: payload.id, showListingError: null }; + case SHOW_LISTING_ERROR: + return { showListingError: payload }; + default: + return state; + } +}; + +export default listingPageReducer; + +// ================ Action creators ================ // + +export const showListingRequest = id => ({ + type: SHOW_LISTING_REQUEST, + payload: { id }, +}); + +export const showListingError = e => ({ + type: SHOW_LISTING_ERROR, + error: true, + payload: e, +}); + +export const showListing = listingId => + (dispatch, getState, sdk) => { + dispatch(showListingRequest(listingId)); + return sdk.listings + .show({ id: listingId, include: ['author', 'images'] }) + .then(data => { + dispatch(showListingsSuccess(data)); + return data; + }) + .catch(e => { + dispatch(showListingError(e)); + throw e; + }); + }; diff --git a/src/containers/ListingPage/ListingPage.js b/src/containers/ListingPage/ListingPage.js index e2c180d0..03b2c9f3 100644 --- a/src/containers/ListingPage/ListingPage.js +++ b/src/containers/ListingPage/ListingPage.js @@ -3,9 +3,12 @@ import { intlShape, injectIntl } from 'react-intl'; import { connect } from 'react-redux'; import { types } from 'sharetribe-sdk'; import { NamedLink, PageLayout } from '../../components'; -import { showListings, getListingsById } from '../../ducks/sdk.duck'; +import { getListingsById } from '../../ducks/sdk.duck'; +import { showListing } from './ListingPage.duck'; import css from './ListingPage.css'; +const { UUID } = types; + // TODO this hard coded info needs to be removed when API supports these features const info = { //title: 'Banyan Studios', @@ -45,14 +48,10 @@ const info = { // N.B. All the presentational content needs to be extracted to their own components export class ListingPageComponent extends Component { - componentDidMount() { - ListingPageComponent.loadData(this.props.params.id, this.props.onLoadListing); - } - render() { - const { entitiesData, intl, params } = this.props; - const id = new types.UUID(params.id); - const listingsById = getListingsById(entitiesData, [id]); + const { params, marketplaceData, showListingError, intl } = this.props; + const id = new UUID(params.id); + const listingsById = getListingsById(marketplaceData, [id]); const currentListing = listingsById.length > 0 ? listingsById[0] : null; const title = currentListing ? currentListing.attributes.title : ''; @@ -140,40 +139,35 @@ export class ListingPageComponent extends Component { const noDataMsg = { id: 'ListingPage.noListingData' }; const noDataError = ; - const loadingOrError = entitiesData.showListingsError ? noDataError : loadingContent; + const loadingOrError = showListingError ? noDataError : loadingContent; return currentListing ? pageContent : loadingOrError; } } -ListingPageComponent.loadData = (id, onLoadListing) => { - onLoadListing(id); -}; +ListingPageComponent.defaultProps = { showListingError: null }; -ListingPageComponent.defaultProps = { listing: null }; - -const { func, object, shape, string } = PropTypes; +const { shape, string, object, instanceOf } = PropTypes; ListingPageComponent.propTypes = { - entitiesData: object.isRequired, - intl: intlShape.isRequired, - onLoadListing: func.isRequired, params: shape({ id: string.isRequired, slug: string.isRequired, }).isRequired, + marketplaceData: object.isRequired, + showListingError: instanceOf(Error), + intl: intlShape.isRequired, }; -const mapStateToProps = state => { - const { data, ListingPage } = state; - const entitiesData = data || {}; - return { ListingPage, entitiesData }; +const mapStateToProps = state => ({ + marketplaceData: state.data, + showListingError: state.ListingPage.showListingError, +}); + +const ListingPage = connect(mapStateToProps)(injectIntl(ListingPageComponent)); + +ListingPage.loadData = params => { + return showListing(new UUID(params.id)); }; -const mapDispatchToProps = dispatch => { - return { - onLoadListing: id => dispatch(showListings({ id, include: ['author', 'images'] })), - }; -}; - -export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListingPageComponent)); +export default ListingPage; diff --git a/src/containers/ListingPage/ListingPage.test.js b/src/containers/ListingPage/ListingPage.test.js index e9b334b3..796b3afd 100644 --- a/src/containers/ListingPage/ListingPage.test.js +++ b/src/containers/ListingPage/ListingPage.test.js @@ -1,19 +1,64 @@ import React from 'react'; +import { types } from 'sharetribe-sdk'; import { createListing } from '../../util/test-data'; import { fakeIntl, renderShallow } from '../../util/test-helpers'; import { ListingPageComponent } from './ListingPage'; +import { showListingsSuccess } from '../../ducks/sdk.duck'; +import { showListingRequest, showListingError, showListing } from './ListingPage.duck'; + +const { UUID } = types; describe('ListingPage', () => { it('matches snapshot', () => { - const entitiesData = { entities: { listing: { listing1: createListing('listing1') } } }; + const marketplaceData = { entities: { listing: { listing1: createListing('listing1') } } }; const tree = renderShallow( l} />, ); expect(tree).toMatchSnapshot(); }); + + describe('Duck', () => { + it('showListing() success', () => { + const id = new UUID('00000000-0000-0000-0000-000000000000'); + const dispatch = jest.fn(action => action); + const response = { status: 200 }; + const show = jest.fn(() => Promise.resolve(response)); + const sdk = { listings: { show } }; + + return showListing(id)(dispatch, null, sdk).then(data => { + expect(data).toEqual(response); + expect(show.mock.calls).toEqual([[{ id, include: ['author', 'images'] }]]); + expect(dispatch.mock.calls).toEqual([ + [showListingRequest(id)], + [showListingsSuccess(data)], + ]); + }); + }); + + it('showListing() error', () => { + const id = new UUID('00000000-0000-0000-0000-000000000000'); + const dispatch = jest.fn(action => action); + const error = new Error('fail'); + const show = jest.fn(() => Promise.reject(error)); + const sdk = { listings: { show } }; + + // Calling sdk.listings.show is expected to fail now + + return showListing(id)(dispatch, null, sdk).then( + () => { + throw new Error('sdk.listings.show was supposed to fail!'); + }, + e => { + expect(e).toEqual(error); + expect(show.mock.calls).toEqual([[{ id, include: ['author', 'images'] }]]); + expect(dispatch.mock.calls).toEqual([[showListingRequest(id)], [showListingError(e)]]); + }, + ); + }); + }); }); diff --git a/src/containers/reducers.js b/src/containers/reducers.js index 7518ccab..efeb3e58 100644 --- a/src/containers/reducers.js +++ b/src/containers/reducers.js @@ -3,9 +3,8 @@ * We are following Ducks module proposition: * https://github.com/erikras/ducks-modular-redux */ - -/* eslint-disable import/prefer-default-export */ import EditListingPage from './EditListingPage/EditListingPage.duck'; +import ListingPage from './ListingPage/ListingPage.duck'; import SearchPage from './SearchPage/SearchPage.duck'; -export { EditListingPage, SearchPage }; +export { EditListingPage, ListingPage, SearchPage }; diff --git a/src/routesConfiguration.js b/src/routesConfiguration.js index c01db109..816c712a 100644 --- a/src/routesConfiguration.js +++ b/src/routesConfiguration.js @@ -61,6 +61,7 @@ const routesConfiguration = [ path: '/l/:slug/:id', exact: true, name: 'ListingPage', + loadData: (params, search) => ListingPage.loadData(params, search), component: props => , }, {