feat(client): use React Final Form instead of Redux Form (#36742)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { reduxForm } from 'redux-form';
|
||||
import { Form } from 'react-final-form';
|
||||
|
||||
import {
|
||||
FormFields,
|
||||
@@ -12,16 +12,7 @@ import {
|
||||
const propTypes = {
|
||||
buttonText: PropTypes.string,
|
||||
enableSubmit: PropTypes.bool,
|
||||
errors: PropTypes.object,
|
||||
fields: PropTypes.objectOf(
|
||||
PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
value: PropTypes.string.isRequired
|
||||
})
|
||||
),
|
||||
formFields: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
handleSubmit: PropTypes.func,
|
||||
hideButton: PropTypes.bool,
|
||||
id: PropTypes.string.isRequired,
|
||||
initialValues: PropTypes.object,
|
||||
@@ -33,67 +24,44 @@ const propTypes = {
|
||||
submit: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export function DynamicForm({
|
||||
// redux-form
|
||||
errors,
|
||||
fields,
|
||||
handleSubmit,
|
||||
fields: {
|
||||
// eslint-disable-next-line react/prop-types
|
||||
_meta: { allPristine }
|
||||
},
|
||||
|
||||
// HOC
|
||||
function DynamicForm({
|
||||
id,
|
||||
formFields,
|
||||
initialValues,
|
||||
options,
|
||||
submit,
|
||||
buttonText,
|
||||
enableSubmit,
|
||||
hideButton,
|
||||
id,
|
||||
options,
|
||||
submit
|
||||
hideButton
|
||||
}) {
|
||||
return (
|
||||
<form
|
||||
id={`dynamic-${id}`}
|
||||
onSubmit={handleSubmit((values, ...args) =>
|
||||
<Form
|
||||
initialValues={initialValues}
|
||||
onSubmit={(values, ...args) =>
|
||||
submit(formatUrlValues(values, options), ...args)
|
||||
)}
|
||||
style={{ width: '100%' }}
|
||||
}
|
||||
>
|
||||
<FormFields
|
||||
errors={errors}
|
||||
fields={fields}
|
||||
formId={id}
|
||||
options={options}
|
||||
/>
|
||||
<BlockSaveWrapper>
|
||||
{hideButton ? null : (
|
||||
<BlockSaveButton
|
||||
disabled={
|
||||
(allPristine && !enableSubmit) ||
|
||||
!!Object.keys(errors).filter(key => errors[key]).length
|
||||
}
|
||||
>
|
||||
{buttonText ? buttonText : null}
|
||||
</BlockSaveButton>
|
||||
)}
|
||||
</BlockSaveWrapper>
|
||||
</form>
|
||||
{({ handleSubmit, pristine, error }) => (
|
||||
<form
|
||||
id={`dynamic-${id}`}
|
||||
onSubmit={handleSubmit}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<FormFields fields={formFields} options={options} />
|
||||
<BlockSaveWrapper>
|
||||
{hideButton ? null : (
|
||||
<BlockSaveButton disabled={(pristine && !enableSubmit) || error}>
|
||||
{buttonText ? buttonText : null}
|
||||
</BlockSaveButton>
|
||||
)}
|
||||
</BlockSaveWrapper>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
DynamicForm.displayName = 'DynamicForm';
|
||||
DynamicForm.propTypes = propTypes;
|
||||
|
||||
const DynamicFormWithRedux = reduxForm()(DynamicForm);
|
||||
|
||||
export default function Form(props) {
|
||||
return (
|
||||
<DynamicFormWithRedux
|
||||
{...props}
|
||||
fields={props.formFields}
|
||||
form={props.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Form.propTypes = propTypes;
|
||||
export default DynamicForm;
|
||||
|
@@ -1,30 +1,14 @@
|
||||
/* global expect */
|
||||
/* global jest, expect */
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import Enzyme from 'enzyme';
|
||||
import Adapter from 'enzyme-adapter-react-16';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
|
||||
import { DynamicForm } from './Form';
|
||||
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
||||
import Form from './Form';
|
||||
|
||||
const defaultTestProps = {
|
||||
errors: {},
|
||||
fields: {
|
||||
_meta: {
|
||||
allPristine: true,
|
||||
name: 'name',
|
||||
onChange: () => {},
|
||||
value: ''
|
||||
}
|
||||
},
|
||||
handleSubmit: () => {},
|
||||
|
||||
buttonText: 'Submit',
|
||||
enableSubmit: true,
|
||||
formFields: ['name', 'website'],
|
||||
hideButton: false,
|
||||
id: 'my-test-form',
|
||||
options: {
|
||||
types: {
|
||||
@@ -36,8 +20,69 @@ const defaultTestProps = {
|
||||
submit: () => {}
|
||||
};
|
||||
|
||||
test('<DynamicForm /> snapshot', () => {
|
||||
const component = renderer.create(<DynamicForm {...defaultTestProps} />);
|
||||
let tree = component.toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
test('should render', () => {
|
||||
const { getByLabelText, getByText } = render(<Form {...defaultTestProps} />);
|
||||
|
||||
const nameInput = getByLabelText(/name/i);
|
||||
expect(nameInput).not.toBeRequired();
|
||||
expect(nameInput).toHaveAttribute('type', 'text');
|
||||
|
||||
const websiteInput = getByLabelText(/website/i);
|
||||
expect(websiteInput).toBeRequired();
|
||||
expect(websiteInput).toHaveAttribute('type', 'url');
|
||||
|
||||
const button = getByText(/submit/i);
|
||||
expect(button).toHaveAttribute('type', 'submit');
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should render with default values', () => {
|
||||
const websiteValue = 'http://mysite.com';
|
||||
const nameValue = 'John';
|
||||
|
||||
const { getByLabelText, getByText } = render(
|
||||
<Form
|
||||
{...defaultTestProps}
|
||||
enableSubmit={true}
|
||||
initialValues={{ name: nameValue, website: websiteValue }}
|
||||
/>
|
||||
);
|
||||
|
||||
const nameInput = getByLabelText(/name/i);
|
||||
expect(nameInput).toHaveValue(nameValue);
|
||||
|
||||
const websiteInput = getByLabelText(/website/i);
|
||||
expect(websiteInput).toHaveValue(websiteValue);
|
||||
|
||||
const button = getByText(/submit/i);
|
||||
expect(button).toBeEnabled();
|
||||
});
|
||||
|
||||
test('should submit', () => {
|
||||
const submit = jest.fn();
|
||||
const props = {
|
||||
...defaultTestProps,
|
||||
submit
|
||||
};
|
||||
const websiteValue = 'http://mysite.com';
|
||||
|
||||
const { getByLabelText, getByText } = render(<Form {...props} />);
|
||||
|
||||
const websiteInput = getByLabelText(/website/i);
|
||||
fireEvent.change(websiteInput, { target: { value: websiteValue } });
|
||||
expect(websiteInput).toHaveValue(websiteValue);
|
||||
|
||||
const button = getByText(/submit/i);
|
||||
expect(button).toBeEnabled();
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(submit).toHaveBeenCalledTimes(1);
|
||||
expect(submit.mock.calls[0][0]).toEqual({ website: websiteValue });
|
||||
|
||||
fireEvent.change(websiteInput, { target: { value: `${websiteValue}///` } });
|
||||
expect(websiteInput).toHaveValue(`${websiteValue}///`);
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(submit).toHaveBeenCalledTimes(2);
|
||||
expect(submit.mock.calls[1][0]).toEqual({ website: websiteValue });
|
||||
});
|
||||
|
@@ -9,20 +9,11 @@ import {
|
||||
FormGroup,
|
||||
HelpBlock
|
||||
} from '@freecodecamp/react-bootstrap';
|
||||
import { Field } from 'react-final-form';
|
||||
|
||||
const propTypes = {
|
||||
errors: PropTypes.objectOf(PropTypes.string),
|
||||
fields: PropTypes.objectOf(
|
||||
PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
value: PropTypes.string.isRequired
|
||||
})
|
||||
).isRequired,
|
||||
fields: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired,
|
||||
options: PropTypes.shape({
|
||||
errors: PropTypes.objectOf(
|
||||
PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(null)])
|
||||
),
|
||||
ignored: PropTypes.arrayOf(PropTypes.string),
|
||||
placeholder: PropTypes.bool,
|
||||
required: PropTypes.arrayOf(PropTypes.string),
|
||||
@@ -31,7 +22,7 @@ const propTypes = {
|
||||
};
|
||||
|
||||
function FormFields(props) {
|
||||
const { errors = {}, fields, options = {} } = props;
|
||||
const { fields, options = {} } = props;
|
||||
const {
|
||||
ignored = [],
|
||||
placeholder = true,
|
||||
@@ -40,38 +31,43 @@ function FormFields(props) {
|
||||
} = options;
|
||||
return (
|
||||
<div>
|
||||
{Object.keys(fields)
|
||||
{fields
|
||||
.filter(field => !ignored.includes(field))
|
||||
.map(key => fields[key])
|
||||
.map(({ name, onChange, value, pristine }) => {
|
||||
const key = kebabCase(name);
|
||||
const type = name in types ? types[name] : 'text';
|
||||
return (
|
||||
<Col key={key} xs={12}>
|
||||
<FormGroup>
|
||||
{type === 'hidden' ? null : (
|
||||
<ControlLabel htmlFor={key}>{startCase(name)}</ControlLabel>
|
||||
)}
|
||||
<FormControl
|
||||
componentClass={type === 'textarea' ? type : 'input'}
|
||||
id={key}
|
||||
name={name}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder ? name : ''}
|
||||
required={required.includes(name)}
|
||||
rows={4}
|
||||
type={type}
|
||||
value={value}
|
||||
/>
|
||||
{name in errors && !pristine ? (
|
||||
<HelpBlock>
|
||||
<Alert bsStyle='danger'>{errors[name]}</Alert>
|
||||
</HelpBlock>
|
||||
) : null}
|
||||
</FormGroup>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
.map(name => (
|
||||
<Field key={`${name}-field`} name={name}>
|
||||
{({ input: { value, onChange }, meta: { pristine, error } }) => {
|
||||
const key = kebabCase(name);
|
||||
const type = name in types ? types[name] : 'text';
|
||||
return (
|
||||
<Col key={key} xs={12}>
|
||||
<FormGroup>
|
||||
{type === 'hidden' ? null : (
|
||||
<ControlLabel htmlFor={key}>
|
||||
{startCase(name)}
|
||||
</ControlLabel>
|
||||
)}
|
||||
<FormControl
|
||||
componentClass={type === 'textarea' ? type : 'input'}
|
||||
id={key}
|
||||
name={name}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder ? name : ''}
|
||||
required={required.includes(name)}
|
||||
rows={4}
|
||||
type={type}
|
||||
value={value}
|
||||
/>
|
||||
{error && !pristine ? (
|
||||
<HelpBlock>
|
||||
<Alert bsStyle='danger'>{error}</Alert>
|
||||
</HelpBlock>
|
||||
) : null}
|
||||
</FormGroup>
|
||||
</Col>
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -1,55 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<DynamicForm /> snapshot 1`] = `
|
||||
<form
|
||||
id="dynamic-my-test-form"
|
||||
style={
|
||||
Object {
|
||||
"width": "100%",
|
||||
}
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="col-xs-12"
|
||||
>
|
||||
<div
|
||||
className="form-group"
|
||||
>
|
||||
<label
|
||||
className="control-label"
|
||||
htmlFor="name"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="name"
|
||||
name="name"
|
||||
onChange={[Function]}
|
||||
placeholder="name"
|
||||
required={false}
|
||||
rows={4}
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"padding": "0 15px",
|
||||
}
|
||||
}
|
||||
>
|
||||
<button
|
||||
className="btn btn-primary btn-block"
|
||||
disabled={false}
|
||||
type="submit"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
@@ -18,7 +18,7 @@ export function callIfDefined(fn) {
|
||||
export function formatUrlValues(values, options) {
|
||||
return Object.keys(values).reduce((result, key) => {
|
||||
let value = values[key];
|
||||
if (options.types[key] === 'url') {
|
||||
if (value && options.types[key] === 'url') {
|
||||
value = normalizeUrl(value, normalizeOptions);
|
||||
}
|
||||
return { ...result, [key]: value };
|
||||
|
Reference in New Issue
Block a user