При начальном рендеринге компоненты ввода работают как обычно. Однако после того, как он снова сфокусирован и перефокусирован, он примет только один символ. Это также случается, если я сбрасываю форму.
Я создал класс FormValidationStateManager для повторного использования необходимых для проверки логов Joi c.
Form.jsx
class Form extends Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
this.validation = new FormValidationStateManager();
}
handleSubmit(e) {
e.preventDefault();
var result = this.validation.validateAll();
this.setState({
// need to do this, trigger render after validations
// we could eventually encapsulate this logic into FormValidationSM.
submit: Date.now()
}, function() {
if (result.valid) {
this.props.onSubmit(result.data);
}
}.bind(this));
}
resetForm(e) {
e.preventDefault();
this.validation.clearForm();
this.setState({
// need to do this, trigger render after validations,
// we could eventually encapsulate this logic into FormValidationSM.
submit: Date.now()
});
}
render() {
// TODO implement logic for rendering name from API call
let name = "John Doe";
return (
<form class='info-form' onSubmit={this.handleSubmit} autocomplete='off'>
<div class='info-block-section-title'>
<h2>Welcome {name}</h2>
<h4>Some nicely worded few sentences that let's the user know they just need to fill out a few bits of information to start the process of getting their student loans paid down!</h4>
</div>
<br />
<div class='info-block-section-body'>
<InfoFieldset validation={this.validation} />
</div>
<div class='info-form-button'>
<FormButton label="Continue" />
<FormButton type="button" onClick={this.resetForm.bind(this)} label="Reset" />
</div>
</form>
)
}
}
export default Form;
Набор полей
class Fieldset extends Component {
constructor(props) {
super(props);
this.state = {
secondaryEmailVisible: false
};
this.handleToggle = this.handleToggle.bind(this);
this.validation = this.props.validation || new FormValidationStateManager();
this.validation.addRules(this.validatorRules.bind(this));
}
validatorRules() {
var _rules = {
// TODO validate format of date, address and zip
date_of_birth: Joi.date().label("Date of Birth").required(),
address_street: Joi.string().label("Street Address").required(),
address_zip: Joi.string().label("Zip Code").required(),
password_confirmation: Joi.any()
.valid(Joi.ref('password'))
.required()
.label('Password Confirmation')
.messages({
"any.only": "{{#label}} must match password"
}).strip(),
password: Joi.string()
.pattern(/\d/, 'digit')
.pattern(/^\S*$/, 'spaces')
.pattern(/^(?!.*?(.)\1{2})/, 'duplicates')
.pattern(/[a-zA-z]/, 'alpha')
.required()
.min(CONSTANTS.PASSWORD_MIN)
.label('Password')
.messages({
"string.min": PASSWORD_HINT,
"string.pattern.name": PASSWORD_HINT
})
}
if (this.state.secondaryEmailVisible) {
_rules['secondary_email'] = Joi.string()
.label("Secondary Email")
.email({ tlds: false })
.required();
}
return _rules;
}
handleToggle() {
this.setState(state => ({
secondaryEmailVisible: !state.secondaryEmailVisible
}));
}
render() {
return(
<div class='info-fieldset'>
{/* for skipping chrome browser autocomplete feature
<input type='text' style={{ display: 'none '}} />
<input type='password' style={{ display: 'none '}} />
*/ }
<strong>Primary email for login: work@example.com</strong>
<FormElementToggle
field="secondary"
label={"Add another email for notifications?"}
onChange={this.handleToggle} />
<div class='info-form-element'>
<DisplayToggle remove={true} show={this.state.secondaryEmailVisible}>
<FormElementInput
field='secondary_email'
label="Secondary Email"
validation={this.validation}
placeholder='john@example.com' />
</DisplayToggle>
<FormElementInput
field='password'
type='password'
label={(
<div>
Password
<Tooltip
placement='top'
trigger={['hover']}
overlay={PASSWORD_HINT}>
<span><i className='fa fa-info-circle'></i></span>
</Tooltip>
</div>
)}
placeholder='********'
validation={this.validation} />
<FormElementInput
key={1}
field='password_confirmation'
type='password'
label="Password Confirmation"
placeholder='********'
validation={this.validation} />
<FormElementInput
field='date_of_birth'
label="Date of Birth"
validation={this.validation}
placeholder='MM/DD/YYYY' />
<FormElementInput
field='address_street'
label="Street Address"
validation={this.validation}
placeholder='123 Example St' />
<FormElementInput
field="address_zip"
label="Zip Code"
validation={this.validation}
placeholder='01234' />
</div>
</div>
)
}
}
export default Fieldset;
FormElementInput.jsx
class FormElementInput extends Component {
constructor(props) {
super(props);
this.input = React.createRef();
this.cachedErrors = {};
this.cachedValue = null;
this.state = { focused: false };
this.validation = this.props.validation || new FormValidationStateManager();
}
componentDidUpdate(prevProps, prevState) {
}
shouldComponentUpdate(nextProps, nextState) {
var filter = function(val, key, obj) {
if (_.isFunction(val)) {
return true;
}
// Certain props like "label" can take a React element, but those
// are created dynamically on the fly and they have an random internal UUID,
// which trips the deep-equality comparision with a false-positive.
// This is wasted cycles for rendering.
if (React.isValidElement(val)) {
return true;
}
// validation is a complex obj that we shouldn't be caring about
if (_.contains(['validation'], key)) {
return true;
}
};
// if the errors after a validation got updated, we should re-render.
// We have to compare in this manner because the validation object is not shallow-copied
// via props since it is managed at a higher-component level, so we need to
// do a local cache comparision with our local copy of errors.
if (!_.isEqual(this.cachedErrors, this.getErrors())) {
return true;
}
// if the errors after a validation got updated, we should re-render.
// We have to compare in this manner because the validation object is not shallow-copied
// via props since it is managed at a higher-component level, so we need to
// do a local cache comparision with our local copy of errors.
if (this.cachedValue !== this.getDisplayValue()) {
return true;
}
return !_.isEqual(_.omit(nextProps, filter), _.omit(this.props, filter)) || !_.isEqual(this.state, nextState);
}
setValue(value) {
console.error('deprecated - can not set value directly on FormElementInput anymore');
throw 'deprecated - can not set value directly on FormElementInput anymore';
}
isDollarType() {
return this.props.type === 'dollar';
}
isRateType() {
return this.props.type === 'rate';
}
getType() {
if (this.isDollarType() || this.isRateType()) {
return 'text';
} else {
return this.props.type;
}
}
getPrefix() {
if (this.isDollarType()) {
return _.compact([this.props.prefix, '$']).join(' ');
} else {
return this.props.prefix;
}
}
getPostfix() {
if (this.isRateType()) {
return _.compact([this.props.postfix, '%']).join(' ');
} else {
return this.props.postfix;
}
}
getDisplayValue() {
var value = this.props.value || this.validation.getFormValue(this.props.field);
// while DOM input is focused, just return the same value being typed
if (this.state.focused) {
return value;
}
if (this.isDollarType()) {
if (_.isUndefined(value)) {
return value;
}
// keep in sync with the validation check in getOnChange()
// even if this is different - it wont break - cause accounting.js handles gracefully
else if (_.isNumber(value) || !value.match(/[a-z]/i)) {
return accounting.formatMoney(value, '', 2);
} else {
return value;
}
} else if (this.isRateType()){
if (_.isUndefined(value)) {
return '';
}
return accounting.formatNumber(value, 3)
} else {
return value;
}
}
getErrors() {
return this.props.errors || this.validation.getValidationMessages(this.props.field);
}
onBlur(event) {
this.validation.validate(this.props.field);
this.setState({ focused: false });
}
onFocus(event) {
this.setState({ focused: true });
}
onChange(event) {
if (this.isDollarType()) {
// only accept if the input at least resembles a currency
// accounting.parse already strips alot of characters and does this silently
// we want to be a little less strict than accounting.parse since we need to allow more
// different types of inputs +-,. and also let accounting.parse do its job
// very basic check here for a-z characters
if (!event.target.value.match(/[a-z]/i)) {
var parsedValue = accounting.parse(event.target.value);
event._parsedValue = parsedValue;
}
}
let _value = event._parsedValue || event.target.value;
console.log(this.input)
this.validation.setFormValue(this.props.field, _value);
}
_eventHandlers(field) {
return {
onChange: this.onChange.bind(this),
onBlur: this.onBlur.bind(this),
onFocus: this.onFocus.bind(this)
};
}
_mergeEventHandlers(p1, p2) {
var events = [
'onChange',
'onBlur',
'onFocus'
];
var r1 = {};
events.map(function(e) {
r1[e] = FuncUtils.mergeFunctions(p1[e], p2[e]);
}.bind(this));
return r1;
}
render() {
let _errors = this.cachedErrors = this.getErrors();
let _value = this.cachedValue = this.getDisplayValue();
let attrs = {
id: this.props.htmlId || ('field_' + this.props.field),
className: classnames({
'error': _errors && _errors.length
}),
type: this.getType(),
placeholder: this.props.placeholder,
autoComplete: "false",
disabled: this.props.disabled,
readOnly: this.props.disabled,
value: _value,
ref: this.input,
};
attrs = _.extend(attrs, this._mergeEventHandlers(this.props, this._eventHandlers()));
const prefix = this.getPrefix();
const postfix = this.getPostfix();
return (
<FormElementWrapper
type="input"
field={this.props.field}
errors={_errors}
label={this.props.label}>
<div className="form-element-input">
<DisplayToggle remove={true} hide={!prefix}>
<span className="prefix">{prefix}</span>
</DisplayToggle>
<div className={classnames({
"has-prefix": !!prefix,
"has-postfix": !!postfix
})}>
<input {...attrs} />
</div>
<DisplayToggle remove={true} hide={!postfix}>
<span className="postfix">{postfix}</span>
</DisplayToggle>
</div>
<div className="form-error">
{_errors.map((msg, idx) => {
return (<FormError key={idx}>{msg}</FormError>)
})}
</div>
</FormElementWrapper>
)
}
};
FormElementInput.PropTypes = {
field: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
value: PropTypes.any,
label: PropTypes.any,
errors: PropTypes.array,
type: PropTypes.string,
placeholder: PropTypes.string,
onChange: PropTypes.func,
onFocus: PropTypes.func,
onKeyUp: PropTypes.func,
tooltip: PropTypes.string,
prefix: PropTypes.string,
postfix: PropTypes.string,
disabled: PropTypes.bool,
disableTrimming: PropTypes.bool
}
FormElementInput.defaultProps = {
type: 'text',
placeholder: 'Enter Value',
tooltip: undefined,
prefix: undefined,
postfix: undefined,
disabled: false,
disableTrimming: false
}
export default FormElementInput;
FormValidationStateManager. js
class FormValidationStateManager {
// default state, validation ref obj
constructor(defaultState) {
this.rules = [];
this.state = defaultState || {};
this.errors = {};
this.dirty = false;
}
getFormValue(key) {
return this.state[key];
}
setFormValue(key, value) {
this.dirty = true;
this.state[key] = value;
}
setFormState(newState) {
this.state = _.extend({}, newState);
}
addRules(rules) {
this.rules.push(rules);
}
isFormDirty(){
return this.dirty;
}
/**
* Clear all previous validations
*
* @return {void}
*/
clearValidations() {
this.dirty = false;
this.errors = {};
}
clearForm() {
this.state = _.mapObject(this.state, function(v, k) {
return '';
});
this.clearValidations();
}
/**
* Check current validity for a specified key or entire form.
*
* @param {?String} key to check validity (entire form if undefined).
* @return {Boolean}.
*/
isValid(key) {
return _.isEmpty(this.getValidationMessages(key));
}
// call within submit
validateAll() {
return this.validate();
}
/**
* Method to validate single form key or entire form against the component data.
*
* @param {String|Function} key to validate, or error-first containing the validation errors if any.
* @param {?Function} error-first callback containing the validation errors if any.
*/
validate(key, callback) {
if (_.isFunction(key)) {
callback = key;
key = undefined;
}
var schema = this._buildRules();
var data = this.state;
var result = this._joiValidate(schema, data, key);
if (key) {
this.errors[key] = result.errors[key];
} else {
this.errors = result.errors;
}
return {
valid: this.isValid(key),
errors: this.errors,
data: result.data
};
}
_buildRules() {
var allRules = this.rules.map(function(r) {
// Rules can be functions and be dynamically evalulated on-the-fly,
// to allow for more complex validation rules.
if (_.isFunction(r)) {
return r();
} else {
return r;
}
});
// collapse all the rules into one single object that will
// be evaluated against `this.state`
return Joi.object(_.extend({}, ...allRules));
}
/**
* Get current validation messages for a specified key or entire form.
*
* @param {?String} key to get messages, or entire form if key is undefined.
* @return {Array}
*/
getValidationMessages(key) {
let errors = this.errors || {};
if (_.isEmpty(errors)) {
return [];
} else {
if (key === undefined) {
return _.flatten(_.keys(errors).map(error => {
return errors[error] || []
}));
} else {
return errors[key] ? errors[key].map(he.decode) : [];
}
}
}
_joiValidate(joiSchema, data, key) {
joiSchema = joiSchema || Joi.object();
data = data || {};
const joiOptions = {
// when true, stops validation on the first error, otherwise returns all the errors found. Defaults to true.
abortEarly: false,
// when true, allows object to contain unknown keys which are ignored. Defaults to false.
allowUnknown: true,
// remove unknown elements from objects and arrays. Defaults to false
stripUnknown: true,
errors: {
escapeHtml: true,
// overrides the way values are wrapped (e.g. [] around arrays, "" around labels).
// Each key can be set to a string with one (same character before and after the value)
// or two characters (first character before and second character after), or false to disable wrapping:
label: false,
wrap: {
label: false,
array: false
}
},
messages: {
"string.empty": "{{#label}} is required",
"any.empty": "{{#label}} is required",
"any.required": "{{#label}} is required"
}
};
const result = joiSchema.validate(data, joiOptions);
let errors = this._formatErrors(result);
if (key) {
errors = _.pick(errors, key);
}
return {
errors: errors,
data: result.value
};
}
_formatErrors(joiResult) {
if (joiResult.error) {
return _.reduce(joiResult.error.details, (memo, detail) => {
// According to docs:
// - "detail.path": ordered array where each element is the accessor to the value where the error happened.
// - "detail.context.key": key of the value that erred, equivalent to the last element of details.path.
// Which is why we get the last element in "path"
var key = _.last(detail.path);
if (!Array.isArray(memo[key])) {
memo[key] = [];
}
if (!_.contains(memo[key], detail.message)) {
memo[key].push(detail.message);
}
return memo;
}, {});
} else {
return {};
}
}
}
export default FormValidationStateManager;