Add TestUtils and a generic way to isolate connected components

This commit is contained in:
Jerko Steiner 2019-01-20 18:11:18 +01:00
parent 0fcd8cbb03
commit bdf0aa57be
21 changed files with 334 additions and 59 deletions

View File

@ -1,5 +1,9 @@
import React from 'react'
export interface IButtonProps {
type: string
}
export class Button extends React.PureComponent {
render() {
return (

View File

@ -2,15 +2,16 @@ import React from 'react'
export interface IInputProps {
name: string
type: 'text' | 'password' | 'hidden'
type: 'text' | 'password' | 'hidden' | 'submit'
value?: string
onChange?: (name: string, value: string) => void
onChange?: (name: this['name'], value: string) => void
readOnly?: boolean
}
export class Input extends React.PureComponent<IInputProps> {
handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (this.props.onChange) {
this.props.onChange(e.target.name, e.target.value)
this.props.onChange(this.props.name, e.target.value)
}
}
render() {
@ -19,6 +20,8 @@ export class Input extends React.PureComponent<IInputProps> {
name={this.props.name}
type={this.props.type}
value={this.props.value}
onChange={this.handleChange}
readOnly={!!this.props.readOnly}
/>
)
}

View File

@ -1,17 +1,30 @@
import assert from 'assert'
import axios, {AxiosInstance} from 'axios'
import axios from 'axios'
import {IHTTPClient} from './IHTTPClient'
import {IHeader} from './IHeader'
import {IMethod, IRoutes} from '@rondo/common'
import {IRequest} from './IRequest'
import {IResponse} from './IResponse'
import {ITypedRequestParams} from './ITypedRequestParams'
export class HTTPClient<T extends IRoutes> implements IHTTPClient<T> {
protected readonly axios: AxiosInstance
interface IRequestor {
request: (params: IRequest) => Promise<IResponse>
}
constructor(baseURL = '', headers?: IHeader) {
this.axios = axios.create({
baseURL,
headers,
export class HTTPClient<T extends IRoutes> implements IHTTPClient<T> {
protected readonly requestor: IRequestor
constructor(
protected readonly baseURL = '',
protected readonly headers?: IHeader,
) {
this.requestor = this.createRequestor()
}
protected createRequestor(): IRequestor {
return axios.create({
baseURL: this.baseURL,
headers: this.headers,
})
}
@ -27,7 +40,7 @@ export class HTTPClient<T extends IRoutes> implements IHTTPClient<T> {
return params.params![key]
})
const response = await this.axios.request({
const response = await this.requestor.request({
method: params.method,
url,
params: params.query,

View File

@ -0,0 +1,26 @@
import {HTTPClientMock} from './HTTPClientMock'
import {getError} from '../test-utils'
describe('HTTPClientMock', () => {
const http = new HTTPClientMock<any>()
describe('mockAdd and mockClear', () => {
it('adds a mock', async () => {
const value = {a: 1}
http.mockAdd({
method: 'get',
url: '/test',
}, value)
const result = await http.get('/test')
expect(result).toBe(value)
http.mockClear()
const error = await getError(http.get('/test'))
expect(error.message).toMatch(/mock/i)
})
})
})

View File

@ -0,0 +1,44 @@
import {HTTPClient} from './HTTPClient'
import {IRoutes} from '@rondo/common'
import {IRequest} from './IRequest'
import {IResponse} from './IResponse'
export class HTTPClientMock<T extends IRoutes> extends HTTPClient<T> {
mocks: {[key: string]: any} = {}
constructor() {
super()
}
createRequestor() {
return {
request: (r: IRequest): Promise<IResponse> => {
return new Promise((resolve, reject) => {
const key = this.serialize(r)
if (!this.mocks.hasOwnProperty(key)) {
setImmediate(() => {
reject(new Error('No mock for request: ' + key))
})
return
}
setImmediate(() => {
resolve({data: this.mocks[key]})
})
})
},
}
}
serialize(r: IRequest) {
return JSON.stringify(r, null, ' ')
}
mockAdd(r: IRequest, response: any) {
this.mocks[this.serialize(r)] = response
}
mockClear() {
this.mocks = []
}
}

View File

@ -0,0 +1,8 @@
import {IMethod} from '@rondo/common'
export interface IRequest {
method: IMethod,
url: string,
params?: {[key: string]: any},
data?: any,
}

View File

@ -0,0 +1,3 @@
export interface IResponse {
data: any
}

View File

@ -1,35 +1,27 @@
import {ILoginState} from './LoginReducer'
import {LoginActions} from './LoginActions'
import {LoginForm} from './LoginForm'
import {bindActionCreators, Dispatch} from 'redux'
import {connect} from 'react-redux'
import {IStateSlicer} from '../redux'
import {bindActionCreators} from 'redux'
import {IStateSelector} from '../redux'
import {Connector} from '../redux/Connector'
export class LoginConnector<GlobalState> {
constructor(
protected readonly loginActions: LoginActions,
protected readonly slice: IStateSlicer<GlobalState, ILoginState>,
) {}
export class LoginConnector extends Connector {
connect() {
return connect(
this.mapStateToProps,
this.mapDispatchToProps,
)(LoginForm)
constructor(protected readonly loginActions: LoginActions) {
super()
}
mapStateToProps = (globalState: GlobalState) => {
const state = this.slice(globalState)
return {
csrfToken: '123', // TODO this should be read from the state too
connect<State>(getLocalState: IStateSelector<State, ILoginState>) {
return this.wrap(
getLocalState,
state => ({
error: state.error,
user: state.user,
}
}
mapDispatchToProps = (dispatch: Dispatch) => {
return {
}),
dispatch => ({
onSubmit: bindActionCreators(this.loginActions.logIn, dispatch),
}
}),
LoginForm,
)
}
}

View File

@ -4,7 +4,6 @@ import {ICredentials} from '@rondo/common'
export interface ILoginFormProps {
error?: string
csrfToken: string
onSubmit: (credentials: ICredentials) => void
}
@ -25,7 +24,7 @@ export class LoginForm extends React.PureComponent<
handleSubmit = () => {
this.props.onSubmit(this.state)
}
handleChange = (name: keyof ILoginFormState, value: string) => {
handleChange = (name: string, value: string) => {
this.setState(
{[name]: value} as Pick<ILoginFormState, keyof ILoginFormState>,
)
@ -33,21 +32,23 @@ export class LoginForm extends React.PureComponent<
render() {
return (
<form onSubmit={this.handleSubmit}>
<Input
type='hidden'
name='_csrf'
value={this.props.csrfToken}
/>
<Input
name='username'
type='text'
onChange={this.handleChange}
value={this.state.username}
/>
<Input
name='password'
type='password'
onChange={this.handleChange}
value={this.state.password}
/>
<Input
name='submit'
type='submit'
value='Log In'
/>
</form>
)
}

View File

@ -22,5 +22,7 @@ export function Login(
return {...state, user: undefined}
case LoginActionKeys.USER_LOG_IN_REJECTED:
return {...state, error: action.error.message}
default:
return state
}
}

View File

@ -0,0 +1,22 @@
import * as Feature from './'
// import React from 'react'
import {TestUtils} from '../test-utils'
const test = new TestUtils()
describe('Login', () => {
const loginActions = new Feature.LoginActions({} as any)
const t = test.withProvider({
reducers: {Login: Feature.Login},
state: {Login: {user: {id: 1}}},
connector: new Feature.LoginConnector(loginActions),
select: state => state.Login,
})
it('should render', () => {
t.render()
})
})

View File

@ -0,0 +1,4 @@
export * from './LoginActions'
export * from './LoginConnector'
export * from './LoginForm'
export * from './LoginReducer'

View File

@ -1,19 +1,9 @@
import {createStore, applyMiddleware, Store} from 'redux'
import {PromiseMiddleware} from './PromiseMiddleware'
import {getError} from '../test-utils'
describe('PromiseMiddleware', () => {
async function getError(promise: Promise<any>): Promise<Error> {
let error: Error
try {
await promise
} catch (err) {
error = err
}
expect(error!).toBeTruthy()
return error!
}
describe('constructor', () => {
it('throws an error when action types are the same', () => {
expect(() => new PromiseMiddleware('a', 'a', 'a')).toThrowError()

View File

@ -0,0 +1,36 @@
import {IStateSelector} from './IStateSelector'
import {connect, Omit} from 'react-redux'
import {Dispatch} from 'redux'
import {ComponentType} from 'react'
// https://stackoverflow.com/questions/54277411
export abstract class Connector {
abstract connect<State, LocalState>(
getLocalState: IStateSelector<State, LocalState>,
): ComponentType<any>
protected wrap<
State,
LocalState,
StateProps,
DispatchProps,
Props
>(
getLocalState: IStateSelector<State, LocalState>,
mapStateToProps: (state: LocalState) => StateProps,
mapDispatchToProps: (dispatch: Dispatch) => DispatchProps,
Component: React.ComponentType<Props>,
): ComponentType<
Omit<Props, keyof Props & (keyof StateProps | keyof DispatchProps)>
> {
return connect(
(state: State) => {
const l = getLocalState(state)
return mapStateToProps(l)
},
mapDispatchToProps,
)(Component as any) as any
}
}

View File

@ -0,0 +1,2 @@
export type IStateSelector<GlobalState, StateSlice>
= (state: GlobalState) => StateSlice

View File

@ -1,3 +0,0 @@
export type IStateSlicer<GlobalState, StateSlice>
= (state: GlobalState) => StateSlice

View File

@ -1 +1,2 @@
export * from './IStateSlicer'
export * from './Connector'
export * from './IStateSelector'

View File

@ -0,0 +1,34 @@
// import React from 'react'
// import {connect, Omit} from 'react-redux'
// interface IProps {
// a: number
// }
// class NumberDisplay extends React.PureComponent<IProps> {
// render() {
// return `${this.props.a}`
// }
// }
// // Case 1: this works
// function mapStateToProps(state: any) {
// return {a: 1}
// }
// const ConnectedNumberDisplay1 = connect(mapStateToProps)(NumberDisplay)
// export const display1 = <ConnectedNumberDisplay1 />
// // Case 2: this doesn't work
// function wrap<State, StateProps, ComponentProps>(
// mapState: (state: State) => StateProps,
// Component: React.ComponentType<ComponentProps>,
// ): React.ComponentType<
// Omit<ComponentProps, keyof StateProps & keyof ComponentProps>
// > {
// return connect(mapState)(Component as any) as any
// }
// const ConnectedNumberDisplay2 = wrap(mapStateToProps, NumberDisplay)
// export const display2 = <ConnectedNumberDisplay2 />

View File

@ -0,0 +1,81 @@
import React from 'react'
import ReactDOM from 'react-dom'
import T from 'react-dom/test-utils'
import {Connector, IStateSelector} from '../redux'
import {Provider} from 'react-redux'
import {PromiseMiddleware} from '../middleware'
import {
Action,
AnyAction,
DeepPartial,
Middleware,
Reducer,
ReducersMapObject,
Store,
applyMiddleware,
combineReducers,
createStore,
} from 'redux'
interface IStoreParams<State, A extends Action<any>> {
reducer: Reducer<State, A>
state?: DeepPartial<State>
middleware?: Middleware[]
}
interface IRenderParams<State> {
reducers: ReducersMapObject<State, any>
state?: DeepPartial<State>
connector: Connector
select: IStateSelector<State, any>
}
export class TestUtils {
render(jsx: JSX.Element) {
const component = T.renderIntoDocument(jsx) as React.Component<any>
const node = ReactDOM.findDOMNode(component)
return {component, node}
}
combineReducers<S>(reducers: ReducersMapObject<S, any>): Reducer<S>
combineReducers<S, A extends Action = AnyAction>(
reducers: ReducersMapObject<S, A>,
): Reducer<S, A> {
return combineReducers(reducers)
}
createStore<State, A extends Action<any> = AnyAction>(
params: IStoreParams<State, A>,
): Store<State, A> {
const middleware = params.middleware || [new PromiseMiddleware().handle]
return createStore(
params.reducer,
params.state,
applyMiddleware(...middleware),
)
}
withProvider<State, A extends Action<any> = AnyAction>(
params: IRenderParams<State>,
) {
const store = this.createStore({
reducer: this.combineReducers(params.reducers),
state: params.state,
})
const Component = params.connector.connect(params.select)
const render = () => {
return this.render(
<Provider store={store}>
<Component />
</Provider>,
)
}
return {
render,
store,
Component,
}
}
}

View File

@ -0,0 +1,10 @@
export async function getError(promise: Promise<any>): Promise<Error> {
let error: Error
try {
await promise
} catch (err) {
error = err
}
expect(error!).toBeTruthy()
return error!
}

View File

@ -0,0 +1,2 @@
export * from './TestUtils'
export * from './getError'