From ab7666968d98909f9a9179f6d11cc97fe539d264 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Fri, 3 Mar 2017 17:49:06 +0200 Subject: [PATCH 1/4] ListingPage: fetch & redux --- src/containers/ListingPage/ListingPage.js | 204 ++++++++++++++-------- 1 file changed, 127 insertions(+), 77 deletions(-) diff --git a/src/containers/ListingPage/ListingPage.js b/src/containers/ListingPage/ListingPage.js index 4fdfcb5b..55d6d0f7 100644 --- a/src/containers/ListingPage/ListingPage.js +++ b/src/containers/ListingPage/ListingPage.js @@ -1,9 +1,13 @@ -import React from 'react'; +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { types } from 'sharetribe-sdk'; import { NamedLink, PageLayout } from '../../components'; +import { showListings, getListingsById } from '../../ducks/sdk.duck'; import css from './ListingPage.css'; +// TODO this hard coded info needs to be removed when API supports these features const info = { - title: 'Banyan Studios', + //title: 'Banyan Studios', price: '55\u20AC / day', images: [ { id: 1, title: 'img1', imageUrl: 'http://placehold.it/750x470' }, @@ -12,18 +16,18 @@ const info = { { id: 4, title: 'img4', imageUrl: 'http://placehold.it/750x470' }, { id: 5, title: 'img5', imageUrl: 'http://placehold.it/750x470' }, ], - description: ` -

- Organic Music Production in a Sustainable, Ethical and Professional Studio. -

-

- Social Permaculture focuses on nourishing our environment with abundance instead of depleting it. -

-

- Banyan Studios is a comfortable, conscious, inspiring and creative retreat for musicians trying to convey their message through state of the art Audio & Video. -

- https://vimeo.com/168106603 - `, + // description: ` + //

+ // Organic Music Production in a Sustainable, Ethical and Professional Studio. + //

+ //

+ // Social Permaculture focuses on nourishing our environment with abundance instead of depleting it. + //

+ //

+ // Banyan Studios is a comfortable, conscious, inspiring and creative retreat for musicians trying to convey their message through state of the art Audio & Video. + //

+ // https://vimeo.com/168106603 + // `, reviews: [ { id: 1, @@ -39,67 +43,113 @@ const info = { }; // N.B. All the presentational content needs to be extracted to their own components -const ListingPage = () => ( - -
- {info.images[0].title} -
- {info.images.slice(1).map(image => ( -
-
- {image.title} -
-
- ))} -
-
- {/* eslint-disable react/no-danger */} -
- {/* eslint-enable react/no-danger */} -
-

Here will be filters (or dragons)

-

Studio type

-

Amenities

-

Additional Services Available

-

Studio hours: 10am - 6pm

-
- -

Contact studio

-
-
-

Studio reviews (1)

-
- {info.reviews.map(review => ( -
-

{review.review}

-
-
- {review.reviewer.name} -
-
- {review.reviewer.name} - {review.reviewer.date} - -
-
- review: {review.rating}/5 -
-
-
- ))} -
-
-
Map
- - {`Book ${info.title}`} - - -); +export class ListingPageComponent extends Component { -export default ListingPage; + componentDidMount() { + ListingPageComponent.loadData(this.props.params.id, this.props.onLoadListing); + } + + render() { + const { entitiesData, params } = this.props; + const id = new types.UUID(params.id); + const currentListing = entitiesData.entities.listing ? getListingsById(entitiesData, [id])[0] : null; + + const title = currentListing ? currentListing.attributes.title : ''; + const description = currentListing ? currentListing.attributes.description : ''; + + // TODO render "loading" or blank page, if currentListing is null. + return ( + +
+ {info.images[0].title} +
+ {info.images.slice(1).map(image => ( +
+
+ {image.title} +
+
+ ))} +
+
+ {/* eslint-disable react/no-danger */} +
+ {/* eslint-enable react/no-danger */} +
+

Here will be filters (or dragons)

+

Studio type

+

Amenities

+

Additional Services Available

+

Studio hours: 10am - 6pm

+
+ +

Contact studio

+
+
+

Studio reviews (1)

+
+ {info.reviews.map(review => ( +
+

{review.review}

+
+
+ {review.reviewer.name} +
+
+ {review.reviewer.name} + {review.reviewer.date} + +
+
+ review: {review.rating}/5 +
+
+
+ ))} +
+
+
Map
+ + {`Book ${title}`} + + + ); + } +}; + +ListingPageComponent.loadData = (id, onLoadListing) => { + onLoadListing(id); +}; + +ListingPageComponent.defaultProps = { listing: null }; + +const { func, object, shape, string } = PropTypes; + +ListingPageComponent.propTypes = { + entitiesData: object.isRequired, + onLoadListing: func.isRequired, + params: shape({ + id: string.isRequired, + slug: string.isRequired, + }).isRequired, +}; + +const mapStateToProps = state => { + const { data, ListingPage } = state; + const entitiesData = data ? data : {}; + return { ListingPage, entitiesData }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + onLoadListing: (id) => dispatch(showListings({ id, include: ['author'] })), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(ListingPageComponent); From 875e6de4cad0b3768425084f133ad55616def8c2 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Fri, 3 Mar 2017 18:22:11 +0200 Subject: [PATCH 2/4] Msg: Loading listing data --- src/containers/ListingPage/ListingPage.js | 33 +++++++++++++++-------- src/translations/en.json | 3 ++- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/containers/ListingPage/ListingPage.js b/src/containers/ListingPage/ListingPage.js index 55d6d0f7..06eef10a 100644 --- a/src/containers/ListingPage/ListingPage.js +++ b/src/containers/ListingPage/ListingPage.js @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from 'react'; +import { intlShape, injectIntl } from 'react-intl'; import { connect } from 'react-redux'; import { types } from 'sharetribe-sdk'; import { NamedLink, PageLayout } from '../../components'; @@ -44,21 +45,21 @@ 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, params } = this.props; + const { entitiesData, intl, params } = this.props; const id = new types.UUID(params.id); - const currentListing = entitiesData.entities.listing ? getListingsById(entitiesData, [id])[0] : null; + const currentListing = entitiesData.entities.listing + ? getListingsById(entitiesData, [id])[0] + : null; const title = currentListing ? currentListing.attributes.title : ''; const description = currentListing ? currentListing.attributes.description : ''; - // TODO render "loading" or blank page, if currentListing is null. - return ( + const pageContent = (
{info.images[0].title} @@ -120,8 +121,17 @@ export class ListingPageComponent extends Component { ); + + const loadingPageMsg = { + id: 'ListingPage.loadingListingData', + defaultMessage: 'Loading listing data', + }; + + const loadingContent = ; + + return currentListing ? pageContent : loadingContent; } -}; +} ListingPageComponent.loadData = (id, onLoadListing) => { onLoadListing(id); @@ -133,6 +143,7 @@ const { func, object, shape, string } = PropTypes; ListingPageComponent.propTypes = { entitiesData: object.isRequired, + intl: intlShape.isRequired, onLoadListing: func.isRequired, params: shape({ id: string.isRequired, @@ -142,14 +153,14 @@ ListingPageComponent.propTypes = { const mapStateToProps = state => { const { data, ListingPage } = state; - const entitiesData = data ? data : {}; + const entitiesData = data || {}; return { ListingPage, entitiesData }; }; -const mapDispatchToProps = (dispatch) => { +const mapDispatchToProps = dispatch => { return { - onLoadListing: (id) => dispatch(showListings({ id, include: ['author'] })), + onLoadListing: id => dispatch(showListings({ id, include: ['author'] })), }; -} +}; -export default connect(mapStateToProps, mapDispatchToProps)(ListingPageComponent); +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListingPageComponent)); diff --git a/src/translations/en.json b/src/translations/en.json index 5ce5a195..858a8517 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -3,5 +3,6 @@ "HeroSearchForm.placeholder": "Location search (soon)", "HeroSearchForm.search": "Search", "HeroSection.title": "Book Studiotime anywhere", - "HeroSection.subTitle": "The largest online community to rent music studios" + "HeroSection.subTitle": "The largest online community to rent music studios", + "ListingPage.loadingListingData": "Loading listing data" } From 47e1b36df20e090c44174ca3bc45aacd3cc70fcd Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Fri, 3 Mar 2017 20:05:29 +0200 Subject: [PATCH 3/4] Update tests --- src/app.test.js | 2 +- src/containers/ListingPage/ListingPage.test.js | 15 ++++++++++++--- .../__snapshots__/ListingPage.test.js.snap | 17 +++-------------- src/util/test-helpers.js | 12 ++++++++++++ 4 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/app.test.js b/src/app.test.js index 4e67a4ac..95cc1e08 100644 --- a/src/app.test.js +++ b/src/app.test.js @@ -25,7 +25,7 @@ describe('Application', () => { const urlTitles = { '/': 'Landing page', '/s': 'Search page', - '/l/listing-title-slug/1234': 'Banyan Studios 55€ / day', + '/l/listing-title-slug/1234': 'Loading listing data', '/u/1234': 'Profile page with display name: 1234', '/checkout/1234': 'Book Banyan Studios (1234)', '/login': 'Authentication page: login tab', diff --git a/src/containers/ListingPage/ListingPage.test.js b/src/containers/ListingPage/ListingPage.test.js index 9b7c1e52..e9b334b3 100644 --- a/src/containers/ListingPage/ListingPage.test.js +++ b/src/containers/ListingPage/ListingPage.test.js @@ -1,10 +1,19 @@ import React from 'react'; -import { renderShallow } from '../../util/test-helpers'; -import ListingPage from './ListingPage'; +import { createListing } from '../../util/test-data'; +import { fakeIntl, renderShallow } from '../../util/test-helpers'; +import { ListingPageComponent } from './ListingPage'; describe('ListingPage', () => { it('matches snapshot', () => { - const tree = renderShallow(); + const entitiesData = { entities: { listing: { listing1: createListing('listing1') } } }; + const tree = renderShallow( + l} + />, + ); expect(tree).toMatchSnapshot(); }); }); diff --git a/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap b/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap index f70e5731..8601c5e5 100644 --- a/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap +++ b/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap @@ -1,6 +1,6 @@ exports[`ListingPage matches snapshot 1`] = ` + title="listing1 title 55€ / day">
img1 - Organic Music Production in a Sustainable, Ethical and Professional Studio. -

-

- Social Permaculture focuses on nourishing our environment with abundance instead of depleting it. -

-

- Banyan Studios is a comfortable, conscious, inspiring and creative retreat for musicians trying to convey their message through state of the art Audio & Video. -

- https://vimeo.com/168106603 - ", + "__html": "listing1 description", } } />
@@ -129,7 +118,7 @@ exports[`ListingPage matches snapshot 1`] = ` "id": 12345, } }> - Book Banyan Studios + Book listing1 title `; diff --git a/src/util/test-helpers.js b/src/util/test-helpers.js index e2021dac..ef40478e 100644 --- a/src/util/test-helpers.js +++ b/src/util/test-helpers.js @@ -46,3 +46,15 @@ export const renderDeep = component => { ); return comp.toJSON(); }; + +// Create fake Internalization object to help with shallow rendering. +export const fakeIntl = { + formatDate: d => d, + formatHTMLMessage: d => d, + formatMessage: msg => msg.defaultMessage, + formatNumber: d => d, + formatPlural: d => d, + formatRelative: d => d, + formatTime: d => d, + now: d => d, +}; From 309b7214be46c8014ea71376de2c18fe7079aad3 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Mon, 6 Mar 2017 13:58:45 +0200 Subject: [PATCH 4/4] getListingsById: handle errors --- src/containers/ListingPage/ListingPage.js | 5 ++--- src/ducks/sdk.duck.js | 10 ++++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/containers/ListingPage/ListingPage.js b/src/containers/ListingPage/ListingPage.js index 06eef10a..32955485 100644 --- a/src/containers/ListingPage/ListingPage.js +++ b/src/containers/ListingPage/ListingPage.js @@ -52,9 +52,8 @@ export class ListingPageComponent extends Component { render() { const { entitiesData, intl, params } = this.props; const id = new types.UUID(params.id); - const currentListing = entitiesData.entities.listing - ? getListingsById(entitiesData, [id])[0] - : null; + const listingsById = getListingsById(entitiesData, [id]); + const currentListing = listingsById.length > 0 ? listingsById[0] : null; const title = currentListing ? currentListing.attributes.title : ''; const description = currentListing ? currentListing.attributes.description : ''; diff --git a/src/ducks/sdk.duck.js b/src/ducks/sdk.duck.js index 89adb04c..491fa213 100644 --- a/src/ducks/sdk.duck.js +++ b/src/ducks/sdk.duck.js @@ -107,8 +107,14 @@ export default function sdkReducer(state = initialState, action = {}) { * @param {Object} data the state part of the Redux store for this SDK reducer * @param {Array} listingIds listing IDs to select from the store */ -export const getListingsById = (data, listingIds) => - denormalisedEntities(data.entities, 'listing', listingIds); +export const getListingsById = (data, listingIds) => { + try { + return denormalisedEntities(data.entities, 'listing', listingIds); + } catch (e) { + console.error(`Could not denormalise entities with given ids (${listingIds.map(id => id.uuid)}). Error: ${e}`); + return []; + } +}; // ================ Action creators ================ //