Initial client side data loading for ListingPage

This commit is contained in:
Kimmo Puputti 2017-03-14 16:36:09 +02:00
parent a9e5f98476
commit 735adb21d5
6 changed files with 198 additions and 54 deletions

View file

@ -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
? <RouteComponent params={match.params} location={location} />
: <NamedRedirect name="LogInPage" state={{ from: match.url }} />;
};
}
}
const toRouteComponent = route => (
<Route
key={route.name}
path={route.path}
exact={route.exact}
render={matchProps => 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 (
<Route
key={route.name}
path={route.path}
exact={route.exact}
render={matchProps => (
<RouteComponentRenderer
{...renderProps}
match={matchProps.match}
location={matchProps.location}
/>
)}
/>
);
};
return (
<Switch>
@ -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 });

View file

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

View file

@ -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 = <PageLayout title={intl.formatMessage(noDataMsg)} />;
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;

View file

@ -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(
<ListingPageComponent
params={{ slug: 'listing1-title', id: 'listing1' }}
entitiesData={entitiesData}
marketplaceData={marketplaceData}
intl={fakeIntl}
onLoadListing={l => 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)]]);
},
);
});
});
});

View file

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

View file

@ -61,6 +61,7 @@ const routesConfiguration = [
path: '/l/:slug/:id',
exact: true,
name: 'ListingPage',
loadData: (params, search) => ListingPage.loadData(params, search),
component: props => <ListingPage {...props} />,
},
{