mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-29 05:00:43 +10:00
Merge pull request #56 from sharetribe/listingpage-e2e
ListingPage: fetch & redux
This commit is contained in:
commit
66c29b8639
7 changed files with 175 additions and 98 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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))>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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 ================ //
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue