mirror of
https://github.com/ascribe/onion.git
synced 2024-12-22 09:23:13 +01:00
Replace Form with react-utility-belt's
This commit is contained in:
parent
61779e5535
commit
fea845ce3d
@ -1,398 +1,219 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import Button from 'react-bootstrap/lib/Button';
|
import Button from 'ascribe-react-components/es6/buttons/button';
|
||||||
import AlertDismissable from './alert';
|
|
||||||
|
import ApiForm from 'ascribe-react-components/es6/form/api_form';
|
||||||
|
import CollapsibleCheckboxProperty from 'ascribe-react-components/es6/form/properties/collapsible_checkbox_property';
|
||||||
|
|
||||||
|
import { createFormForPropertyTypes } from 'react-utility-belt/es6/form/form';
|
||||||
|
import formSpecExtender from 'react-utility-belt/es6/form/utils/form_spec_extender';
|
||||||
|
import Property from 'react-utility-belt/es6/form/properties/property';
|
||||||
|
import { objectOnlyArrayValue } from 'react-utility-belt/es6/prop_types';
|
||||||
|
|
||||||
import GlobalNotificationModel from '../../models/global_notification_model';
|
import GlobalNotificationModel from '../../models/global_notification_model';
|
||||||
import GlobalNotificationActions from '../../actions/global_notification_actions';
|
import GlobalNotificationActions from '../../actions/global_notification_actions';
|
||||||
|
|
||||||
import { sanitize } from '../../utils/general';
|
import AlertDismissable from '../alert_dismissable';
|
||||||
|
import AscribeSpinner from '../ascribe_spinner';
|
||||||
|
|
||||||
|
import { safeInvoke, sanitize } from '../../utils/general';
|
||||||
import { getLangText } from '../../utils/lang';
|
import { getLangText } from '../../utils/lang';
|
||||||
import request from '../../utils/request';
|
|
||||||
|
|
||||||
|
|
||||||
|
const { bool, func, string } = React.PropTypes;
|
||||||
|
|
||||||
let Form = React.createClass({
|
const BaseForm = createFormForPropertyTypes([Property, CollapsibleCheckboxProperty]);
|
||||||
|
const ApifiedForm = ApiForm(BaseForm);
|
||||||
|
|
||||||
|
const Form = React.createClass(formSpecExtender({
|
||||||
propTypes: {
|
propTypes: {
|
||||||
url: React.PropTypes.string,
|
url: string.isRequired, // Required for ApiForm
|
||||||
method: React.PropTypes.string,
|
|
||||||
buttonSubmitText: React.PropTypes.string,
|
|
||||||
handleSuccess: React.PropTypes.func,
|
|
||||||
getFormData: React.PropTypes.func,
|
|
||||||
children: React.PropTypes.oneOfType([
|
|
||||||
React.PropTypes.object,
|
|
||||||
React.PropTypes.array
|
|
||||||
]),
|
|
||||||
className: React.PropTypes.string,
|
|
||||||
spinner: React.PropTypes.element,
|
|
||||||
buttons: React.PropTypes.oneOfType([
|
|
||||||
React.PropTypes.element,
|
|
||||||
React.PropTypes.arrayOf(React.PropTypes.element)
|
|
||||||
]),
|
|
||||||
|
|
||||||
// Can be used to freeze the whole form
|
buttonText: string,
|
||||||
disabled: React.PropTypes.bool,
|
className: string,
|
||||||
|
|
||||||
// You can use the form for inline requests, like the submit click on a button.
|
/**
|
||||||
// For the form to then not display the error on top, you need to enable this option.
|
* Errors to display.
|
||||||
// It will make use of the GlobalNotification
|
*
|
||||||
isInline: React.PropTypes.bool,
|
* Must be an object whose keys contain only arrays of errors. See react-utility-belt's Form
|
||||||
|
* for how it should be structured.
|
||||||
|
*/
|
||||||
|
errors: objectOnlyArrayValue,
|
||||||
|
|
||||||
autoComplete: React.PropTypes.string,
|
// For ApiForm
|
||||||
|
onSubmitError: func,
|
||||||
|
onValidationError: func,
|
||||||
|
|
||||||
onReset: React.PropTypes.func
|
/**
|
||||||
|
* If enabled, show respective errors as a global notification instead of passing them down
|
||||||
|
* to the backing Form or child Properties
|
||||||
|
*/
|
||||||
|
showFormErrorsAsNotification: bool,
|
||||||
|
showPropertyErrorsAsNotification: bool
|
||||||
|
|
||||||
|
// All other props are passed to the backing Form
|
||||||
},
|
},
|
||||||
|
|
||||||
getDefaultProps() {
|
getDefaultProp() {
|
||||||
return {
|
return {
|
||||||
method: 'post',
|
buttonText: getLangText('SAVE'),
|
||||||
buttonSubmitText: 'SAVE',
|
// By default, display all validation errors on their properties
|
||||||
autoComplete: 'off'
|
onValidationError: this.defaultOnValidationError,
|
||||||
|
|
||||||
|
renderFormErrors: this.defaultRenderFormErrors
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
getInitialState() {
|
getInitialState() {
|
||||||
return {
|
return {
|
||||||
edited: false,
|
errors: {}
|
||||||
submitted: false,
|
|
||||||
errors: []
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
reset() {
|
createErrorMessage: (errorProp) => {
|
||||||
// If onReset prop is defined from outside,
|
switch (errorProp) {
|
||||||
// notify component that a form reset is happening.
|
case 'min' || 'max':
|
||||||
if(typeof this.props.onReset === 'function') {
|
return getLangText('The value you defined is not in the valid range');
|
||||||
this.props.onReset();
|
case 'pattern':
|
||||||
}
|
return getLangText('The value you defined is not matching the valid pattern');
|
||||||
|
case 'required':
|
||||||
for(let ref in this.refs) {
|
return getLangText('This field is required');
|
||||||
if(typeof this.refs[ref].reset === 'function') {
|
default:
|
||||||
this.refs[ref].reset();
|
return null;
|
||||||
}
|
|
||||||
}
|
|
||||||
this.setState(this.getInitialState());
|
|
||||||
},
|
|
||||||
|
|
||||||
submit(event){
|
|
||||||
if(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({submitted: true});
|
|
||||||
this.clearErrors();
|
|
||||||
|
|
||||||
// selecting http method based on props
|
|
||||||
if(this[this.props.method] && typeof this[this.props.method] === 'function') {
|
|
||||||
window.setTimeout(() => this[this.props.method](), 100);
|
|
||||||
} else {
|
|
||||||
throw new Error('This HTTP method is not supported by form.js (' + this.props.method + ')');
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
request(method) {
|
defaultOnValidationError(validationErrors) {
|
||||||
request(this.props.url, {
|
// Create useful messages based on the validation prop that failed for each error
|
||||||
method,
|
const errors = Object.entries(validationErrors)
|
||||||
jsonBody: this.getFormData()
|
.reduce((propertyErrors, [name, validationProp]) => {
|
||||||
})
|
const errorMsg = this.createErrorMessage(validationProp);
|
||||||
.then(this.handleSuccess)
|
if (errorMsg) {
|
||||||
.catch(this.handleError);
|
propertyErrors[name] = [errorMsg];
|
||||||
},
|
|
||||||
|
|
||||||
post() {
|
|
||||||
this.request('POST');
|
|
||||||
},
|
|
||||||
|
|
||||||
put() {
|
|
||||||
this.request('PUT');
|
|
||||||
},
|
|
||||||
|
|
||||||
patch() {
|
|
||||||
this.request('PATCH');
|
|
||||||
},
|
|
||||||
|
|
||||||
delete() {
|
|
||||||
this.request('DELETE');
|
|
||||||
},
|
|
||||||
|
|
||||||
getFormData() {
|
|
||||||
let data = {};
|
|
||||||
|
|
||||||
for (let refName in this.refs) {
|
|
||||||
const ref = this.refs[refName];
|
|
||||||
|
|
||||||
if (ref.state && 'value' in ref.state) {
|
|
||||||
// An input can also provide an `Object` as a value
|
|
||||||
// which we're going to merge with `data` (overwrites)
|
|
||||||
if(ref.state.value && ref.state.value.constructor === Object) {
|
|
||||||
Object.assign(data, ref.state.value);
|
|
||||||
} else {
|
|
||||||
data[ref.props.name] = ref.state.value;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof this.props.getFormData === 'function') {
|
return propertyErrors;
|
||||||
data = Object.assign(data, this.props.getFormData());
|
}, {});
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
this.setState({ errors });
|
||||||
},
|
},
|
||||||
|
|
||||||
handleChangeChild(){
|
onSubmitError(err, ...args) {
|
||||||
this.setState({ edited: true });
|
const {
|
||||||
},
|
onSubmitError,
|
||||||
|
showFormErrorsAsNotification,
|
||||||
|
showPropertyErrorsAsNotification
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
handleSuccess(response){
|
// Strip out any fields that are password related before logging
|
||||||
if(typeof this.props.handleSuccess === 'function') {
|
const formData = sanitize(
|
||||||
this.props.handleSuccess(response);
|
this.refs.form.getData(),
|
||||||
}
|
(value, name) => !name.includes('password')
|
||||||
|
);
|
||||||
|
console.logGlobal(err, formData);
|
||||||
|
|
||||||
for(let ref in this.refs) {
|
if (err.json && err.json.errors) {
|
||||||
if(this.refs[ref] && typeof this.refs[ref].handleSuccess === 'function'){
|
// Form validation failed server-side
|
||||||
this.refs[ref].handleSuccess(response);
|
if (showPropertyErrorsAsNotification) {
|
||||||
}
|
// This could be made better, ie. it could delegate to a callback to construct the
|
||||||
}
|
// message so the error is more descriptive.
|
||||||
this.setState({
|
Object.values(err.json.errors).forEach((error) => {
|
||||||
edited: false,
|
// Only use the first error if there's multiple for the Property
|
||||||
submitted: false
|
const errorMsg = Array.isArray(error) ? error[0] : error;
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
handleError(err) {
|
const notification = new GlobalNotificationModel(errorMsg, 'danger');
|
||||||
if (err.json) {
|
GlobalNotificationActions.appendGlobalNotification(notification);
|
||||||
for (let input in err.json.errors){
|
});
|
||||||
if (this.refs && this.refs[input] && this.refs[input].state) {
|
} else {
|
||||||
this.refs[input].setErrors(err.json.errors[input]);
|
// Make sure all error entries arrays before passing them down
|
||||||
} else {
|
const errors = Object.entries(err.json.errors)
|
||||||
this.setState({errors: this.state.errors.concat(err.json.errors[input])});
|
.reduce((arrayifiedErrors, [name, errorVal]) => {
|
||||||
}
|
if (errorVal) {
|
||||||
|
arrayifiedErrors[name] = Array.isArray(errorVal) ? errorVal
|
||||||
|
: [errorVal];
|
||||||
|
}
|
||||||
|
|
||||||
|
return arrayifiedErrors;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
this.setState({ errors });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let formData = this.getFormData();
|
// Something else happened
|
||||||
|
const errMsg = getLangText('Oops, something went wrong on our side. Please try again or ' +
|
||||||
|
'contact us if the problem persists.');
|
||||||
|
|
||||||
// sentry shouldn't post the user's password
|
if (showFormErrorsAsNotification) {
|
||||||
if (formData.password) {
|
const notification = new GlobalNotificationModel(errMsg, 'danger');
|
||||||
delete formData.password;
|
|
||||||
delete formData.password_confirm;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.logGlobal(err, formData);
|
|
||||||
|
|
||||||
if (this.props.isInline) {
|
|
||||||
let notification = new GlobalNotificationModel(getLangText('Something went wrong, please try again later'), 'danger');
|
|
||||||
GlobalNotificationActions.appendGlobalNotification(notification);
|
GlobalNotificationActions.appendGlobalNotification(notification);
|
||||||
} else {
|
} else {
|
||||||
this.setState({errors: [getLangText('Something went wrong, please try again later')]});
|
this.setState({
|
||||||
}
|
errors: {
|
||||||
}
|
form: [errMsg]
|
||||||
|
}
|
||||||
this.setState({submitted: false});
|
|
||||||
},
|
|
||||||
|
|
||||||
clearErrors() {
|
|
||||||
for(let ref in this.refs){
|
|
||||||
if (this.refs[ref] && typeof this.refs[ref].clearErrors === 'function'){
|
|
||||||
this.refs[ref].clearErrors();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.setState({errors: []});
|
|
||||||
},
|
|
||||||
|
|
||||||
getButtons() {
|
|
||||||
const { buttons, disabled, buttonSubmitText, spinner } = this.props;
|
|
||||||
const { submitted, edited } = this.state;
|
|
||||||
|
|
||||||
if (submitted || buttons !== undefined) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className={classNames({ 'hide': !submitted })}>
|
|
||||||
{spinner}
|
|
||||||
</div>
|
|
||||||
<div className={classNames({ 'hide': submitted })}>
|
|
||||||
{buttons}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (edited && !disabled) {
|
|
||||||
return (
|
|
||||||
<div className="row" style={{margin: 0}}>
|
|
||||||
<p className="pull-right">
|
|
||||||
<Button
|
|
||||||
className="btn btn-default btn-sm ascribe-margin-1px"
|
|
||||||
type="submit">
|
|
||||||
{buttonSubmitText}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="btn btn-danger btn-delete btn-sm ascribe-margin-1px"
|
|
||||||
type="reset">
|
|
||||||
{getLangText('CANCEL')}
|
|
||||||
</Button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
getErrors() {
|
|
||||||
let errors = null;
|
|
||||||
if (this.state.errors.length > 0){
|
|
||||||
errors = this.state.errors.map((error) => {
|
|
||||||
return <AlertDismissable error={error} key={error}/>;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return errors;
|
|
||||||
},
|
|
||||||
|
|
||||||
renderChildren() {
|
|
||||||
return React.Children.map(this.props.children, (child, i) => {
|
|
||||||
if (child) {
|
|
||||||
// Since refs will be overwritten by this functions return statement,
|
|
||||||
// we still want to be able to define refs for nested `Form` or `Property`
|
|
||||||
// children, which is why we're upfront simply invoking the callback-ref-
|
|
||||||
// function before overwriting it.
|
|
||||||
if(typeof child.ref === 'function' && this.refs[child.props.name]) {
|
|
||||||
child.ref(this.refs[child.props.name]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return React.cloneElement(child, {
|
|
||||||
handleChange: this.handleChangeChild,
|
|
||||||
ref: child.props.name,
|
|
||||||
key: i,
|
|
||||||
// We need this in order to make editable be overridable when setting it directly
|
|
||||||
// on Property
|
|
||||||
editable: child.props.overrideForm ? child.props.editable : !this.props.disabled
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { invoked, result } = safeInvoke(onSubmitError, err, ...args);
|
||||||
|
return invoked ? result : err;
|
||||||
|
},
|
||||||
|
|
||||||
|
onSubmitRequest() {
|
||||||
|
// Reset any old errors if we passed validation and are about to submit
|
||||||
|
this.setState({
|
||||||
|
errors: {}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
defaultRenderFormErrors(errors) {
|
||||||
* All webkit-based browsers are ignoring the attribute autoComplete="off",
|
return errors.map((error) => (
|
||||||
* as stated here: http://stackoverflow.com/questions/15738259/disabling-chrome-autofill/15917221#15917221
|
<AlertDismissable error={error} key={error} />
|
||||||
* So what we actually have to do is depended on whether or not this.props.autoComplete is set to "on" or "off"
|
));
|
||||||
* insert two fake hidden inputs that mock password and username so that chrome/safari is filling those
|
|
||||||
*/
|
|
||||||
getFakeAutocompletableInputs() {
|
|
||||||
if(this.props.autoComplete === 'off') {
|
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
<input style={{display: 'none'}} type="text" name="fakeusernameremembered"/>
|
|
||||||
<input style={{display: 'none'}} type="password" name="fakepasswordremembered"/>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a single ref and returns a human-readable error message
|
|
||||||
* @param {object} refToValidate A customly constructed object to check
|
|
||||||
* @return {oneOfType([arrayOf(string), bool])} Either an error message or false, saying that
|
|
||||||
* everything is valid
|
|
||||||
*/
|
|
||||||
_hasRefErrors(refToValidate) {
|
|
||||||
let errors = Object
|
|
||||||
.keys(refToValidate)
|
|
||||||
.reduce((a, constraintKey) => {
|
|
||||||
const contraintValue = refToValidate[constraintKey];
|
|
||||||
|
|
||||||
if(!contraintValue) {
|
|
||||||
switch(constraintKey) {
|
|
||||||
case 'min' || 'max':
|
|
||||||
a.push(getLangText('The field you defined is not in the valid range'));
|
|
||||||
break;
|
|
||||||
case 'pattern':
|
|
||||||
a.push(getLangText('The value you defined is not matching the valid pattern'));
|
|
||||||
break;
|
|
||||||
case 'required':
|
|
||||||
a.push(getLangText('This field is required'));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return a;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return errors.length ? errors : false;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This method validates all child inputs of the form.
|
|
||||||
*
|
|
||||||
* As of now, it only considers
|
|
||||||
* - `max`
|
|
||||||
* - `min`
|
|
||||||
* - `pattern`
|
|
||||||
* - `required`
|
|
||||||
*
|
|
||||||
* The idea is to enhance this method everytime we need more thorough validation.
|
|
||||||
* So feel free to add props that additionally should be checked, if they're present
|
|
||||||
* in the input's props.
|
|
||||||
*
|
|
||||||
* @return {[type]} [description]
|
|
||||||
*/
|
|
||||||
validate() {
|
|
||||||
this.clearErrors();
|
|
||||||
const validatedFormInputs = {};
|
|
||||||
|
|
||||||
Object
|
|
||||||
.keys(this.refs)
|
|
||||||
.forEach((refName) => {
|
|
||||||
let refToValidate = {};
|
|
||||||
const property = this.refs[refName];
|
|
||||||
const input = property.refs.input;
|
|
||||||
const value = ReactDOM.findDOMNode(input).value || input.state.value;
|
|
||||||
const { max,
|
|
||||||
min,
|
|
||||||
pattern,
|
|
||||||
required,
|
|
||||||
type } = input.props;
|
|
||||||
|
|
||||||
refToValidate.required = required ? value : true;
|
|
||||||
refToValidate.pattern = pattern && typeof value === 'string' ? value.match(pattern) : true;
|
|
||||||
refToValidate.max = type === 'number' ? parseInt(value, 10) <= max : true;
|
|
||||||
refToValidate.min = type === 'number' ? parseInt(value, 10) >= min : true;
|
|
||||||
|
|
||||||
const validatedRef = this._hasRefErrors(refToValidate);
|
|
||||||
validatedFormInputs[refName] = validatedRef;
|
|
||||||
});
|
|
||||||
const errorMessagesForRefs = sanitize(validatedFormInputs);
|
|
||||||
this.handleError({ json: { errors: errorMessagesForRefs } });
|
|
||||||
return !Object.keys(errorMessagesForRefs).length;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
let className = 'ascribe-form';
|
const {
|
||||||
|
buttonText,
|
||||||
|
className,
|
||||||
|
errors: propErrors,
|
||||||
|
showFormErrorsAsNotification: ignoredShowFormErrorsAsNotification, // ignored
|
||||||
|
showPropertyErrorsAsNotification: ignoredShowPropertyErrorsAsNotification, // ignored
|
||||||
|
...props
|
||||||
|
} = this.props;
|
||||||
|
const { errors: stateErrors } = this.state;
|
||||||
|
|
||||||
if(this.props.className) {
|
// FIXME: Use deep-merge instead, making sure to factor in that if a key's value is not an
|
||||||
className += ' ' + this.props.className;
|
// array, it should be merged as an array
|
||||||
}
|
const errors = Object.assign({}, propErrors, stateErrors);
|
||||||
|
|
||||||
|
const buttonDefault = (
|
||||||
|
<Button wide type="submit">
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttonSubmitting = (
|
||||||
|
<Button disabled wide type="button">
|
||||||
|
<AscribeSpinner color="dark-blue" size="md" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<ApifiedForm
|
||||||
role="form"
|
ref="form"
|
||||||
className={className}
|
{...props}
|
||||||
onSubmit={this.submit}
|
buttonDefault={buttonDefault}
|
||||||
onReset={this.reset}
|
buttonSubmitting={buttonSubmitting}
|
||||||
autoComplete={this.props.autoComplete}>
|
className={classNames('ascribe-form', className)}
|
||||||
{this.getFakeAutocompletableInputs()}
|
errors={errors}
|
||||||
{this.getErrors()}
|
errorType={AlertDismissable}
|
||||||
{this.renderChildren()}
|
onSubmitError={this.onSubmitError}
|
||||||
{this.getButtons()}
|
onSubmitRequest={this.onSubmitRequest}
|
||||||
</form>
|
onValidationError={this.onValidationError} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|
||||||
export default Form;
|
export default Form;
|
||||||
|
Loading…
Reference in New Issue
Block a user