Merge pull request #62 from sharetribe/create-listing

Create listing
This commit is contained in:
Vesa Luusua 2017-03-08 19:16:23 +02:00 committed by GitHub
commit cf035c3368
16 changed files with 471 additions and 15 deletions

View file

@ -0,0 +1,3 @@
.error {
color: red;
}

View file

@ -0,0 +1,11 @@
/* eslint-disable no-console, import/prefer-default-export */
import EditListingForm from './EditListingForm';
export const Empty = {
component: EditListingForm,
props: {
onSubmit(values) {
console.log('submit new password form values:', values);
},
},
};

View file

@ -0,0 +1,92 @@
import React, { Component, PropTypes } from 'react';
import { Field, reduxForm, propTypes as formPropTypes } from 'redux-form';
import { intlShape, injectIntl } from 'react-intl';
import { maxLength, required } from '../../util/validators';
import css from './EditListingForm.css';
const MAX_LENGTH = 60;
const RenderField = ({ input, label, type, meta: { touched, error } }) => {
const component = type === 'textarea'
? <textarea {...input} placeholder={label} />
: <input {...input} placeholder={label} type={type} />;
return (
<div>
<label htmlFor={input.name}>{label}</label>
<div>
{component}
{touched && (error && <span className={css.error}>{error}</span>)}
</div>
</div>
)
};
const { any, bool, shape, string } = PropTypes;
RenderField.propTypes = {
input: any.isRequired,
label: string.isRequired,
type: string.isRequired,
meta: shape({
touched: bool,
error: string,
}).isRequired,
};
class EditListingForm extends Component {
componentDidMount() {
this.handleInitialize();
}
handleInitialize() {
const { initData = {}, initialize } = this.props;
initialize(initData);
}
render() {
const {
disabled,
handleSubmit,
intl,
pristine,
saveActionMsg = 'Save listing',
submitting,
} = this.props;
const requiredStr = intl.formatMessage({ id: 'EditListingForm.required' });
const maxLengthStr = intl.formatMessage({ id: 'EditListingForm.maxLength' }, {
maxLength: MAX_LENGTH,
});
const maxLength60 = maxLength(maxLengthStr, MAX_LENGTH);
return (
<form onSubmit={handleSubmit}>
<Field
name="title"
label="Title"
component={RenderField}
type="text"
validate={[required(requiredStr), maxLength60]}
/>
<Field
name="description"
label="Description"
component={RenderField}
type="textarea"
validate={[required(requiredStr)]}
/>
<button type="submit" disabled={pristine || submitting || disabled}>{saveActionMsg}</button>
</form>
);
}
}
EditListingForm.defaultProps = { initData: {} };
EditListingForm.propTypes = {
...formPropTypes,
initData: shape({ title: string, description: string }),
intl: intlShape.isRequired,
};
export default reduxForm({ form: 'EditListingForm' })(injectIntl(EditListingForm));

View file

@ -0,0 +1,10 @@
import React from 'react';
import { renderDeep } from '../../util/test-helpers';
import EditListingForm from './EditListingForm';
describe('EditListingForm', () => {
it('matches snapshot', () => {
const tree = renderDeep(<EditListingForm />);
expect(tree).toMatchSnapshot();
});
});

View file

@ -0,0 +1,45 @@
exports[`EditListingForm matches snapshot 1`] = `
<form
onSubmit={[Function]}>
<div>
<label
htmlFor="title">
Title
</label>
<div>
<input
name="title"
onBlur={[Function]}
onChange={[Function]}
onDragStart={[Function]}
onDrop={[Function]}
onFocus={[Function]}
placeholder="Title"
type="text"
value="" />
</div>
</div>
<div>
<label
htmlFor="description">
Description
</label>
<div>
<textarea
name="description"
onBlur={[Function]}
onChange={[Function]}
onDragStart={[Function]}
onDrop={[Function]}
onFocus={[Function]}
placeholder="Description"
value="" />
</div>
</div>
<button
disabled={true}
type="submit">
Save listing
</button>
</form>
`;

View file

@ -0,0 +1,131 @@
/* eslint-disable no-console */
import { showListingsSuccess as globalShowListingsSuccess } from '../../ducks/sdk.duck';
const requestAction = actionType => params => ({ type: actionType, payload: { params } });
const successAction = actionType => result => ({ type: actionType, payload: result.data });
const errorAction = actionType => error => ({ type: actionType, payload: error, error: true });
// ================ Action types ================ //
export const CREATE_LISTING_REQUEST = 'app/EditListingPage/CREATE_LISTING_REQUEST';
export const CREATE_LISTING_SUCCESS = 'app/EditListingPage/CREATE_LISTING_SUCCESS';
export const CREATE_LISTING_ERROR = 'app/EditListingPage/CREATE_LISTING_ERROR';
export const SHOW_LISTINGS_REQUEST = 'app/EditListingPage/SHOW_LISTINGS_REQUEST';
export const SHOW_LISTINGS_SUCCESS = 'app/EditListingPage/SHOW_LISTINGS_SUCCESS';
export const SHOW_LISTINGS_ERROR = 'app/EditListingPage/SHOW_LISTINGS_ERROR';
// ================ Reducer ================ //
const initialState = {
// Error instance placeholders for each endpoint
createListingsError: null,
showListingsError: null,
submittedListingId: null,
redirectToListing: false,
};
export default function reducer(state = initialState, action = {}) {
const { type, payload } = action;
switch (type) {
case CREATE_LISTING_REQUEST:
return {
...state,
createListingsError: null,
submittedListingId: null,
redirectToListing: false,
};
case CREATE_LISTING_SUCCESS:
return { ...state, submittedListingId: payload.data.id, redirectToListing: true };
case CREATE_LISTING_ERROR:
console.error(payload);
return { ...state, createListingsError: payload, redirectToListing: false };
case SHOW_LISTINGS_REQUEST:
return { ...state, showListingsError: null };
case SHOW_LISTINGS_SUCCESS:
return initialState;
case SHOW_LISTINGS_ERROR:
console.error(payload);
return { ...state, showListingsError: payload, redirectToListing: false };
default:
return state;
}
}
// ================ Selectors ================ //
// ================ 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.create
export const createListing = requestAction(CREATE_LISTING_REQUEST);
export const createListingSuccess = successAction(CREATE_LISTING_SUCCESS);
export const createListingError = errorAction(CREATE_LISTING_ERROR);
// 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);
// ================ Thunk ================ //
const endpoints = {
[CREATE_LISTING_REQUEST]: {
method: sdk => sdk.listings.create,
success: createListingSuccess,
error: createListingError,
},
[SHOW_LISTINGS_REQUEST]: {
method: sdk => sdk.listings.show,
success: showListingsSuccess,
error: showListingsError,
},
};
function callEndpoint(sdkMethod, dispatchSuccess, dispatchError, action) {
const params = action.payload.params;
return sdkMethod(params).then(resp => dispatchSuccess(resp)).catch(e => dispatchError(e));
}
export function requestCreateListing(actionPayload) {
return (dispatch, getState, sdk) => {
// Create request action
const action = createListing(actionPayload);
// Notify store that we are sending a request
dispatch(action);
// Select SDK endpoint and call it
const { method, success, error } = endpoints[action.type];
return callEndpoint(
method(sdk),
response => dispatch(success(response)),
e => dispatch(error(e)),
action,
);
};
}
export function requestShowListing(actionPayload) {
return (dispatch, getState, sdk) => {
const action = showListings(actionPayload);
dispatch(action);
const { method, success, error } = endpoints[action.type];
return callEndpoint(
method(sdk),
response => {
// EditListingPage fetches new listing data, which also needs to be added to global data
dispatch(globalShowListingsSuccess(response));
// In case of success, we'll clear state.EditListingPage (user will be redirected away)
dispatch(success(response));
},
e => dispatch(error(e)),
action,
);
};
}

View file

@ -0,0 +1,117 @@
import React, { Component, PropTypes } from 'react';
import { intlShape, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { types } from 'sharetribe-sdk';
import { NamedRedirect, PageLayout } from '../../components';
import { EditListingForm } from '../../containers';
import { getListingsById } from '../../ducks/sdk.duck';
import { createSlug } from '../../util/urlHelpers';
import { requestCreateListing, requestShowListing } from './EditListingPage.duck';
const formatRequestData = values => {
const { title, description } = values;
return {
title,
description,
address: 'Bulevardi 14, 00200 Helsinki, Finland',
geolocation: {
lat: 40.6,
lng: 73.9,
},
};
};
const onSubmit = (submitListing, onLoadListing) => {
return values =>
submitListing(values).then(resp => onLoadListing(resp.payload.data.id.uuid)).catch(e => {
// eslint-disable-next-line no-console
console.error(e);
});
};
// N.B. All the presentational content needs to be extracted to their own components
export class EditListingPageComponent extends Component {
componentDidMount() {
if (this.props.type === 'edit') {
EditListingPageComponent.loadData(this.props.params.id, this.props.onLoadListing);
}
}
render() {
const { data, intl, onCreateListing, onLoadListing, page, params, type } = this.props;
const isNew = type === 'new';
const id = page.submittedListingId || new types.UUID(params.id);
const listingsById = getListingsById(data, [id]);
const currentListing = listingsById.length > 0 ? listingsById[0] : null;
const shouldRedirect = page.submittedListingId && currentListing;
const showForm = isNew || currentListing;
if (shouldRedirect) {
const slug = currentListing ? createSlug(currentListing.attributes.title) : null;
return <NamedRedirect name="ListingPage" params={{ id: id.uuid, slug }} />;
} else if (showForm) {
const saveActionMsg = page.redirectToListing ? 'Redirecting to listing' : 'Create listing';
const disableForm = page.redirectToListing && !page.showListingsError;
// Currently creates a new listing based on existing one (sdk doesn't support listings.update)
const initData = currentListing && type === 'edit'
? {
title: currentListing.attributes.title,
description: currentListing.attributes.description,
}
: {};
return (
<PageLayout title={'Edit listing'}>
<EditListingForm
onSubmit={onSubmit(onCreateListing, onLoadListing)}
saveActionMsg={saveActionMsg}
disabled={disableForm}
initData={initData}
/>
</PageLayout>
);
} else {
const loadingPageMsg = {
id: 'ListingPage.loadingListingData',
};
return <PageLayout title={intl.formatMessage(loadingPageMsg)} />;
}
}
}
EditListingPageComponent.loadData = (id, onLoadListing) => {
onLoadListing(id);
};
EditListingPageComponent.defaultProps = { listing: null, params: null, type: 'edit' };
const { func, object, shape, string } = PropTypes;
EditListingPageComponent.propTypes = {
data: object.isRequired,
intl: intlShape.isRequired,
onLoadListing: func.isRequired,
onCreateListing: func.isRequired,
page: object.isRequired,
params: shape({
id: string,
slug: string,
}),
type: string,
};
const mapStateToProps = state => {
const { data = {}, EditListingPage } = state;
return { page: EditListingPage, data };
};
const mapDispatchToProps = dispatch => {
return {
onLoadListing: id => dispatch(requestShowListing({ id, include: ['author', 'images'] })),
onCreateListing: values => dispatch(requestCreateListing(formatRequestData(values))),
};
};
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(EditListingPageComponent));

View file

@ -64,26 +64,28 @@ export class ListingPageComponent extends Component {
: [];
// TODO componentize
const imageCarousel = images.length > 0
? (
<div className={css.imageContainer}>
<img className={css.mainImage} alt={title} src={images[0].sizes[0].url} />
<div className={css.thumbnailContainer}>
{images.slice(1).map(image => (
<div key={image.id.uuid} className={css.squareWrapper}>
<div className={css.aspectWrapper}>
<img className={css.thumbnail} alt={`${title} thumbnail`} src={image.sizes[0].url} />
</div>
const imageCarousel = images.length > 0
? <div className={css.imageContainer}>
<img className={css.mainImage} alt={title} src={images[0].sizes[0].url} />
<div className={css.thumbnailContainer}>
{images.slice(1).map(image => (
<div key={image.id.uuid} className={css.squareWrapper}>
<div className={css.aspectWrapper}>
<img
className={css.thumbnail}
alt={`${title} thumbnail`}
src={image.sizes[0].url}
/>
</div>
))}
</div>
</div>
))}
</div>
)
</div>
: null;
const pageContent = (
<PageLayout title={`${title} ${info.price}`}>
{ imageCarousel }
{imageCarousel}
{/* eslint-disable react/no-danger */}
<div className={css.description} dangerouslySetInnerHTML={{ __html: description }} />
{/* eslint-enable react/no-danger */}

View file

@ -3,6 +3,8 @@ import ChangeAccountPasswordForm from './ChangeAccountPasswordForm/ChangeAccount
import ChangePasswordForm from './ChangePasswordForm/ChangePasswordForm';
import CheckoutPage from './CheckoutPage/CheckoutPage';
import ContactDetailsPage from './ContactDetailsPage/ContactDetailsPage';
import EditListingForm from './EditListingForm/EditListingForm';
import EditListingPage from './EditListingPage/EditListingPage';
import EditProfilePage from './EditProfilePage/EditProfilePage';
import HeroSearchForm from './HeroSearchForm/HeroSearchForm';
import InboxPage from './InboxPage/InboxPage';
@ -30,6 +32,8 @@ export {
ChangePasswordForm,
CheckoutPage,
ContactDetailsPage,
EditListingForm,
EditListingPage,
EditProfilePage,
HeroSearchForm,
InboxPage,

View file

@ -5,6 +5,7 @@
*/
/* eslint-disable import/prefer-default-export */
import EditListingPage from './EditListingPage/EditListingPage.duck';
import SearchPage from './SearchPage/SearchPage.duck';
export { SearchPage };
export { EditListingPage, SearchPage };

View file

@ -7,6 +7,7 @@ import * as NamedLink from './components/NamedLink/NamedLink.example';
import * as ChangeAccountPasswordForm
from './containers/ChangeAccountPasswordForm/ChangeAccountPasswordForm.example';
import * as ChangePasswordForm from './containers/ChangePasswordForm/ChangePasswordForm.example';
import * as EditListingForm from './containers/EditListingForm/EditListingForm.example';
import * as HeroSearchForm from './containers/HeroSearchForm/HeroSearchForm.example';
import * as LoginForm from './containers/LoginForm/LoginForm.example';
import * as PasswordForgottenForm
@ -17,6 +18,7 @@ export {
BookingInfo,
ChangeAccountPasswordForm,
ChangePasswordForm,
EditListingForm,
HeroSearchForm,
ListingCard,
LoginForm,

View file

@ -49,3 +49,8 @@ button {
}
}
}
textarea {
width: 100%;
border: 1px solid #ddd;
}

View file

@ -4,6 +4,7 @@ import {
AuthenticationPage,
CheckoutPage,
ContactDetailsPage,
EditListingPage,
EditProfilePage,
InboxPage,
LandingPage,
@ -62,6 +63,18 @@ const routesConfiguration = [
name: 'ListingPage',
component: props => <ListingPage {...props} />,
},
{
path: '/l/new',
exact: true,
name: 'NewListingPage',
component: props => <EditListingPage {...props} type={'new'} />,
},
{
path: '/l/:slug/:id/edit',
exact: true,
name: 'EditListingPage',
component: props => <EditListingPage {...props} type={'edit'} />,
},
],
},
{

View file

@ -5,6 +5,8 @@
"HeroSearchForm.search": "Search",
"HeroSection.subTitle": "The largest online community to rent music studios",
"HeroSection.title": "Book Studiotime anywhere",
"EditListingForm.required": "Required",
"EditListingForm.maxLength": "Must be {maxLength} characters or less",
"ListingPage.loadingListingData": "Loading listing data",
"ListingPage.noListingData": "Could not find listing data",
"PageLayout.authInfoFailed": "Could not get authentication information.",

4
src/util/urlHelpers.jsx Normal file
View file

@ -0,0 +1,4 @@
/* eslint-disable import/prefer-default-export */
export const createSlug = (str) =>
encodeURIComponent(str.toLowerCase().split(' ').join('-'))

14
src/util/validators.js Normal file
View file

@ -0,0 +1,14 @@
/**
* Validator functions and helpers for Redux Forms
*/
// Redux Form expects and undefined value for a successful validation
const VALID = undefined;
export const required = message => value => {
return value ? VALID : message;
};
export const maxLength = (message, maximumLength) => value => {
return !value || value.length <= maximumLength ? VALID : message;
};