Skip to content

Instantly share code, notes, and snippets.

@clindsey
Last active January 10, 2017 23:12
Show Gist options
  • Save clindsey/25d92b22ef11ed0cdb818950d9f427aa to your computer and use it in GitHub Desktop.
Save clindsey/25d92b22ef11ed0cdb818950d9f427aa to your computer and use it in GitHub Desktop.
redux-form-1.0.0
<div id="js-app"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/6.20.0/polyfill.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.3.2/react.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.3.2/react-dom.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/3.5.2/redux.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/4.4.5/react-redux.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux-saga/0.14.2/redux-saga.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux-form/6.4.3/redux-form.js"></script>
<script src="https://unpkg.com/react-router/umd/ReactRouter.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-router-redux/4.0.7/ReactRouterRedux.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/classnames/2.2.5/dedupe.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script> <!-- this is just for the credit card validation library, not sure why it even needs jQuery -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.payment/3.0.0/jquery.payment.js"></script>
<script src="//codepen.io/clindsey/pen/LbyNre.js"></script>

redux-form-1.0.0

figuring out how to use redux, redux-form and react to build form components with complex field arrays, including validation, normalization and parsing. These components should work inside of any <form />

Uses inuit-css 6, redux-sagas, react-router, react-router-redux

based on designs by @sbelous

A Pen by not important on CodePen.

License.

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
), {});
};
}
// begin colors
$brand-primary: #60c1da;
$brand-secondary: #0698bd;
$brand-red: #e90c27;
$brand-green: #4ab043;
$brand-yellow: #ffd305;
$brand-black: #4a4a4a;
$brand-gray: #9b9b9b;
$brand-lighter-gray: #b0b0b0;
$brand-disabled-gray: #d0d0d0;
$brand-border-gray: #e0e0e0;
$brand-primary-muted: #a0dae9;
$brand-success: #5cb85c;
$brand-info: #5bc0de; // stylelint-disable-line no-indistinguishable-colors
$brand-warning: #f0ad4e;
$brand-danger: #d9534f;
$brand-inverse: $gray-dark;
// end colors
html {
color: $text-base-color;
font-family: 'Open Sans', sans-serif;
font-size: 14px;
@include mq($from: tablet) {
font-size: 16px;
}
}
body {
margin-top: $inuit-global-spacing-unit;
margin-bottom: $inuit-global-spacing-unit;
}
// begin FormField
.c-form-field {
margin-bottom: $inuit-global-spacing-unit;
}
.c-form-field--error {
.c-form-field__control {
border-bottom: solid 1px $border-color;
}
.c-form-field__hint {
color: $brand-danger;
}
}
.c-form-field__hint {
font-size: 0.75rem;
color: $text-muted-color;
font-weight: 300;
min-height: $inuit-global-spacing-unit;
}
.c-form-field__img {
height: 1.375rem; // 22px
margin-right: 0;
text-align: center;
width: 2.125rem; // 34px
> img {
margin: 0 auto;
height: 1.375rem; // 22px
}
}
.c-form-field__control {
border-bottom: solid 1px $border-color;
}
.c-form-field__input {
-webkit-backface-visibility: hidden;
border: none;
color: $text-base-color;
font-family: inherit;
font-weight: 300;
padding: 0;
width: 100%;
}
// end FormField
// begin FormButton
.c-form-button {
border: solid 1px $white;
border-radius: $control-radius;
color: $white;
cursor: pointer;
display: inline-block;
font: inherit;
font-weight: 600;
margin: $inuit-global-spacing-unit-small 0;
padding: round($inuit-global-spacing-unit-small * 0.5) $inuit-global-spacing-unit;
text-align: center;
vertical-align: middle;
}
.c-form-button--primary {
background-color: $brand-primary;
border-color: $brand-primary;
&.c-form-button--disabled,
&:disabled {
background-color: $brand-primary-muted;
border-color: $brand-primary-muted;
}
&.c-form-button--inverse {
background-color: $white;
color: $brand-primary;
border-color: $brand-primary;
}
}
.c-form-button--block {
width: 100%;
}
.c-form-button--destructive {
background-color: $brand-danger;
border-color: $brand-danger;
}
// end FormButton
// begin FormRadio
.c-form-radio__label {
border-radius: $control-radius;
border: solid 1px $border-color;
color: $text-muted-color;
cursor: pointer;
display: block;
margin: 0 auto;
overflow-x: hidden;
padding: $inuit-global-spacing-unit-tiny;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
.c-form-radio__item {
margin-bottom: $inuit-global-spacing-unit-tiny;
text-align: center;
}
.c-form-radio__field:checked + label {
border-color: $brand-primary;
color: $brand-primary;
}
.c-form-radio--error {
.c-form-radio__hint {
color: $brand-danger;
}
}
.c-form-radio__hint {
color: $text-muted-color;
font-size: 0.8571rem;
}
// end FormRadio
// begin Typography
.c-h1 {
font-size: 2rem;
}
.c-h2 {
font-size: 1.5rem;
font-weight: 600;
}
.c-h3 {
font-size: 1.25rem;
}
.c-h4 {
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
}
.c-h5 {
font-size: 0.9375rem;
font-weight: 600;
}
.c-text-strong {
color: $brand-secondary;
}
.c-text-strong--inverse {
color: $white;
}
.c-text-strong--stronger {
font-weight: 600;
color: $text-base-color;
}
.c-text-strong--super-strong {
font-weight: 600;
font-size: 1.25rem;
}
.c-text-small {
font-size: 0.875rem;
}
.c-text-small--muted {
color: $brand-gray;
}
.c-text-small--strong {
color: $brand-secondary;
}
.c-text-small--stronger {
font-weight: 600;
}
// end Typography
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,600" rel="stylesheet" />
<link href="http://codepen.io/clindsey/pen/XNKwXY" rel="stylesheet" />
<link href="http://codepen.io/clindsey/pen/jVMoKL" rel="stylesheet" />
<link href="http://codepen.io/clindsey/pen/LbyNre" rel="stylesheet" />
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment