Add withForm HOC, as well as RegisterForm

This commit is contained in:
Jerko Steiner 2019-03-17 15:04:39 +05:00
parent 428630072c
commit 5a34885605
12 changed files with 256 additions and 38 deletions

View File

@ -12,5 +12,6 @@ module.exports = {
'js',
'jsx'
],
setupFiles: ['<rootDir>/jest.setup.js']
setupFiles: ['<rootDir>/jest.setup.js'],
verbose: false
}

View File

@ -2,7 +2,7 @@ import React from 'react'
export interface IInputProps {
name: string
type: 'text' | 'password' | 'hidden' | 'submit'
type: 'text' | 'password' | 'hidden' | 'submit' | 'email'
value?: string
onChange?: (name: this['name'], value: string) => void
placeholder?: string

View File

@ -10,6 +10,10 @@ export enum LoginActionKeys {
USER_LOG_OUT = 'USER_LOG_OUT',
USER_LOG_OUT_PENDING = 'USER_LOG_OUT_PENDING',
USER_LOG_OUT_REJECTED = 'USER_LOG_OUT_REJECTED',
REGISTER_USER = 'REGISTER_USER',
REGISTER_USER_PENDING = 'REGISTER_USER_PENDING',
REGISTER_USER_REJECTED = 'REGISTER_USER_REJECTED',
}
export class LoginActions {
@ -37,6 +41,22 @@ export class LoginActions {
type: LoginActionKeys.USER_LOG_OUT,
}
}
register = (profile: ICredentials):
IAction<IUser, LoginActionKeys.REGISTER_USER> => {
return {
payload: this.http.post('/auth/register', profile),
type: LoginActionKeys.REGISTER_USER,
}
}
registerError = (error: Error)
: IErrorAction<LoginActionKeys.REGISTER_USER_REJECTED> => {
return {
error,
type: LoginActionKeys.REGISTER_USER_REJECTED,
}
}
}
// This makes it very easy to write reducer code.

View File

@ -1,9 +1,16 @@
import {Connector} from '../redux/Connector'
import {ICredentials} from '@rondo/common'
import {ILoginState} from './LoginReducer'
import {IStateSelector} from '../redux'
import {LoginActions} from './LoginActions'
import {LoginForm} from './LoginForm'
import {bindActionCreators} from 'redux'
import {IStateSelector} from '../redux'
import {Connector} from '../redux/Connector'
import {withForm} from './withForm'
const defaultCredentials: ICredentials = {
username: '',
password: '',
}
export class LoginConnector extends Connector {
@ -21,7 +28,7 @@ export class LoginConnector extends Connector {
dispatch => ({
onSubmit: bindActionCreators(this.loginActions.logIn, dispatch),
}),
LoginForm,
withForm(LoginForm, defaultCredentials),
)
}
}

View File

@ -1,55 +1,33 @@
import React from 'react'
import {Input} from '../components/Input'
import {ICredentials} from '@rondo/common'
import {Input} from '../components/Input'
export interface ILoginFormProps {
error?: string
onSubmit: (credentials: ICredentials) => Promise<void>
onSuccess?: () => void
onSubmit: () => void
onChange: (name: string, value: string) => void
data: ICredentials
}
export interface ILoginFormState extends ICredentials {}
// TODO maybe replace this with Formik, which is recommended in React docs
// https://jaredpalmer.com/formik/docs/overview
export class LoginForm extends React.PureComponent<
ILoginFormProps, ILoginFormState
> {
constructor(props: ILoginFormProps) {
super(props)
this.state = {
username: '',
password: '',
}
}
handleSubmit = async () => {
const {onSuccess} = this.props
await this.props.onSubmit(this.state)
if (onSuccess) {
onSuccess()
}
}
handleChange = (name: string, value: string) => {
this.setState(
{[name]: value} as Pick<ILoginFormState, keyof ILoginFormState>,
)
}
export class LoginForm extends React.PureComponent<ILoginFormProps> {
render() {
return (
<form onSubmit={this.handleSubmit}>
<form onSubmit={this.props.onSubmit}>
<p className='error'>{this.props.error}</p>
<Input
name='username'
type='text'
onChange={this.handleChange}
value={this.state.username}
onChange={this.props.onChange}
value={this.props.data.username}
placeholder='Username'
/>
<Input
name='password'
type='password'
onChange={this.handleChange}
value={this.state.password}
onChange={this.props.onChange}
value={this.props.data.password}
placeholder='Password'
/>
<Input

View File

@ -17,11 +17,15 @@ export function Login(
): ILoginState {
switch (action.type) {
case LoginActionKeys.USER_LOG_IN:
return {...state, user: action.payload}
return {...state, user: action.payload, error: ''}
case LoginActionKeys.USER_LOG_OUT:
return {...state, user: undefined}
case LoginActionKeys.USER_LOG_IN_REJECTED:
return {...state, error: action.error.message}
case LoginActionKeys.REGISTER_USER:
return {...state, user: action.payload, error: ''}
case LoginActionKeys.REGISTER_USER_REJECTED:
return {...state, error: action.error.message}
default:
return state
}

View File

@ -0,0 +1,33 @@
import {Connector} from '../redux/Connector'
import {ICredentials} from '@rondo/common'
import {ILoginState} from './LoginReducer'
import {IStateSelector} from '../redux'
import {LoginActions} from './LoginActions'
import {RegisterForm} from './RegisterForm'
import {bindActionCreators} from 'redux'
import {withForm} from './withForm'
const defaultCredentials: ICredentials = {
username: '',
password: '',
}
export class RegisterConnector extends Connector {
constructor(protected readonly loginActions: LoginActions) {
super()
}
connect<State>(getLocalState: IStateSelector<State, ILoginState>) {
return this.wrap(
getLocalState,
state => ({
error: state.error,
}),
dispatch => ({
onSubmit: bindActionCreators(this.loginActions.register, dispatch),
}),
withForm(RegisterForm, defaultCredentials),
)
}
}

View File

@ -0,0 +1,72 @@
import * as Feature from './'
import {HTTPClientMock, TestUtils, getError} from '../test-utils'
import {IAPIDef} from '@rondo/common'
import T from 'react-dom/test-utils'
const test = new TestUtils()
describe('RegisterForm', () => {
const http = new HTTPClientMock<IAPIDef>()
const loginActions = new Feature.LoginActions(http)
const t = test.withProvider({
reducers: {Login: Feature.Login},
state: {Login: {user: {id: 1}}},
connector: new Feature.RegisterConnector(loginActions),
select: state => state.Login,
})
it('should render', () => {
t.render()
})
describe('submit', () => {
const data = {username: 'user', password: 'pass'}
const onSuccess = jest.fn()
let node: Element
beforeEach(() => {
http.mockAdd({
method: 'post',
url: '/auth/register',
data,
}, {id: 123})
node = t.render({onSuccess}).node
T.Simulate.change(
node.querySelector('input[name="username"]')!,
{target: {value: 'user'}} as any,
)
T.Simulate.change(
node.querySelector('input[name="password"]')!,
{target: {value: 'pass'}} as any,
)
})
it('should submit a form', async () => {
T.Simulate.submit(node)
const {req} = await http.wait()
expect(req).toEqual({
method: 'post',
url: '/auth/register',
data,
})
expect(onSuccess.mock.calls.length).toBe(1)
})
it('sets the error message on failure', async () => {
http.mockAdd({
method: 'post',
url: '/auth/register',
data,
}, {error: 'test'}, 500)
T.Simulate.submit(node)
await getError(http.wait())
expect(node.querySelector('.error')!.textContent)
.toMatch(/HTTP Status: 500/i)
})
})
})

View File

@ -0,0 +1,39 @@
import React from 'react'
import {ICredentials} from '@rondo/common'
import {Input} from '../components/Input'
export interface IRegisterFormProps {
error?: string
onSubmit: () => void
onChange: (name: string, value: string) => void
data: ICredentials
}
export class RegisterForm extends React.PureComponent<IRegisterFormProps> {
render() {
return (
<form onSubmit={this.props.onSubmit}>
<p className='error'>{this.props.error}</p>
<Input
name='username'
type='email'
onChange={this.props.onChange}
value={this.props.data.username}
placeholder='Email'
/>
<Input
name='password'
type='password'
onChange={this.props.onChange}
value={this.props.data.password}
placeholder='Password'
/>
<Input
name='submit'
type='submit'
value='Register'
/>
</form>
)
}
}

View File

@ -2,3 +2,5 @@ export * from './LoginActions'
export * from './LoginConnector'
export * from './LoginForm'
export * from './LoginReducer'
export * from './RegisterForm'
export * from './RegisterConnector'

View File

@ -0,0 +1,61 @@
import React from 'react'
export interface IComponentProps<Data> {
onSubmit: () => void
onChange: (name: string, value: string) => void
// TODO clear data on successful submission. This is important to prevent
// passwords being accidentally rendered in the background.
data: Data
}
export interface IFormHOCProps<Data> {
onSubmit: (props: Data) => Promise<void>
// TODO figure out what would happen if the underlying child component
// would have the same required property as the HOC, like onSuccess?
onSuccess?: () => void
}
export function withForm<Data, Props extends IComponentProps<Data>>(
Component: React.ComponentType<Props>,
initialState: Data,
) {
type OtherProps = Pick<Props,
Exclude<keyof Props, keyof IComponentProps<Data>>>
type T = IFormHOCProps<Data> & OtherProps
return class FormHOC extends React.PureComponent<T, Data> {
constructor(props: T) {
super(props)
this.state = initialState
}
handleSubmit = async (e: React.FormEvent) => {
const {onSuccess} = this.props
e.preventDefault()
await this.props.onSubmit(this.state)
if (onSuccess) {
onSuccess()
}
}
handleChange = (name: string, value: string) => {
this.setState(
{[name]: value} as unknown as Pick<Data, keyof Data>,
)
}
render() {
const {children, onSuccess, onSubmit, ...otherProps} = this.props
// Casting otherProps to any because type inference does not work when
// combinig OtherProps and ChildProps back to Component:
// https://github.com/Microsoft/TypeScript/issues/28938
return (
<Component
{...otherProps as any}
data={this.state}
onSubmit={this.handleSubmit}
onChange={this.handleChange}
/>
)
}
}
}

View File

@ -7,6 +7,7 @@ export interface IAPIDef {
'/auth/register': {
'post': {
body: ICredentials
response: IUser
}
}
'/auth/login': {