Merge pull request #50 from sharetribe/listing-search-with-sdk

E2E listing search with the SDK Duck
This commit is contained in:
Kimmo Puputti 2017-02-24 09:12:33 +02:00 committed by GitHub
commit 96b519a9de
15 changed files with 315 additions and 120 deletions

View file

@ -1,10 +1,24 @@
import React, { PropTypes } from 'react';
import React from 'react';
import { NamedLink } from '../../components';
import * as propTypes from '../../util/propTypes';
import css from './ListingCard.css';
const ListingCard = props => {
const { id, title, price, description, location, review, author } = props;
const { listing } = props;
const id = listing.id.uuid;
const { title = '', description = '', address = '' } = listing.attributes || {};
const slug = encodeURIComponent(title.split(' ').join('-'));
const authorName = listing.author && listing.author.attributes
? `${listing.author.attributes.profile.firstName} ${listing.author.attributes.profile.lastName}`
: '';
// TODO: these are not yet present in the API data, figure out what
// to do with them
const price = '55\u20AC / day';
const review = { rating: 3, count: 10 };
const authorAvatar = 'http://placehold.it/44x44';
const authorReview = { rating: 4 };
return (
<div className={css.listing}>
<div className={css.squareWrapper}>
@ -26,22 +40,22 @@ const ListingCard = props => {
</div>
<div className={css.details}>
<div className={css.location}>
{location}
{address}
</div>
<div className={css.reviews}>
(<span>{review.rating}</span><span>/5</span>){' '}
<span>{review.count}</span>
<span>{review.count} reviews</span>
</div>
</div>
<hr />
<div className={css.authorInfo}>
<div className={css.avatarWrapper}>
<img className={css.avatar} src={author.avatar} alt={author.name} />
<img className={css.avatar} src={authorAvatar} alt={authorName} />
</div>
<div className={css.authorDetails}>
<span className={css.authorName}>{author.name}</span>
<span className={css.authorName}>{authorName}</span>
<div className={css.authorReview}>
review: <span>{author.review.rating}</span><span>/5</span>
review: <span>{authorReview.rating}</span><span>/5</span>
</div>
</div>
</div>
@ -50,22 +64,6 @@ const ListingCard = props => {
);
};
ListingCard.defaultProps = { location: null, review: {}, author: {} };
const { shape, string } = PropTypes;
ListingCard.propTypes = {
id: string.isRequired,
title: string.isRequired,
price: string.isRequired,
description: string.isRequired,
location: string,
review: shape({ rating: string.isRequired, count: string.isRequired }),
author: shape({
name: string.isRequired,
avatar: string.isRequired,
review: shape({ rating: string.isRequired }),
}),
};
ListingCard.propTypes = { listing: propTypes.listing.isRequired };
export default ListingCard;

View file

@ -1,23 +1,12 @@
import React from 'react';
import { renderShallow } from '../../util/test-helpers';
import { renderShallow, createUser, createListing } from '../../util/test-helpers';
import ListingCard from './ListingCard';
describe('ListingCard', () => {
it('matches snapshot', () => {
const listing = {
id: 'some-id',
title: 'Banyan Studios',
price: '55\u20AC / day',
description: 'Organic Music Production in a Sustainable, Ethical and Professional Studio.',
location: 'New York, NY \u2022 40mi away',
review: { count: '8 reviews', rating: '4' },
author: {
name: 'The Stardust Collective',
avatar: 'http://placehold.it/44x44',
review: { rating: '4' },
},
};
const tree = renderShallow(<ListingCard {...listing} />);
const author = createUser('user1');
const listing = { ...createListing('listing1'), author };
const tree = renderShallow(<ListingCard listing={listing} />);
expect(tree).toMatchSnapshot();
});
});

View file

@ -13,27 +13,27 @@ exports[`ListingCard matches snapshot 1`] = `
name="ListingPage"
params={
Object {
"id": "some-id",
"slug": "Banyan-Studios",
"id": "listing1",
"slug": "listing1-title",
}
}>
Banyan Studios
listing1 title
</withFlattenedRoutes(withRouter(NamedLink))>
<div>
55€ / day
</div>
</div>
<div>
Organic Music Production in a Sustainable, Ethical and Professional Studio.
listing1 description
</div>
<div>
<div>
New York, NY • 40mi away
listing1 address
</div>
<div>
(
<span>
4
3
</span>
<span>
/5
@ -41,7 +41,8 @@ exports[`ListingCard matches snapshot 1`] = `
)
<span>
8 reviews
10
reviews
</span>
</div>
</div>
@ -49,12 +50,12 @@ exports[`ListingCard matches snapshot 1`] = `
<div>
<div>
<img
alt="The Stardust Collective"
alt="user1 first name user1 last name"
src="http://placehold.it/44x44" />
</div>
<div>
<span>
The Stardust Collective
user1 first name user1 last name
</span>
<div>
review:

View file

@ -1,11 +1,19 @@
import React, { PropTypes } from 'react';
import React from 'react';
import { NamedLink } from '../../components';
import * as propTypes from '../../util/propTypes';
import css from './ListingCardSmall.css';
// <NamedLink name="SearchListingsPage">X</NamedLink>
const ListingCardSmall = props => {
const { id, title, price, review } = props;
const { listing } = props;
const { title = '' } = listing.attributes || {};
const id = listing.id.uuid;
const slug = encodeURIComponent(title.split(' ').join('-'));
// TODO: these are not yet present in the API data, figure out what
// to do with them
const price = '55\u20AC / day';
const review = { rating: 3, count: 10 };
return (
<div className={css.listing}>
<div className={css.squareWrapper}>
@ -29,15 +37,6 @@ const ListingCardSmall = props => {
);
};
ListingCardSmall.defaultProps = { review: {} };
const { number, shape, string } = PropTypes;
ListingCardSmall.propTypes = {
id: number.isRequired,
title: string.isRequired,
price: string.isRequired,
review: shape({ rating: string.isRequired, count: string.isRequired }),
};
ListingCardSmall.propTypes = { listing: propTypes.listing.isRequired };
export default ListingCardSmall;

View file

@ -1,23 +1,12 @@
import React from 'react';
import { renderShallow } from '../../util/test-helpers';
import { renderShallow, createUser, createListing } from '../../util/test-helpers';
import ListingCardSmall from './ListingCardSmall';
describe('ListingCardSmall', () => {
it('matches snapshot', () => {
const listing = {
id: 123,
title: 'Banyan Studios',
price: '55\u20AC / day',
description: 'Organic Music Production in a Sustainable, Ethical and Professional Studio.',
location: 'New York, NY \u2022 40mi away',
review: { count: '8 reviews', rating: '4' },
author: {
name: 'The Stardust Collective',
avatar: 'http://placehold.it/44x44',
review: { rating: '4' },
},
};
const tree = renderShallow(<ListingCardSmall {...listing} />);
const author = createUser('user1');
const listing = { ...createListing('listing1'), author };
const tree = renderShallow(<ListingCardSmall listing={listing} />);
expect(tree).toMatchSnapshot();
});
});

View file

@ -12,16 +12,16 @@ exports[`ListingCardSmall matches snapshot 1`] = `
name="ListingPage"
params={
Object {
"id": 123,
"slug": "Banyan-Studios",
"id": "listing1",
"slug": "listing1-title",
}
}>
Banyan Studios
listing1 title
</withFlattenedRoutes(withRouter(NamedLink))>
<div>
(
<span>
4
3
</span>
<span>
/5
@ -29,7 +29,7 @@ exports[`ListingCardSmall matches snapshot 1`] = `
)
<span>
8 reviews
10
</span>
</div>
<div>

View file

@ -1,9 +1,8 @@
import React, { Component, PropTypes } from 'react';
import classNames from 'classnames';
import { connect } from 'react-redux';
import { addFlashNotification } from '../../ducks/FlashNotification.duck';
import { addFilter, callFetchListings, loadListings } from './SearchPage.duck';
import css from './SearchPage.css';
import { types } from 'sharetribe-sdk';
import { searchListings, getListingsById } from '../../ducks/sdk.duck';
import {
FilterPanel,
ListingCard,
@ -13,13 +12,13 @@ import {
SearchResultsPanel,
} from '../../components';
import css from './SearchPage.css';
const { LatLng } = types;
export class SearchPageComponent extends Component {
componentDidMount() {
// TODO: This should be moved to Router
const { initialListingsLoaded } = this.props;
if (!initialListingsLoaded) {
this.props.onLoadListings();
}
SearchPageComponent.loadData(this.props.dispatch);
}
render() {
@ -37,12 +36,12 @@ export class SearchPageComponent extends Component {
</div>
<div className={listingsClassName}>
<SearchResultsPanel>
{listings.map(l => <ListingCard key={l.id} {...l} />)}
{listings.map(l => <ListingCard key={l.id.uuid} listing={l} />)}
</SearchResultsPanel>
</div>
<div className={mapClassName}>
<MapPanel>
{listings.map(l => <ListingCardSmall key={l.id} {...l} />)}
{listings.map(l => <ListingCardSmall key={l.id.uuid} listing={l} />)}
</MapPanel>
</div>
</div>
@ -51,30 +50,23 @@ export class SearchPageComponent extends Component {
}
}
SearchPageComponent.loadData = callFetchListings;
SearchPageComponent.loadData = dispatch => {
dispatch(searchListings({ origin: new LatLng(40, 70), include: ['author'] }));
};
SearchPageComponent.defaultProps = { initialListingsLoaded: false, listings: [], tab: 'listings' };
const { array, bool, func, oneOf } = PropTypes;
const { array, func, oneOf } = PropTypes;
SearchPageComponent.propTypes = {
onLoadListings: func.isRequired,
initialListingsLoaded: bool,
listings: array,
tab: oneOf(['filters', 'listings', 'map']).isRequired,
dispatch: func.isRequired,
};
const mapStateToProps = state => ({
initialListingsLoaded: state.SearchPage.initialListingsLoaded,
listings: state.SearchPage.listings,
});
const mapDispatchToProps = function mapDispatchToProps(dispatch) {
return {
onAddNotice: msg => dispatch(addFlashNotification('notice', msg)),
onAddFilter: (k, v) => dispatch(addFilter(k, v)),
onLoadListings: () => dispatch(loadListings.request()),
};
const mapStateToProps = state => {
const listingIds = state.data.searchPageResults;
return { listings: getListingsById(state.data, listingIds) };
};
export default connect(mapStateToProps, mapDispatchToProps)(SearchPageComponent);
export default connect(mapStateToProps)(SearchPageComponent);

View file

@ -17,7 +17,9 @@ const { LatLng } = types;
describe('SearchPageComponent', () => {
it('matches snapshot', () => {
const tree = renderShallow(<SearchPageComponent onLoadListings={v => v} />);
const tree = renderShallow(
<SearchPageComponent onLoadListings={v => v} dispatch={() => null} />,
);
expect(tree).toMatchSnapshot();
});
});

View file

@ -1,6 +1,6 @@
/* eslint-disable no-constant-condition, no-console */
import { call, put, take, fork } from 'redux-saga/effects';
import { updatedEntities } from '../util/data';
import { updatedEntities, denormalisedEntities } from '../util/data';
const requestAction = actionType => params => ({ type: actionType, payload: { params } });
@ -41,6 +41,7 @@ const initialState = {
showUsersError: null,
// Database of all the fetched entities.
entities: {},
searchPageResults: [],
};
const merge = (state, payload) => {
@ -48,6 +49,8 @@ const merge = (state, payload) => {
return { ...state, entities };
};
const searchResults = payload => payload.data.data.map(listing => listing.id);
export default function sdkReducer(state = initialState, action = {}) {
const { type, payload } = action;
switch (type) {
@ -70,7 +73,7 @@ export default function sdkReducer(state = initialState, action = {}) {
case SEARCH_LISTINGS_REQUEST:
return { ...state, searchListingsError: null };
case SEARCH_LISTINGS_SUCCESS:
return merge(state, payload);
return { ...merge(state, payload), searchPageResults: searchResults(payload) };
case SEARCH_LISTINGS_ERROR:
console.error(payload);
return { ...state, searchListingsError: payload };
@ -96,24 +99,44 @@ export default function sdkReducer(state = initialState, action = {}) {
}
}
// ================ Selectors ================ //
/**
* Get the denormalised listing entities with the given IDs
*
* @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);
// ================ Action creators ================ //
// All the action creators that don't have the {Success, Error} suffix
// take the params object that the corresponding SDK endpoint method
// expects.
// SDK method: listings.show
export const showListings = requestAction(SHOW_LISTINGS_REQUEST);
export const showListingsSuccess = successAction(SHOW_LISTINGS_SUCCESS);
export const showListingsError = errorAction(SHOW_LISTINGS_ERROR);
// SDK method: listings.query
export const queryListings = requestAction(QUERY_LISTINGS_REQUEST);
export const queryListingsSuccess = successAction(QUERY_LISTINGS_SUCCESS);
export const queryListingsError = errorAction(QUERY_LISTINGS_ERROR);
// SDK method: listings.search
export const searchListings = requestAction(SEARCH_LISTINGS_REQUEST);
export const searchListingsSuccess = successAction(SEARCH_LISTINGS_SUCCESS);
export const searchListingsError = errorAction(SEARCH_LISTINGS_ERROR);
// SDK method: marketplace.show
export const showMarketplace = requestAction(SHOW_MARKETPLACE_REQUEST);
export const showMarketplaceSuccess = successAction(SHOW_MARKETPLACE_SUCCESS);
export const showMarketplaceError = errorAction(SHOW_MARKETPLACE_ERROR);
// SDK method: users.show
export const showUsers = requestAction(SHOW_USERS_REQUEST);
export const showUsersSuccess = successAction(SHOW_USERS_SUCCESS);
export const showUsersError = errorAction(SHOW_USERS_ERROR);

View file

@ -29,6 +29,7 @@ import {
showMarketplace,
showUsers,
} from './ducks/sdk.duck';
import routeConfiguration from './routesConfiguration';
import './index.css';
@ -75,7 +76,7 @@ if (typeof window !== 'undefined') {
{ showListings, queryListings, searchListings, showMarketplace, showUsers },
store.dispatch,
);
window.app = { config, sdk, sdkTypes: types, actions, store, sample };
window.app = { config, sdk, sdkTypes: types, actions, store, sample, routeConfiguration };
}
}

View file

@ -29,28 +29,24 @@ const routesConfiguration = [
exact: true,
name: 'SearchPage',
component: props => <SearchPage {...props} />,
loadData: SearchPage ? SearchPage.loadData : null,
routes: [
{
path: '/s/filters',
exact: true,
name: 'SearchFiltersPage',
component: props => <SearchPage {...props} tab="filters" />,
loadData: SearchPage ? SearchPage.loadData : null,
},
{
path: '/s/listings',
exact: true,
name: 'SearchListingsPage',
component: props => <SearchPage {...props} tab="listings" />,
loadData: SearchPage ? SearchPage.loadData : null,
},
{
path: '/s/map',
exact: true,
name: 'SearchMapPage',
component: props => <SearchPage {...props} tab="map" />,
loadData: SearchPage ? SearchPage.loadData : null,
},
],
},

View file

@ -1,3 +1,5 @@
import { reduce } from 'lodash';
/**
* Combine the given relationships objects
*
@ -52,3 +54,49 @@ export const updatedEntities = (oldEntities, apiResponse) => {
return newEntities;
};
/**
* Denormalise the entities with the given IDs from the entities object
*
* This function calculates the dernormalised tree structure from the
* normalised entities object with all the relationships joined in.
*
* @param {Object} entities entities object in the SDK Redux store
* @param {String} type entity type of the given IDs
* @param {Array<UUID>} ids IDs to pick from the entities
*/
export const denormalisedEntities = (entities, type, ids) => {
if (!entities[type] && ids.length > 0) {
throw new Error(`No entities of type ${type}`);
}
return ids.map(id => {
const entity = entities[type][id.uuid];
if (!entity) {
throw new Error(`Entity ${type} with id ${id.uuid} not found`);
}
const { relationships, ...entityData } = entity;
if (relationships) {
// Recursively join in all the relationship entities
return reduce(
relationships,
(ent, relRef, relName) => {
// A relationship reference can be either a single object or
// an array of objects. We want to keep that form in the final
// result.
const hasMultipleRefs = Array.isArray(relRef);
const refs = hasMultipleRefs ? relRef : [relRef];
const relIds = refs.map(ref => ref.data.id);
const relType = refs[0].data.type;
const rels = denormalisedEntities(entities, relType, relIds);
// eslint-disable-next-line no-param-reassign
ent[relName] = hasMultipleRefs ? rels : rels[0];
return ent;
},
entityData,
);
}
return entityData;
});
};

View file

@ -1,5 +1,10 @@
import { types } from 'sharetribe-sdk';
import { combinedRelationships, combinedResourceObjects, updatedEntities } from './data';
import {
combinedRelationships,
combinedResourceObjects,
updatedEntities,
denormalisedEntities,
} from './data';
const { UUID } = types;
@ -162,4 +167,110 @@ describe('data utils', () => {
});
});
});
describe('denormalisedEntities()', () => {
const createListing = id => ({
id: new UUID(id),
type: 'listing',
attributes: { title: `Listing ${id} title`, description: `Listing ${id} description` },
});
const createUser = id => ({
id: new UUID(id),
type: 'user',
attributes: { name: `User ${id} name` },
});
const createImage = id => ({
id: new UUID(id),
type: 'image',
attributes: { url: `https://example.com/${id}.jpg`, width: 300, height: 200 },
});
it('return no results with empty ids', () => {
const entities = { listing: { listing1: createListing('listing1') } };
expect(denormalisedEntities(entities, 'listing', [])).toEqual([]);
});
it('return no results with empty entities and empty ids', () => {
const entities = {};
expect(denormalisedEntities(entities, 'listing', [])).toEqual([]);
});
it('throws when selecting a nonexistent type', () => {
const entities = { listing: { listing1: createListing('listing1') } };
expect(() => denormalisedEntities(entities, 'user', [new UUID('user1')])).toThrow(
'No entities of type user',
);
});
it('throws when selecting a nonexistent id', () => {
const entities = { listing: { listing1: createListing('listing1') } };
const ids = [new UUID('listing2')];
expect(() => denormalisedEntities(entities, 'listing', ids)).toThrow(
'Entity listing with id listing2 not found',
);
});
it('throws if a related entity is not found', () => {
const listing1 = createListing('listing1');
listing1.relationships = { author: { data: createUser('user1') } };
const entities = { listing: { listing1 } };
const ids = [listing1.id];
expect(() => denormalisedEntities(entities, 'listing', ids)).toThrow(
'No entities of type user',
);
});
it('returns the selected entities', () => {
const listing1 = createListing('listing1');
const listing2 = createListing('listing2');
const listing3 = createListing('listing3');
const entities = { listing: { listing1, listing2, listing3 } };
const ids = [listing1.id, listing3.id];
expect(denormalisedEntities(entities, 'listing', ids)).toEqual([listing1, listing3]);
});
it('denormalises simple relationships', () => {
const user1 = createUser('user1');
const listing1 = createListing('listing1');
const listing1Relationships = { author: { data: user1 } };
const listing1WithRelationships = { ...listing1, relationships: listing1Relationships };
const listing2 = createListing('listing2');
const entities = {
listing: { listing1: listing1WithRelationships, listing2 },
user: { user1 },
};
const ids = [listing1.id];
expect(denormalisedEntities(entities, 'listing', ids)).toEqual([
{ ...listing1, author: user1 },
]);
});
it('denormalises multiple relationships', () => {
const user1 = createUser('user1');
const image1 = createImage('image1');
const image2 = createImage('image2');
const listing1 = createListing('listing1');
const listing1Relationships = {
author: { data: user1 },
images: [{ data: image1 }, { data: image2 }],
};
const listing1WithRelationships = { ...listing1, relationships: listing1Relationships };
const listing2 = createListing('listing2');
const listing3 = createListing('listing3');
const entities = {
listing: { listing1: listing1WithRelationships, listing2, listing3 },
user: { user1 },
image: { image1, image2 },
};
const ids = [listing1.id, listing2.id];
expect(denormalisedEntities(entities, 'listing', ids)).toEqual([
{ ...listing1, author: user1, images: [image1, image2] },
listing2,
]);
});
});
});

View file

@ -41,8 +41,29 @@ export const route = shape({
loadData: func,
});
// User object from the API
export const user = shape({ id: uuid.isRequired, type: value('user').isRequired });
// Denormalised user object
export const user = shape({
id: uuid.isRequired,
type: value('user').isRequired,
attributes: shape({
email: string.isRequired,
profile: shape({
firstName: string.isRequired,
lastName: string.isRequired,
slug: string.isRequired,
}).isRequired,
}),
});
// Listing object from the API
export const listing = shape({ id: uuid.isRequired, type: value('listing').isRequired });
// Denormalised listing object
export const listing = shape({
id: uuid.isRequired,
type: value('listing').isRequired,
attributes: shape({
title: string.isRequired,
description: string.isRequired,
address: string.isRequired,
geolocation: latlng.isRequired,
}),
author: user,
});

View file

@ -5,8 +5,11 @@ import toJson from 'enzyme-to-json';
import { IntlProvider } from 'react-intl';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { types } from 'sharetribe-sdk';
import configureStore from '../store';
const { UUID, LatLng } = types;
// Provide all the context for components that connect to the Redux
// store, i18n, router, etc.
export const TestProvider = props => {
@ -46,3 +49,25 @@ export const renderDeep = component => {
);
return comp.toJSON();
};
// Create a user that conforms to the util/propTypes user schema
export const createUser = id => ({
id: new UUID(id),
type: 'user',
attributes: {
email: '${id}@example.com',
profile: { firstName: `${id} first name`, lastName: `${id} last name`, slug: `${id}-slug` },
},
});
// Create a user that conforms to the util/propTypes listing schema
export const createListing = id => ({
id: new UUID(id),
type: 'listing',
attributes: {
title: `${id} title`,
description: `${id} description`,
address: `${id} address`,
geolocation: new LatLng(40, 60),
},
});