|
const { |
|
call, |
|
takeEvery |
|
} = ReduxSaga.effects; |
|
|
|
const { |
|
routerMiddleware, |
|
routerReducer, |
|
syncHistoryWithStore |
|
} = ReactRouterRedux; |
|
|
|
const { |
|
Field, |
|
FieldArray, |
|
Fields, |
|
FormSection, |
|
reduxForm |
|
} = ReduxForm; |
|
|
|
const { |
|
IndexRoute, |
|
Link, |
|
Route, |
|
Router, |
|
hashHistory |
|
} = ReactRouter; |
|
|
|
setTimeout(() => { |
|
const {Provider} = ReactRedux; |
|
const store = configureStore(); |
|
const history = syncHistoryWithStore(hashHistory, store); |
|
ReactDOM.render(( |
|
<Provider {...{store}}> |
|
<Router {...{history}}> |
|
<Route |
|
component={AppContainer} |
|
path="/" |
|
> |
|
<Route |
|
component={CreateContainer} |
|
path="create" |
|
/> |
|
<Route |
|
component={EditContainer} |
|
path="edit/:id" |
|
/> |
|
<IndexRoute component={ListContainer} /> |
|
</Route> |
|
</Router> |
|
</Provider> |
|
), document.getElementById('js-app')); |
|
}, 0); |
|
|
|
function configureStore (initialState) { |
|
const reducers = Redux.combineReducers({ |
|
form: ReduxForm.reducer, |
|
routing: routerReducer, |
|
records: recordsReducer |
|
}); |
|
const router = routerMiddleware(hashHistory); |
|
const sagaMiddleware = ReduxSaga.default(); |
|
const store = Redux.createStore(reducers, initialState, Redux.applyMiddleware(router, sagaMiddleware)); |
|
sagaMiddleware.run(rootSaga); |
|
return store; |
|
}; |
|
|
|
function *rootSaga () { |
|
yield [ |
|
createRedirect(), |
|
updateRedirect() |
|
]; |
|
} |
|
|
|
function *createRedirect () { |
|
yield takeEvery('RECORD_ADD', listRedirectEffect) |
|
} |
|
|
|
function *updateRedirect () { |
|
yield takeEvery('RECORD_UPDATE', listRedirectEffect) |
|
} |
|
|
|
function *listRedirectEffect () { |
|
yield call(hashHistory.push, '/'); |
|
} |
|
|
|
function recordsReducer (state = {maxId: 0, records: {}}, action) { |
|
if (action.type === 'RECORD_ADD') { |
|
return { |
|
...state, |
|
maxId: state.maxId + 1, |
|
records: { |
|
...state.records, |
|
[state.maxId]: action.record |
|
} |
|
}; |
|
} |
|
if (action.type === 'RECORD_UPDATE') { |
|
return { |
|
...state, |
|
records: { |
|
...state.records, |
|
[action.id]: action.record |
|
} |
|
}; |
|
} |
|
return state; |
|
} |
|
|
|
const recordsActions = { |
|
addRecord: record => ({type: 'RECORD_ADD', record}), |
|
updateRecord: (id, record) => ({type: 'RECORD_UPDATE', id, record}) |
|
}; |
|
|
|
class AppComponent extends React.Component { |
|
render () { |
|
return ( |
|
<div className="o-wrapper"> |
|
{this.props.children} |
|
</div> |
|
); |
|
} |
|
} |
|
|
|
const AppContainer = ReactRedux.connect(() => { |
|
return { |
|
}; |
|
}, { |
|
})(AppComponent); |
|
|
|
class CreateComponent extends React.Component { |
|
static propTypes = { |
|
addRecord: React.PropTypes.func.isRequired |
|
}; |
|
|
|
constructor (props) { |
|
super(props); |
|
this.handleSubmit = this.onSubmit(); |
|
} |
|
|
|
onSubmit () { |
|
return values => { |
|
this.props.addRecord(values); |
|
}; |
|
} |
|
|
|
render () { |
|
const initialValues = {}; |
|
return ( |
|
<div className="o-layout"> |
|
<div className="o-layout__item u-1/2@tablet"> |
|
<h1>{'Create Container'}</h1> |
|
<CreateForm |
|
onSubmit={this.handleSubmit} |
|
{...{initialValues}} |
|
/> |
|
<Link to="/">{'List'}</Link> |
|
</div> |
|
</div> |
|
); |
|
} |
|
} |
|
|
|
const CreateContainer = ReactRedux.connect(() => { |
|
return { |
|
}; |
|
}, { |
|
addRecord: recordsActions.addRecord |
|
})(CreateComponent); |
|
|
|
class EditComponent extends React.Component { |
|
static propTypes = { |
|
id: React.PropTypes.string.isRequired, |
|
record: React.PropTypes.object.isRequired, |
|
updateRecord: React.PropTypes.func.isRequired |
|
}; |
|
|
|
constructor (props) { |
|
super(props); |
|
this.handleSubmit = this.onSubmit(); |
|
} |
|
|
|
onSubmit () { |
|
const {id} = this.props; |
|
return values => { |
|
this.props.updateRecord(id, values); |
|
}; |
|
} |
|
|
|
render () { |
|
const {record} = this.props; |
|
const initialValues = record; |
|
return ( |
|
<div className="o-layout"> |
|
<div className="o-layout__item u-1/2@tablet"> |
|
<h1>{'Edit Record'}</h1> |
|
<EditForm |
|
onSubmit={this.handleSubmit} |
|
{...{initialValues}} |
|
/> |
|
<Link to="/">{'List'}</Link> |
|
</div> |
|
</div> |
|
); |
|
} |
|
} |
|
|
|
const EditContainer = ReactRedux.connect((state, ownProps) => { |
|
const { |
|
params: { |
|
id |
|
} |
|
} = ownProps; |
|
const { |
|
records: { |
|
records |
|
} |
|
} = state; |
|
return { |
|
id, |
|
record: records[id] |
|
}; |
|
}, { |
|
updateRecord: recordsActions.updateRecord |
|
})(EditComponent); |
|
|
|
class ListComponent extends React.Component { |
|
render () { |
|
const {records} = this.props; |
|
return ( |
|
<div> |
|
<h1>{'list container'}</h1> |
|
<ul className="o-list-bare"> |
|
{Object.keys(records).map((recordKey, key) => { |
|
const record = records[recordKey]; |
|
return ( |
|
<li {...{key}}> |
|
<table className="o-table"> |
|
<tbody> |
|
<tr> |
|
<td colSpan="3"><Link to={`/edit/${recordKey}`}>{record.profile.firstLastName}</Link></td> |
|
</tr> |
|
<tr> |
|
<td colSpan="3">{record.profile.phoneNumber}</td> |
|
</tr> |
|
<tr> |
|
<td>{record.payment.expirationDate}</td> |
|
<td>{record.payment.securityCode}</td> |
|
<td>{record.payment.zipCode}</td> |
|
</tr> |
|
</tbody> |
|
</table> |
|
</li> |
|
); |
|
})} |
|
</ul> |
|
<Link to="/create">{'Create'}</Link> |
|
</div> |
|
); |
|
} |
|
} |
|
|
|
const ListContainer = ReactRedux.connect(state => { |
|
const { |
|
records: { |
|
records |
|
} |
|
} = state; |
|
return { |
|
records |
|
}; |
|
}, { |
|
})(ListComponent); |
|
|
|
class SignUpComponent extends React.Component { |
|
render () { |
|
const { |
|
handleSubmit, |
|
invalid |
|
} = this.props; |
|
return ( |
|
<form onSubmit={handleSubmit}> |
|
<FormSection name="profile"> |
|
<ProfileFields /> |
|
</FormSection> |
|
<FormSection name="payment"> |
|
<PaymentFields /> |
|
</FormSection> |
|
<MembersFields /> |
|
<FormButton |
|
className="c-form-button--primary c-form-button--block" |
|
label="Submit" |
|
type="submit" |
|
/> |
|
</form> |
|
); |
|
} |
|
} |
|
|
|
function validatePayment (values) { |
|
const errors = {}; |
|
if (!values || !values.expirationDate) { |
|
errors.expirationDate = 'Expiration date is required'; |
|
} else if (!jQuery.payment.validateCardExpiry.apply({}, values.expirationDate.split('/'))) { |
|
errors.expirationDate = 'Expiration date is invalid'; |
|
} |
|
if (!values || !values.securityCode) { |
|
errors.securityCode = 'Security code is required'; |
|
} else if (!jQuery.payment.validateCardCVC(values.securityCode)) { |
|
errors.securityCode = 'Security code is invalid'; |
|
} |
|
if (!values || !values.zipCode) { |
|
errors.zipCode = 'Zip code is required'; |
|
} else if (/(^\d{5}$)|(^\d{5}-\d{4}$)/.test(values.zipCode) === false) { |
|
errors.zipCode = 'Zip code is invalid'; |
|
} |
|
return errors; |
|
} |
|
|
|
function validateProfile (values) { |
|
const errors = {}; |
|
if (!values || !values.firstLastName) { |
|
errors.firstLastName = 'Required'; |
|
} else if (values.firstLastName.split(' ').length < 2) { |
|
errors.firstLastName = 'Both first and last name are required'; |
|
} |
|
if (!values || !values.phoneNumber) { |
|
errors.phoneNumber = 'Required'; |
|
} else if (!values.phoneNumber.match(/^\d{10}$/)) { |
|
errors.phoneNumber = 'Invalid'; |
|
} |
|
if (!values || !values.type) { |
|
errors.type = 'Required'; |
|
} |
|
return errors; |
|
} |
|
|
|
function validateMembers (values) { |
|
if (!values) { |
|
return []; |
|
} |
|
const errors = []; |
|
values.forEach(((widget, index) => { |
|
const memberErrors = {}; |
|
if (!widget.name) { |
|
memberErrors.name = 'Required'; |
|
errors[index] = memberErrors; |
|
} else if (widget.name.match(/\w+/g).length < 3) { |
|
memberErrors.name = 'Must be 3 words' |
|
errors[index] = memberErrors; |
|
} |
|
if (!widget.gender) { |
|
memberErrors.gender = 'Required'; |
|
errors[index] = memberErrors; |
|
} |
|
})); |
|
return errors; |
|
} |
|
|
|
const validateSignUp = validationEngine({ |
|
members: validateMembers, |
|
payment: validatePayment, |
|
profile: validateProfile |
|
}); |
|
|
|
const CreateForm = reduxForm({ |
|
form: 'createForm', |
|
validate: validateSignUp |
|
})(SignUpComponent); |
|
|
|
const EditForm = reduxForm({ |
|
form: 'editForm', |
|
validate: validateSignUp |
|
})(SignUpComponent); |
|
|
|
class ProfileFields extends React.Component { |
|
render () { |
|
return ( |
|
<div> |
|
<Field |
|
component={FormRadio} |
|
hint="Pick a type" |
|
label="Type" |
|
name="type" |
|
options={[ |
|
{ |
|
classes: 'u-1/3', |
|
icon: 'Password', |
|
label: 'Alpha', |
|
value: 'alpha' |
|
}, { |
|
classes: 'u-1/3', |
|
icon: 'Password', |
|
label: 'Bravo', |
|
value: 'bravo' |
|
}, { |
|
classes: 'u-1/3', |
|
icon: 'Password', |
|
label: 'Charlie', |
|
value: 'charlie' |
|
} |
|
]} |
|
/> |
|
<FormField |
|
hint="Recipient" |
|
fields={[ |
|
{ |
|
name: 'firstLastName', |
|
placeholder: 'First and Last Name', |
|
type: 'text' |
|
} |
|
]} |
|
icon="Birthday" |
|
/> |
|
<FormField |
|
hint="Must be a valid US number" |
|
fields={[ |
|
{ |
|
name: 'phoneNumber', |
|
format: formatPhone, |
|
parse: parsePhone, |
|
placeholder: 'Phone Number', |
|
type: 'tel' |
|
} |
|
]} |
|
icon="Pin" |
|
/> |
|
</div> |
|
); |
|
} |
|
} |
|
|
|
class MembersFields extends React.Component { |
|
renderMembers ({fields}) { |
|
return ( |
|
<ul className="o-list-bare"> |
|
{fields.map((member, key) => ( |
|
<li |
|
className="u-margin-bottom" |
|
{...{key}} |
|
> |
|
<div className="o-media"> |
|
<div className="o-media__img"> |
|
<FormButton |
|
className="c-form-button--destructive" |
|
label="X" |
|
onClick={() => fields.remove(key)} |
|
type="button" |
|
/> |
|
</div> |
|
<div className="o-media__body"> |
|
<h4 className="c-h4 u-margin-bottom-small">{`Widget ${key + 1}`}</h4> |
|
<FormField |
|
hint="Must be 3 words" |
|
fields={[ |
|
{ |
|
name: `${member}.name`, |
|
placeholder: 'Name', |
|
type: 'text' |
|
} |
|
]} |
|
icon="Pin" |
|
/> |
|
<Field |
|
component={FormRadio} |
|
hint="Choose your style" |
|
label="Gender" |
|
name={`${member}.gender`} |
|
options={[ |
|
{ |
|
classes: 'u-1/3', |
|
icon: 'Password', |
|
label: 'Girl', |
|
value: 'female' |
|
}, { |
|
classes: 'u-1/3', |
|
icon: 'Password', |
|
label: 'Boy', |
|
value: 'male' |
|
}, { |
|
classes: 'u-1/3', |
|
icon: 'Password', |
|
label: 'Unspecified', |
|
value: 'unspecified' |
|
} |
|
]} |
|
/> |
|
</div> |
|
</div> |
|
</li> |
|
))} |
|
<li> |
|
<FormButton |
|
className="c-form-button--primary c-form-button--inverse c-form-button--block" |
|
label="+ Add widget" |
|
onClick={() => fields.push({})} |
|
type="button" |
|
/> |
|
</li> |
|
</ul> |
|
); |
|
} |
|
|
|
render () { |
|
return ( |
|
<FieldArray |
|
name="members" |
|
component={this.renderMembers} |
|
/> |
|
); |
|
} |
|
} |
|
|
|
class PaymentFields extends React.Component { |
|
render () { |
|
return ( |
|
<div> |
|
<FormField |
|
hint="Don't use real info!" |
|
fields={[ |
|
{ |
|
className: 'u-1/3', |
|
name: 'expirationDate', |
|
format: formatExpirationDate, |
|
placeholder: 'MM / YY', |
|
type: 'text' |
|
}, { |
|
className: 'u-1/3', |
|
name: 'securityCode', |
|
placeholder: 'CVV', |
|
type: 'text' |
|
}, { |
|
className: 'u-1/3', |
|
name: 'zipCode', |
|
placeholder: 'Zip', |
|
type: 'text' |
|
} |
|
]} |
|
icon="Calendar" |
|
/> |
|
</div> |
|
); |
|
} |
|
} |
|
|
|
class FormField extends React.Component { // refactor, missing propTypes |
|
static propTypes = { |
|
icon: React.PropTypes.string, |
|
fields: React.PropTypes.arrayOf(React.PropTypes.shape({ |
|
className: React.PropTypes.string, |
|
format: React.PropTypes.func, |
|
name: React.PropTypes.string.isRequired, |
|
normalize: React.PropTypes.func, |
|
parse: React.PropTypes.func, |
|
placeholder: React.PropTypes.string.isRequired, // refactor, is this really required? Is this used for making checkboxes/radios? |
|
type: React.PropTypes.string.isRequired |
|
})), |
|
hint: React.PropTypes.string |
|
}; |
|
|
|
renderFields (props) { |
|
const { |
|
fields, |
|
hint, |
|
icon |
|
} = props; |
|
const errors = fields.map(({name}) => { |
|
const { |
|
meta: { |
|
error, |
|
touched |
|
} // refactor, ¯\_(ツ)_/¯ @ next line |
|
} = eval(`props.${name}`); // eslint-disable-line no-eval |
|
return touched && error; // refactor, `touched` might not be behaving as expected? |
|
}).filter(i => i); |
|
const error = errors[0] || props.error; |
|
const message = error || hint; |
|
const className = classNames({ |
|
'c-form-field': true, |
|
'c-form-field--error': !!error |
|
}); |
|
const Icon = SVGIcon[icon]; |
|
return ( |
|
<div {...{className}}> |
|
<div className="o-media"> |
|
<div className="o-media__img c-form-field__img"> |
|
{icon && (<Icon />)} |
|
</div> |
|
<div className="o-media__body"> |
|
<div className="c-form-field__control"> |
|
{fields.map((field, key) => { |
|
const { |
|
format, |
|
name, |
|
normalize, |
|
parse, |
|
placeholder, |
|
type |
|
} = field; // refactor, what else is in `props.${name}`? |
|
// refactor, I had to comment out the line below to make this work with FormSection |
|
// const {input} = eval(`props.${name}`); // eslint-disable-line no-eval |
|
const inputClassName = classNames({ |
|
'c-form-field__input': true, |
|
[field.className]: field.className && true // refactor, `&& true`? I don't get it |
|
}); |
|
return ( |
|
<Field |
|
className={inputClassName} |
|
component="input" |
|
{...{key, placeholder, type, format, normalize, parse, name}} |
|
/> |
|
); |
|
})} |
|
</div> |
|
<div className="c-form-field__hint">{message}</div> |
|
</div> |
|
</div> |
|
</div> |
|
); |
|
} |
|
|
|
render () { |
|
const { |
|
fields, |
|
hint, |
|
icon |
|
} = this.props; |
|
return ( |
|
<Fields |
|
component={this.renderFields} |
|
names={fields.map(({name}) => name)} |
|
{...{fields, hint, icon}} |
|
/> |
|
); |
|
} |
|
} |
|
|
|
class FormRadio extends React.Component { |
|
handleChange (value) { |
|
return () => { |
|
this.props.input.onChange(value); |
|
} |
|
} |
|
|
|
render () { |
|
const { |
|
hint, |
|
input: { |
|
value, |
|
name |
|
}, |
|
meta: { |
|
error, |
|
touched |
|
}, |
|
options |
|
} = this.props; |
|
const message = (touched && error) || hint; |
|
const className = classNames({ |
|
'c-form-radio': true, |
|
'c-form-radio--error': (touched && !!error) |
|
}); |
|
return ( |
|
<div {...{className}}> |
|
<div className="o-layout"> |
|
{options.map((field, key) => { |
|
const Icon = SVGIcon[field.icon]; |
|
const fieldClasses = field.classes || ''; |
|
return ( |
|
<div |
|
className={`c-form-radio__item o-layout__item ${fieldClasses}`} |
|
{...{key}} |
|
> |
|
<input |
|
checked={value === field.value} |
|
className="c-form-radio__field u-hidden-visually" |
|
id={`${name}-${key}`} |
|
onChange={this.handleChange(field.value)} |
|
type="radio" |
|
value={field.value} |
|
{...{name}} |
|
/> |
|
<label |
|
className="c-form-radio__label" |
|
htmlFor={`${name}-${key}`} |
|
> |
|
{field.icon && Icon && (<Icon active={value === field.value} />)} |
|
{field.label} |
|
</label> |
|
</div> |
|
); |
|
})} |
|
<div className="o-layout__item u-1/1 c-form-radio__message"> |
|
<div className="c-form-radio__hint">{message}</div> |
|
</div> |
|
</div> |
|
</div> |
|
); |
|
} |
|
} |
|
|
|
class FormButton extends React.Component { |
|
static propTypes = { |
|
className: React.PropTypes.string, |
|
disabled: React.PropTypes.bool, |
|
onClick: React.PropTypes.func, |
|
type: React.PropTypes.string.isRequired |
|
}; |
|
|
|
render () { |
|
const { |
|
disabled, |
|
label, |
|
onClick, |
|
type |
|
} = this.props; |
|
const className = classNames({ |
|
'c-form-button': true, |
|
[this.props.className]: this.props.className && true |
|
}); |
|
return ( |
|
<button {...{ |
|
className, |
|
disabled, |
|
onClick, |
|
type |
|
}} |
|
>{label}</button> |
|
); |
|
} |
|
} |
|
|
|
function formatPhone (input) { // refactor, deleting a phone number doesn't work so well |
|
if (!input) { |
|
return ''; |
|
} // logic taken from https://github.com/omarshammas/jquery.formance/ |
|
const results = input.replace(/\D/g, '').match(/^(\d{0,3})?(\d{0,3})?(\d{0,4})?$/); |
|
if (!results) { |
|
return input; |
|
} |
|
const [_phoneNumber, areaCode, first3, last4] = results.filter(i => i).map(i => `${i}`); // eslint-disable-line no-unused-vars |
|
let output = ''; |
|
if (areaCode) { |
|
output += `(${areaCode}`; |
|
} |
|
if (areaCode && areaCode.length === 3) { |
|
output += ') '; |
|
} |
|
if (first3) { |
|
output += first3; |
|
} |
|
if (first3 && first3.length === 3) { |
|
output += ' - '; |
|
} |
|
if (last4) { |
|
output += last4; |
|
} |
|
return output; |
|
} |
|
|
|
function parsePhone (input) { |
|
if (/\d/.test(input) === false) { |
|
return input; |
|
} |
|
return input.match(/\d+/g).join(''); |
|
} |
|
|
|
function formatExpirationDate (input) { // refactor, doesn't work very well when deleting |
|
if (!input) { |
|
return ''; |
|
} // logic taken from https://github.com/omarshammas/jquery.formance/ |
|
let output = input; |
|
if (/^\d$/.test(input) && input !== '0' && input !== '1') { |
|
output = `0${output} / `; |
|
} else if (/^\d\d$/.test(input)) { |
|
output = `${output} / `; |
|
} |
|
return output; |
|
} |
|
|
|
function validationEngine (validators) { |
|
return values => { |
|
return Object.keys(validators).map(name => ({ |
|
name, |
|
error: validators[name](values[name]) |
|
})).reduce((p, {name, error}) => ( |
|
Object.keys(name).length ? {...p, [name]: error} : p |
|
), {}); |
|
}; |
|
} |