Add withForm HOC, as well as RegisterForm
This commit is contained in:
parent
428630072c
commit
5a34885605
@ -12,5 +12,6 @@ module.exports = {
|
|||||||
'js',
|
'js',
|
||||||
'jsx'
|
'jsx'
|
||||||
],
|
],
|
||||||
setupFiles: ['<rootDir>/jest.setup.js']
|
setupFiles: ['<rootDir>/jest.setup.js'],
|
||||||
|
verbose: false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React from 'react'
|
|||||||
|
|
||||||
export interface IInputProps {
|
export interface IInputProps {
|
||||||
name: string
|
name: string
|
||||||
type: 'text' | 'password' | 'hidden' | 'submit'
|
type: 'text' | 'password' | 'hidden' | 'submit' | 'email'
|
||||||
value?: string
|
value?: string
|
||||||
onChange?: (name: this['name'], value: string) => void
|
onChange?: (name: this['name'], value: string) => void
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
|||||||
@ -10,6 +10,10 @@ export enum LoginActionKeys {
|
|||||||
USER_LOG_OUT = 'USER_LOG_OUT',
|
USER_LOG_OUT = 'USER_LOG_OUT',
|
||||||
USER_LOG_OUT_PENDING = 'USER_LOG_OUT_PENDING',
|
USER_LOG_OUT_PENDING = 'USER_LOG_OUT_PENDING',
|
||||||
USER_LOG_OUT_REJECTED = 'USER_LOG_OUT_REJECTED',
|
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 {
|
export class LoginActions {
|
||||||
@ -37,6 +41,22 @@ export class LoginActions {
|
|||||||
type: LoginActionKeys.USER_LOG_OUT,
|
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.
|
// This makes it very easy to write reducer code.
|
||||||
|
|||||||
@ -1,9 +1,16 @@
|
|||||||
|
import {Connector} from '../redux/Connector'
|
||||||
|
import {ICredentials} from '@rondo/common'
|
||||||
import {ILoginState} from './LoginReducer'
|
import {ILoginState} from './LoginReducer'
|
||||||
|
import {IStateSelector} from '../redux'
|
||||||
import {LoginActions} from './LoginActions'
|
import {LoginActions} from './LoginActions'
|
||||||
import {LoginForm} from './LoginForm'
|
import {LoginForm} from './LoginForm'
|
||||||
import {bindActionCreators} from 'redux'
|
import {bindActionCreators} from 'redux'
|
||||||
import {IStateSelector} from '../redux'
|
import {withForm} from './withForm'
|
||||||
import {Connector} from '../redux/Connector'
|
|
||||||
|
const defaultCredentials: ICredentials = {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
}
|
||||||
|
|
||||||
export class LoginConnector extends Connector {
|
export class LoginConnector extends Connector {
|
||||||
|
|
||||||
@ -21,7 +28,7 @@ export class LoginConnector extends Connector {
|
|||||||
dispatch => ({
|
dispatch => ({
|
||||||
onSubmit: bindActionCreators(this.loginActions.logIn, dispatch),
|
onSubmit: bindActionCreators(this.loginActions.logIn, dispatch),
|
||||||
}),
|
}),
|
||||||
LoginForm,
|
withForm(LoginForm, defaultCredentials),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,55 +1,33 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {Input} from '../components/Input'
|
|
||||||
import {ICredentials} from '@rondo/common'
|
import {ICredentials} from '@rondo/common'
|
||||||
|
import {Input} from '../components/Input'
|
||||||
|
|
||||||
export interface ILoginFormProps {
|
export interface ILoginFormProps {
|
||||||
error?: string
|
error?: string
|
||||||
onSubmit: (credentials: ICredentials) => Promise<void>
|
onSubmit: () => void
|
||||||
onSuccess?: () => 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
|
// TODO maybe replace this with Formik, which is recommended in React docs
|
||||||
// https://jaredpalmer.com/formik/docs/overview
|
// https://jaredpalmer.com/formik/docs/overview
|
||||||
export class LoginForm extends React.PureComponent<
|
export class LoginForm extends React.PureComponent<ILoginFormProps> {
|
||||||
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>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<form onSubmit={this.handleSubmit}>
|
<form onSubmit={this.props.onSubmit}>
|
||||||
<p className='error'>{this.props.error}</p>
|
<p className='error'>{this.props.error}</p>
|
||||||
<Input
|
<Input
|
||||||
name='username'
|
name='username'
|
||||||
type='text'
|
type='text'
|
||||||
onChange={this.handleChange}
|
onChange={this.props.onChange}
|
||||||
value={this.state.username}
|
value={this.props.data.username}
|
||||||
placeholder='Username'
|
placeholder='Username'
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
name='password'
|
name='password'
|
||||||
type='password'
|
type='password'
|
||||||
onChange={this.handleChange}
|
onChange={this.props.onChange}
|
||||||
value={this.state.password}
|
value={this.props.data.password}
|
||||||
placeholder='Password'
|
placeholder='Password'
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@ -17,11 +17,15 @@ export function Login(
|
|||||||
): ILoginState {
|
): ILoginState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case LoginActionKeys.USER_LOG_IN:
|
case LoginActionKeys.USER_LOG_IN:
|
||||||
return {...state, user: action.payload}
|
return {...state, user: action.payload, error: ''}
|
||||||
case LoginActionKeys.USER_LOG_OUT:
|
case LoginActionKeys.USER_LOG_OUT:
|
||||||
return {...state, user: undefined}
|
return {...state, user: undefined}
|
||||||
case LoginActionKeys.USER_LOG_IN_REJECTED:
|
case LoginActionKeys.USER_LOG_IN_REJECTED:
|
||||||
return {...state, error: action.error.message}
|
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:
|
default:
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|||||||
33
packages/client/src/login/RegisterConnector.tsx
Normal file
33
packages/client/src/login/RegisterConnector.tsx
Normal 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
72
packages/client/src/login/RegisterForm.test.tsx
Normal file
72
packages/client/src/login/RegisterForm.test.tsx
Normal 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
39
packages/client/src/login/RegisterForm.tsx
Normal file
39
packages/client/src/login/RegisterForm.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,3 +2,5 @@ export * from './LoginActions'
|
|||||||
export * from './LoginConnector'
|
export * from './LoginConnector'
|
||||||
export * from './LoginForm'
|
export * from './LoginForm'
|
||||||
export * from './LoginReducer'
|
export * from './LoginReducer'
|
||||||
|
export * from './RegisterForm'
|
||||||
|
export * from './RegisterConnector'
|
||||||
|
|||||||
61
packages/client/src/login/withForm.tsx
Normal file
61
packages/client/src/login/withForm.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ export interface IAPIDef {
|
|||||||
'/auth/register': {
|
'/auth/register': {
|
||||||
'post': {
|
'post': {
|
||||||
body: ICredentials
|
body: ICredentials
|
||||||
|
response: IUser
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
'/auth/login': {
|
'/auth/login': {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user