Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add form input styles #21

Closed
wants to merge 3 commits into from
Closed
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@
"redux": "^3.7.2",
"redux-devtools-extension": "^2.13.2",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.2.0"
"redux-thunk": "^2.2.0",
"styled-components": "^2.2.1"
},
"jest": {
"collectCoverage": true,
Expand Down
51 changes: 51 additions & 0 deletions src/components/Field.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import PropTypes from 'prop-types';
import React from 'react';
import styled from 'styled-components';

import Input from './Input';


export const StyledLabel = styled.label`
display: block;
font-weight: bold;
line-height: 1.618;
`;


/**
* A wrapper around a form input and label.
*
* @param {string} props.label A descriptive label for the field.
* @param {string} props.name The input field's name.
* @param {string[]} [props.errors] A list of errors for the field.
* @param {string} [props.id] The ID to give the input. Defaults to the input's name.
* @param {...object} [props.extraProps] Additional props to pass to the input component.
*/
const Field = ({ errors, id, label, name, ...extraProps }) => (
<div>
<StyledLabel htmlFor={id || name}>{label}</StyledLabel>
<Input id={id || name} name={name} {...extraProps} />
{errors.length > 0 && (
<ul>
{errors.map(error => (
<li key={error}>{error}</li>
))}
</ul>
)}
</div>
);

Field.defaultProps = {
errors: [],
id: '',
};

Field.propTypes = {
errors: PropTypes.arrayOf(PropTypes.string),
id: PropTypes.string,
label: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
};


export default Field;
35 changes: 35 additions & 0 deletions src/components/Input.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import PropTypes from 'prop-types';
import React from 'react';
import styled from 'styled-components';


export const StyledInput = styled.input`
border: 1px solid #e2e2e2;
border-radius: 3px;
display: block;
padding: .5em;
width: 100%;

&:focus {
border-color: #b7eaff;
outline: none;
}
`;


/**
* Component for accepting text input.
*
* @param {string} props.name The name to give the input field.
* @param {...object} [props.extraProps] Any additional props to pass to the input field.
*/
const Input = ({ name, ...extraProps }) => (
<StyledInput name={name} {...extraProps} />
);

Input.propTypes = {
name: PropTypes.string.isRequired,
};


export default Input;
27 changes: 10 additions & 17 deletions src/components/RegistrationForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { connect } from 'react-redux';
import { Redirect } from 'react-router-dom';

import { register } from '../actionCreators';
import Field from './Field';
import { getRegistrationComplete, getRegistrationErrors, getRegistrationLoading } from '../selectors';


Expand Down Expand Up @@ -39,33 +40,25 @@ export class RegistrationForm extends React.Component {

return (
<form onSubmit={this.handleSubmit}>
<label htmlFor="email">Email:</label>
<input
<Field
disabled={this.props.isLoading}
id="email"
errors={this.props.errors.email}
label="Email"
name="email"
onChange={this.handleInputChange}
placeholder="[email protected]"
type="email"
/>
<ul>
{this.props.errors.email && this.props.errors.email.map(error => (
<li key={error}>{error}</li>
))}
</ul>

<label htmlFor="password">Password:</label>
<input

<Field
disabled={this.props.isLoading}
errors={this.props.errors.password}
id="password"
label="Password"
name="password"
onChange={this.handleInputChange}
type="password"
/>
<ul>
{this.props.errors.password && this.props.errors.password.map(error => (
<li key={error}>{error}</li>
))}
</ul>

<button
disabled={this.props.isLoading}
Expand All @@ -87,7 +80,7 @@ RegistrationForm.defaultProps = {
RegistrationForm.propTypes = {
errors: PropTypes.shape({
email: PropTypes.arrayOf(PropTypes.string),
non_field_erros: PropTypes.arrayOf(PropTypes.string),
non_field_errors: PropTypes.arrayOf(PropTypes.string),
password: PropTypes.arrayOf(PropTypes.string),
}),
isComplete: PropTypes.bool,
Expand Down
85 changes: 85 additions & 0 deletions src/components/__tests__/Field.spec.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { shallow } from 'enzyme';
import React from 'react';

import Field, { StyledLabel } from '../Field';


const setup = ({ label = 'Foo', name = 'foo', ...rest } = {}) => {
const props = {
...rest,
label,
name,
};
const wrapper = shallow(<Field {...props} />);

return {
props,
wrapper,
};
};


describe('Field Component', () => {
it('should render a label', () => {
const { props, wrapper } = setup();
const label = wrapper.find(StyledLabel);

expect(label.children().text()).toBe(props.label);
});

it('should render an Input', () => {
const { props, wrapper } = setup();
const input = wrapper.find('Input');

expect(input.prop('name')).toBe(props.name);
});

it('should pass any extra props to the Input component', () => {
const extraProps = { foo: 'bar', bar: 'baz' };
const { wrapper } = setup(extraProps);
const input = wrapper.find('Input');

expect(input.props()).toMatchObject(extraProps);
});

it('should provide a default ID equivalent to the given name', () => {
const { props, wrapper } = setup();
const input = wrapper.find('Input');
const label = wrapper.find(StyledLabel);

expect(input.prop('id')).toBe(props.name);
expect(label.prop('htmlFor')).toBe(props.name);
});

it('should accept an ID and use it for the label and input', () => {
const id = 'custom-id';
const { wrapper } = setup({ id });
const input = wrapper.find('Input');
const label = wrapper.find(StyledLabel);

expect(input.prop('id')).toBe(id);
expect(label.prop('htmlFor')).toBe(id);
});

describe('Error Rendering', () => {
it('should not render an error list with no errors', () => {
const { wrapper } = setup();
const errorList = wrapper.find('ul');

expect(errorList).toHaveLength(0);
});

it('should map each error to a list item', () => {
const errors = ['Error 1', 'Error 2', 'Another error'];
const { wrapper } = setup({ errors });
const errorList = wrapper.find('ul');

expect(errorList).toHaveLength(1);

errors.forEach((error) => {
const node = errorList.findWhere(n => n.key() === error);
expect(node.text()).toBe(error);
});
});
});
});
39 changes: 39 additions & 0 deletions src/components/__tests__/Input.spec.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { shallow } from 'enzyme';
import React from 'react';

import Input, { StyledInput } from '../Input';


const setup = ({ name = 'foo', ...rest } = {}) => {
const props = {
...rest,
name,
};
const wrapper = shallow(<Input {...props} />);

return {
props,
wrapper,
};
};


describe('Input Component', () => {
it('should render a standard text input', () => {
const { props, wrapper } = setup();
const input = wrapper.find(StyledInput);

expect(input).toHaveLength(1);
expect(input.prop('name')).toBe(props.name);
});

it('should render any additional props passed to it', () => {
const extraProps = { foo: 'bar', bar: 'baz' };
const { wrapper } = setup(extraProps);
const input = wrapper.find(StyledInput);

Object.keys(extraProps).forEach((key) => {
expect(input.prop(key)).toBe(extraProps[key]);
});
});
});
73 changes: 31 additions & 42 deletions src/components/__tests__/RegistrationForm.spec.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,16 @@ describe('RegistrationForm', () => {
expect(wrapper.state('password')).toBe('');
});

it('should call its onSubmit prop when submitted', () => {
const userData = { email: '[email protected]', password: 'password' };
const { props, wrapper } = setup();
wrapper.instance().state = userData;

const mockEvent = { preventDefault: jest.fn() };
it('should render an email and password field', () => {
const { wrapper } = setup();
const email = wrapper.find('Field[name="email"]');
const password = wrapper.find('Field[name="password"]');

wrapper.simulate('submit', mockEvent);
expect(email.prop('onChange')).toBe(wrapper.instance().handleInputChange);
expect(email.prop('type')).toBe('email');

expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(props.onSubmit).toHaveBeenCalledWith(userData);
expect(password.prop('onChange')).toBe(wrapper.instance().handleInputChange);
expect(password.prop('type')).toBe('password');
});

it('should render any provided errors', () => {
Expand All @@ -63,17 +62,11 @@ describe('RegistrationForm', () => {

const { wrapper } = setup({ errors });

const findErrors = (errorList) => {
errorList.forEach((error) => {
const errorWrapper = wrapper.findWhere(n => n.key() === error);

expect(errorWrapper).toHaveLength(1);
expect(errorWrapper.text()).toBe(error);
});
};
Object.keys(errors).forEach((field) => {
const fieldComponent = wrapper.find(`Field[name="${field}"]`);

findErrors(emailError);
findErrors(passwordError);
expect(fieldComponent.prop('errors')).toEqual(errors[field]);
});
});

it('should redirect to the dashboard if complete', () => {
Expand All @@ -88,7 +81,7 @@ describe('RegistrationForm', () => {
const { wrapper } = setup({ isLoading: true });

// All inputs should be disabled
wrapper.find('input').forEach((node) => {
wrapper.find('Field').forEach((node) => {
expect(node.prop('disabled')).toBe(true);
});

Expand All @@ -98,41 +91,37 @@ describe('RegistrationForm', () => {
});
});

describe('input handlers', () => {
it('should have a handler to update email state', () => {
describe('Event Handlers', () => {
it('should update its state when an input changes', () => {
const { wrapper } = setup();
const emailWrapper = wrapper.find('input[name="email"]');

const newEmail = '[email protected]';

emailWrapper.simulate('change', {
const mockChange = {
target: {
name: 'email',
value: newEmail,
name: 'foo',
value: 'bar',
},
});
};

expect(wrapper.state('email')).toBe(newEmail);
wrapper.instance().handleInputChange(mockChange);

expect(wrapper.state(mockChange.target.name)).toBe(mockChange.target.value);
});

it('should have a handler to update password state', () => {
const { wrapper } = setup();
const passwordWrapper = wrapper.find('input[name="password"]');
it('should call its onSubmit prop when submitted', () => {
const userData = { email: '[email protected]', password: 'password' };
const { props, wrapper } = setup();
wrapper.instance().state = userData;

const newPassword = 'password';
const mockEvent = { preventDefault: jest.fn() };

passwordWrapper.simulate('change', {
target: {
name: 'password',
value: newPassword,
},
});
wrapper.simulate('submit', mockEvent);

expect(wrapper.state('password')).toBe(newPassword);
expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(props.onSubmit).toHaveBeenCalledWith(userData);
});
});

describe('redux connections', () => {
describe('Redux Connections', () => {
describe('mapStateToProps', () => {
it('should pass any form errors from state as props', () => {
const errors = { email: ['Invalid email.'] };
Expand Down
Loading