Skip to content

Instantly share code, notes, and snippets.

@clindsey
Last active January 11, 2017 20:13
Show Gist options
  • Save clindsey/97bb9aa9bd822de4d99e1a9abda34072 to your computer and use it in GitHub Desktop.
Save clindsey/97bb9aa9bd822de4d99e1a9abda34072 to your computer and use it in GitHub Desktop.
relations-catalog-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="https://s3-us-west-2.amazonaws.com/s.cdpn.io/292951/redux-devtools.js"></script>
<script src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/292951/redux-devtools-dock-monitor.js"></script>
<script src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/292951/redux-devtools-log-monitor.js"></script>
<script src="//codepen.io/clindsey/pen/LbyNre.js"></script>

relations-catalog-1.0.0

a simple demo of redux-form's FieldArrays, validations, formatting, and parsing

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;
const DockMonitor = ReduxDevToolsDockMonitor.default;
const LogMonitor = ReduxDevToolsLogMonitor.default;
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 enhancer = Redux.compose(Redux.applyMiddleware(router, sagaMiddleware), DevTools.instrument());
const store = Redux.createStore(reducers, initialState, enhancer);
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}
<DevTools />
</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/4@tablet">
</div>
<div className="o-layout__item u-1/2@tablet">
<h1 className="c-h1">{'New Person'}</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/4@tablet">
</div>
<div className="o-layout__item u-1/2@tablet">
<h1 className="c-h1">{'Update Person'}</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 className="o-layout">
<div className="o-layout__item u-1/4@tablet">
</div>
<div className="o-layout__item u-1/2@tablet">
<h1 className="c-h1">{'People List'}</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>
</tbody>
</table>
</li>
);
})}
</ul>
<Link to="/create">{'Add a new person'}</Link>
</div>
</div>
);
}
}
const ListContainer = ReactRedux.connect(state => {
const {
records: {
records
}
} = state;
return {
records
};
}, {
})(ListComponent);
class PersonForm extends React.Component {
render () {
const {
handleSubmit,
invalid
} = this.props;
return (
<form onSubmit={handleSubmit}>
<FormSection name="profile">
<ProfileFields />
</FormSection>
<RelationsFields />
<FormButton
className="c-form-button--primary c-form-button--block"
label="Submit"
type="submit"
/>
</form>
);
}
}
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 validateRelations (values) {
if (!values) {
return [];
}
const errors = [];
values.forEach(((relation, index) => {
const memberErrors = validateProfile(relation);
if (Object.keys(memberErrors).length) {
errors[index] = memberErrors;
}
}));
return errors;
}
const validateSignUp = validationEngine({
relations: validateRelations,
profile: validateProfile
});
const CreateForm = reduxForm({
form: 'createForm',
validate: validateSignUp
})(PersonForm);
const EditForm = reduxForm({
form: 'editForm',
validate: validateSignUp
})(PersonForm);
class ProfileFields extends React.Component {
render () {
return (
<div>
<Field
component={FormRadio}
label="Type"
name="type"
options={[
{
classes: 'u-1/3',
icon: 'Password',
label: 'Human',
value: 'human'
}, {
classes: 'u-1/3',
icon: 'Password',
label: 'Alien',
value: 'alien'
}, {
classes: 'u-1/3',
icon: 'Password',
label: 'Cyborg',
value: 'cyborg'
}
]}
/>
<FormField
fields={[
{
name: 'firstLastName',
placeholder: 'First and Last Name',
type: 'text'
}
]}
icon="Password"
/>
<FormField
fields={[
{
name: 'phoneNumber',
format: formatPhone,
parse: parsePhone,
placeholder: 'Phone Number',
type: 'tel'
}
]}
icon="Password"
/>
</div>
);
}
}
class RelationsFields extends React.Component {
renderRelations ({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">{`Relation ${key + 1}`}</h4>
<FormSection name={member}>
<ProfileFields />
</FormSection>
</div>
</div>
</li>
))}
<li>
<FormButton
className="c-form-button--primary c-form-button--inverse c-form-button--block"
label="+ Add a relation"
onClick={() => fields.push({})}
type="button"
/>
</li>
</ul>
);
}
render () {
return (
<FieldArray
name="relations"
component={this.renderRelations}
/>
);
}
}
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>
);
}
}
const DevTools = ReduxDevTools.createDevTools(
<DockMonitor
changePositionKey="ctrl-q"
defaultIsVisible={false}
toggleVisibilityKey="ctrl-h"
>
<LogMonitor theme="tomorrow" />
</DockMonitor>
);
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, // 5625463739
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.75rem;
min-height: 2rem;
}
// 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