Merge pull request #56 from sharetribe/listingpage-e2e

ListingPage: fetch & redux
This commit is contained in:
Vesa Luusua 2017-03-06 14:21:51 +02:00 committed by GitHub
commit 66c29b8639
7 changed files with 175 additions and 98 deletions

View file

@ -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',

View file

@ -1,9 +1,14 @@
import React from 'react';
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';
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 +17,18 @@ const info = {
{ id: 4, title: 'img4', imageUrl: 'http://placehold.it/750x470' },
{ id: 5, title: 'img5', imageUrl: 'http://placehold.it/750x470' },
],
description: `
<p>
Organic Music Production in a Sustainable, Ethical and Professional Studio.
</p>
<p>
Social Permaculture focuses on nourishing our environment with abundance instead of depleting it.
</p>
<p>
Banyan Studios is a comfortable, conscious, inspiring and creative retreat for musicians trying to convey their message through state of the art Audio & Video.
</p>
<a href="https://vimeo.com/168106603" alt="video">https://vimeo.com/168106603</a>
`,
// description: `
// <p>
// Organic Music Production in a Sustainable, Ethical and Professional Studio.
// </p>
// <p>
// Social Permaculture focuses on nourishing our environment with abundance instead of depleting it.
// </p>
// <p>
// Banyan Studios is a comfortable, conscious, inspiring and creative retreat for musicians trying to convey their message through state of the art Audio & Video.
// </p>
// <a href="https://vimeo.com/168106603" alt="video">https://vimeo.com/168106603</a>
// `,
reviews: [
{
id: 1,
@ -39,67 +44,122 @@ const info = {
};
// N.B. All the presentational content needs to be extracted to their own components
const ListingPage = () => (
<PageLayout title={`${info.title} ${info.price}`}>
<div className={css.imageContainer}>
<img className={css.mainImage} alt={info.images[0].title} src={info.images[0].imageUrl} />
<div className={css.thumbnailContainer}>
{info.images.slice(1).map(image => (
<div key={image.id} className={css.squareWrapper}>
<div className={css.aspectWrapper}>
<img className={css.thumbnail} alt={image.title} src={image.imageUrl} />
</div>
</div>
))}
</div>
</div>
{/* eslint-disable react/no-danger */}
<div className={css.description} dangerouslySetInnerHTML={{ __html: info.description }} />
{/* eslint-enable react/no-danger */}
<div className={css.filterSection}>
<h1>Here will be filters (or dragons)</h1>
<h2>Studio type</h2>
<h2>Amenities</h2>
<h2>Additional Services Available</h2>
<p><strong>Studio hours:</strong> 10am - 6pm</p>
</div>
<a className={css.contact} href="mailto:studio.dude@mystudio.com">
<h2>Contact studio</h2>
</a>
<div className={css.reviewSection}>
<h2>Studio reviews (1)</h2>
<div className={css.reviews}>
{info.reviews.map(review => (
<div key={review.id} className={css.review}>
<p>{review.review}</p>
<div className={css.reviewDetails}>
<div className={css.avatarWrapper}>
<img
className={css.avatar}
src={review.reviewer.avatar}
alt={review.reviewer.name}
/>
</div>
<div className={css.reviewDetails}>
<span className={css.authorName}>{review.reviewer.name}</span><span
className={css.date}
>
{review.reviewer.date}
</span>
</div>
<div className={css.rating}>
review: <span>{review.rating}</span><span>/5</span>
</div>
</div>
</div>
))}
</div>
</div>
<div className={css.map}>Map</div>
<NamedLink className={css.buttonLink} name="OrderDetailsPage" params={{ id: 12345 }}>
{`Book ${info.title}`}
</NamedLink>
</PageLayout>
);
export class ListingPageComponent extends Component {
componentDidMount() {
ListingPageComponent.loadData(this.props.params.id, this.props.onLoadListing);
}
export default ListingPage;
render() {
const { entitiesData, intl, params } = this.props;
const id = new types.UUID(params.id);
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 : '';
const pageContent = (
<PageLayout title={`${title} ${info.price}`}>
<div className={css.imageContainer}>
<img className={css.mainImage} alt={info.images[0].title} src={info.images[0].imageUrl} />
<div className={css.thumbnailContainer}>
{info.images.slice(1).map(image => (
<div key={image.id} className={css.squareWrapper}>
<div className={css.aspectWrapper}>
<img className={css.thumbnail} alt={image.title} src={image.imageUrl} />
</div>
</div>
))}
</div>
</div>
{/* eslint-disable react/no-danger */}
<div className={css.description} dangerouslySetInnerHTML={{ __html: description }} />
{/* eslint-enable react/no-danger */}
<div className={css.filterSection}>
<h1>Here will be filters (or dragons)</h1>
<h2>Studio type</h2>
<h2>Amenities</h2>
<h2>Additional Services Available</h2>
<p><strong>Studio hours:</strong> 10am - 6pm</p>
</div>
<a className={css.contact} href="mailto:studio.dude@mystudio.com">
<h2>Contact studio</h2>
</a>
<div className={css.reviewSection}>
<h2>Studio reviews (1)</h2>
<div className={css.reviews}>
{info.reviews.map(review => (
<div key={review.id} className={css.review}>
<p>{review.review}</p>
<div className={css.reviewDetails}>
<div className={css.avatarWrapper}>
<img
className={css.avatar}
src={review.reviewer.avatar}
alt={review.reviewer.name}
/>
</div>
<div className={css.reviewDetails}>
<span className={css.authorName}>{review.reviewer.name}</span><span
className={css.date}
>
{review.reviewer.date}
</span>
</div>
<div className={css.rating}>
review: <span>{review.rating}</span><span>/5</span>
</div>
</div>
</div>
))}
</div>
</div>
<div className={css.map}>Map</div>
<NamedLink className={css.buttonLink} name="OrderDetailsPage" params={{ id: 12345 }}>
{`Book ${title}`}
</NamedLink>
</PageLayout>
);
const loadingPageMsg = {
id: 'ListingPage.loadingListingData',
defaultMessage: 'Loading listing data',
};
const loadingContent = <PageLayout title={intl.formatMessage(loadingPageMsg)} />;
return currentListing ? pageContent : loadingContent;
}
}
ListingPageComponent.loadData = (id, onLoadListing) => {
onLoadListing(id);
};
ListingPageComponent.defaultProps = { listing: null };
const { func, object, shape, string } = PropTypes;
ListingPageComponent.propTypes = {
entitiesData: object.isRequired,
intl: intlShape.isRequired,
onLoadListing: func.isRequired,
params: shape({
id: string.isRequired,
slug: string.isRequired,
}).isRequired,
};
const mapStateToProps = state => {
const { data, ListingPage } = state;
const entitiesData = data || {};
return { ListingPage, entitiesData };
};
const mapDispatchToProps = dispatch => {
return {
onLoadListing: id => dispatch(showListings({ id, include: ['author'] })),
};
};
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListingPageComponent));

View file

@ -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(<ListingPage params={{ slug: 'banyan-studios', id: 1234 }} />);
const entitiesData = { entities: { listing: { listing1: createListing('listing1') } } };
const tree = renderShallow(
<ListingPageComponent
params={{ slug: 'listing1-title', id: 'listing1' }}
entitiesData={entitiesData}
intl={fakeIntl}
onLoadListing={l => l}
/>,
);
expect(tree).toMatchSnapshot();
});
});

View file

@ -1,6 +1,6 @@
exports[`ListingPage matches snapshot 1`] = `
<Connect(withRouter(PageLayout))
title="Banyan Studios 55€ / day">
title="listing1 title 55€ / day">
<div>
<img
alt="img1"
@ -39,18 +39,7 @@ exports[`ListingPage matches snapshot 1`] = `
<div
dangerouslySetInnerHTML={
Object {
"__html": "
<p>
Organic Music Production in a Sustainable, Ethical and Professional Studio.
</p>
<p>
Social Permaculture focuses on nourishing our environment with abundance instead of depleting it.
</p>
<p>
Banyan Studios is a comfortable, conscious, inspiring and creative retreat for musicians trying to convey their message through state of the art Audio & Video.
</p>
<a href=\"https://vimeo.com/168106603\" alt=\"video\">https://vimeo.com/168106603</a>
",
"__html": "listing1 description",
}
} />
<div>
@ -129,7 +118,7 @@ exports[`ListingPage matches snapshot 1`] = `
"id": 12345,
}
}>
Book Banyan Studios
Book listing1 title
</withFlattenedRoutes(withRouter(NamedLink))>
</Connect(withRouter(PageLayout))>
`;

View file

@ -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<UUID>} 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 ================ //

View file

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

View file

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