Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions examples/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"form-container": "^0.2.4-rc11",
"form-container": "^0.2.5-rc1",
"material-ui-next": "^1.0.0-beta.38",
"react": "^16.2.0",
"react-dom": "^16.2.0",
Expand Down
19 changes: 10 additions & 9 deletions examples/src/Form.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { connectForm, IFormProps } from 'form-container';
import { connectForm, IFormProps, Control } from 'form-container';
import { email, required, alphaNumeric, strongPassword } from './validators';
import { TextField, Button, CardActions, CardHeader, CardContent } from 'material-ui-next';

Expand Down Expand Up @@ -28,14 +28,15 @@ class Form extends React.Component<IProps, {}> {
<form name="login" onSubmit={this.handleSubmit}>
<CardHeader title="Sign in" subheader="form-container example" />
<CardContent>
<TextField
style={{ marginBottom: '20px' }}
label="Enter your email"
fullWidth={true}
error={!!this.dirtyInputError('email')}
helperText={this.dirtyInputError('email')}
{...bindInput('email')}
/>
<Control name="email" shouldValidate={false} {...this.props}>
<TextField
style={{ marginBottom: '20px' }}
label="Enter your email"
fullWidth={true}
error={!!this.dirtyInputError('email')}
helperText={this.dirtyInputError('email')}
/>
</Control>
<TextField
type="password"
style={{ marginBottom: '20px' }}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "form-container",
"version": "0.2.4",
"version": "0.2.5-rc1",
"engines": {
"node": ">=6.0.0"
},
Expand Down
49 changes: 45 additions & 4 deletions src/FormContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import * as React from 'react';
import { pipe, isNil } from './utils';

import * as validation from './validate';
import { IFormConfig, IBoundInput } from './interfaces';
import { IFormConfig, IBoundInput, IFormProps } from './interfaces';
import { ReactElement } from 'react';

const hoistNonReactStatics = require('hoist-non-react-statics');

Expand All @@ -13,7 +14,8 @@ const makeWrapper = <T extends {}>(config: IFormConfig<T>) => (WrappedComponent:
this.state = {
model: config.initialModel || {},
touched: {},
inputs: {}
inputs: {},
shouldValidate: {}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we get away without using a wrapper component and just populate shouldValidate as each bound input is rendered?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what would be the best way to do it?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking we could have a side effect when invoking bindInput that could update the state to register the field. Then the validation would only occur on registered fields.

That would work, but it wouldn't be able to unregister the field on unmount so that's where I draw a blank.

I like the thinking behind the approach here, but it feels like too much boilerplate. I'm trying to think if there's another way. 🤔

Copy link
Owner Author

@vitkon vitkon Mar 28, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is HOC the boilerplate you're talking about?
From my POV it's the only solution that gives us full control (e.g. we can pass ref down of not based on the child type) and allows to stay declarative.

One other thing to consider: child input might have a locked down interface (e.g. with Typescript) and won't allow for any arbitrary props like shouldValidate

What's left is to move shouldValidate to validators, but it has less access to conditional logic in JSX that can be reused.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same concept in redux-form btw:
https://codesandbox.io/s/8zwvq2zr

so seems like I'm not inventing the wheel here ;)

};
}

Expand All @@ -33,6 +35,12 @@ const makeWrapper = <T extends {}>(config: IFormConfig<T>) => (WrappedComponent:
setFieldToTouched = (prop: keyof T) =>
this.setTouched(Object.assign({}, this.state.touched, { [prop]: true }));

setShouldValidate = (prop: keyof T, isSet: boolean = true) => {
const shouldValidate = Object.assign({}, this.state.shouldValidate, { [prop]: isSet });
this.setState({ shouldValidate });
return shouldValidate;
};

getValue = (name: keyof T) => {
const { state: { model: { [name]: modelValue } } } = this;

Expand Down Expand Up @@ -97,15 +105,17 @@ const makeWrapper = <T extends {}>(config: IFormConfig<T>) => (WrappedComponent:
form: {
model: this.state.model,
inputs: this.state.inputs,
touched: this.state.touched
touched: this.state.touched,
shouldValidate: this.state.shouldValidate
},
formMethods: {
bindInput: this.bindInput,
bindNativeInput: this.bindNativeInput,
bindToChangeEvent: this.bindToChangeEvent,
setProperty: this.setProperty,
setModel: this.setModel,
setFieldToTouched: this.setFieldToTouched
setFieldToTouched: this.setFieldToTouched,
setShouldValidate: this.setShouldValidate
}
});

Expand All @@ -122,3 +132,34 @@ export const connectForm = <T extends {} = any>(
validators: any[] = [],
config: IFormConfig<T> = {}
) => (Component: any) => pipe(validation.validate(validators), makeWrapper<T>(config))(Component);

export interface IControlProps extends IFormProps {
name: string;
shouldValidate?: boolean;
}

export class Control extends React.Component<IControlProps, {}> {
componentDidMount() {
this.setShouldValidate();
}

componentWillReceiveProps(nextProps: IControlProps) {
if (nextProps.shouldValidate !== this.props.shouldValidate) {
this.setShouldValidate();
}
}

private setShouldValidate() {
const { shouldValidate, formMethods } = this.props;
if (shouldValidate !== undefined) {
formMethods.setShouldValidate(this.props.name, shouldValidate);
}
}

render() {
const { name, formMethods: { bindInput } } = this.props;
return React.Children.map(this.props.children, child =>
React.cloneElement(child as ReactElement<any>, { ...bindInput(name) })
);
}
}
80 changes: 57 additions & 23 deletions src/__tests__/validate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@ import { mount } from 'enzyme';
import * as validation from '../validate';
import { ValidationType, Condition } from '../interfaces';
import { ValidationRuleFactory } from '../validators';
import { Control } from '../FormContainer';
const hoistNonReactStatics = require('hoist-non-react-statics');

const isRequired: Condition = value => !!value;
const required = ValidationRuleFactory(isRequired, 'This field is required');

const initialFormProps = {
model: {},
shouldValidate: {},
validationErrors: {},
validationWarnings: {}
};

describe('Validation', () => {
describe('validate error validator', () => {
it('should return a valid result of validationFn execution', () => {
Expand All @@ -17,24 +25,17 @@ describe('Validation', () => {
<input {...bindInput('foo')} />
</form>
);
const props = {
form: {
model: {
foo: 'test'
}
}
};
const props = { form: { ...initialFormProps, model: { foo: 'test' } } };
const result = validation.validate([required('foo', 'Required field')])(
MockComponent as any
)(props);
expect(result.props).toEqual({
form: {
...initialFormProps,
isValid: true,
model: {
foo: 'test'
},
validationErrors: {},
validationWarnings: {}
}
}
});
});
Expand All @@ -47,6 +48,7 @@ describe('Validation', () => {
);
const props = {
form: {
...initialFormProps,
model: {
foo: ''
}
Expand All @@ -57,14 +59,14 @@ describe('Validation', () => {
)(props);
expect(result.props).toEqual({
form: {
...initialFormProps,
isValid: false,
model: {
foo: ''
},
validationErrors: {
foo: 'Required field'
},
validationWarnings: {}
}
}
});
});
Expand All @@ -77,24 +79,17 @@ describe('Validation', () => {
<input {...bindInput('foo')} />
</form>
);
const props = {
form: {
model: {
foo: 'test'
}
}
};
const props = { form: { ...initialFormProps, model: { foo: 'test' } } };
const result = validation.validate([
required('foo', 'Required field', ValidationType.Warning)
])(MockComponent as any)(props);
expect(result.props).toEqual({
form: {
...initialFormProps,
isValid: true,
model: {
foo: 'test'
},
validationErrors: {},
validationWarnings: {}
}
}
});
});
Expand All @@ -107,6 +102,7 @@ describe('Validation', () => {
);
const props = {
form: {
...initialFormProps,
model: {
foo: ''
}
Expand All @@ -117,16 +113,54 @@ describe('Validation', () => {
])(MockComponent as any)(props);
expect(result.props).toEqual({
form: {
...initialFormProps,
isValid: true,
model: {
foo: ''
},
validationErrors: {},
validationWarnings: {
foo: 'Required field'
}
}
});
});

it('should conditionally validate', () => {
const MockComponent = (props: any) => {
const { formMethods: { bindInput }, form } = props;
return (
<form>
<Control name="foo" shouldValidate={false} {...props}>
<input />
</Control>
</form>
);
};
const props = {
form: {
...initialFormProps,
model: {
foo: ''
},
shouldValidate: {
foo: false
}
}
};

const result = validation.validate([required('foo')])(MockComponent as any)(props);
expect(result.props).toEqual({
form: {
...initialFormProps,
isValid: true,
model: {
foo: ''
},
shouldValidate: {
foo: false
}
}
});
});
});
});
1 change: 1 addition & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface IFormMethods<T = any> {
setProperty: (prop: keyof T, value: T[keyof T]) => any;
setModel: (model: { [name in keyof T]?: any }) => any;
setFieldToTouched: (prop: keyof T) => any;
setShouldValidate: (prop: keyof T, isSet: boolean) => any;
}

export interface IFormProps<T = any> {
Expand Down
2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export { connectForm } from './FormContainer';
export { connectForm, Control } from './FormContainer';
export { IFormProps, IFormConfig, ValidationRule, ValidationType } from './interfaces';
export { ValidationRuleFactory } from './validators';
7 changes: 7 additions & 0 deletions src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ const getValidationResult = ({
rules
.filter(([rule, field, type = ValidationType.Error]) => type === validationType)
.reduce((errors, [rule, field, type]) => {
const fieldKey = Object.keys(field)[0];
const shouldValidate = allProps.form.shouldValidate[fieldKey];

if (shouldValidate === false) {
return errors; // skip further validation
}

const isValid = rule(model, allProps);

if (isValid) {
Expand Down