Add TestUtils and a generic way to isolate connected components
This commit is contained in:
parent
0fcd8cbb03
commit
bdf0aa57be
@ -1,5 +1,9 @@
|
||||
import React from 'react'
|
||||
|
||||
export interface IButtonProps {
|
||||
type: string
|
||||
}
|
||||
|
||||
export class Button extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
26
packages/client/src/http/HTTPClientMock.test.ts
Normal file
26
packages/client/src/http/HTTPClientMock.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
44
packages/client/src/http/HTTPClientMock.ts
Normal file
44
packages/client/src/http/HTTPClientMock.ts
Normal 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 = []
|
||||
}
|
||||
|
||||
}
|
||||
8
packages/client/src/http/IRequest.ts
Normal file
8
packages/client/src/http/IRequest.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import {IMethod} from '@rondo/common'
|
||||
|
||||
export interface IRequest {
|
||||
method: IMethod,
|
||||
url: string,
|
||||
params?: {[key: string]: any},
|
||||
data?: any,
|
||||
}
|
||||
3
packages/client/src/http/IResponse.ts
Normal file
3
packages/client/src/http/IResponse.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export interface IResponse {
|
||||
data: any
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
22
packages/client/src/login/index.test.tsx
Normal file
22
packages/client/src/login/index.test.tsx
Normal 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()
|
||||
})
|
||||
|
||||
})
|
||||
4
packages/client/src/login/index.ts
Normal file
4
packages/client/src/login/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './LoginActions'
|
||||
export * from './LoginConnector'
|
||||
export * from './LoginForm'
|
||||
export * from './LoginReducer'
|
||||
@ -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()
|
||||
|
||||
36
packages/client/src/redux/Connector.ts
Normal file
36
packages/client/src/redux/Connector.ts
Normal 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
|
||||
}
|
||||
}
|
||||
2
packages/client/src/redux/IStateSelector.ts
Normal file
2
packages/client/src/redux/IStateSelector.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export type IStateSelector<GlobalState, StateSlice>
|
||||
= (state: GlobalState) => StateSlice
|
||||
@ -1,3 +0,0 @@
|
||||
export type IStateSlicer<GlobalState, StateSlice>
|
||||
= (state: GlobalState) => StateSlice
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from './IStateSlicer'
|
||||
export * from './Connector'
|
||||
export * from './IStateSelector'
|
||||
|
||||
34
packages/client/src/redux/temp-test.tsx
Normal file
34
packages/client/src/redux/temp-test.tsx
Normal 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 />
|
||||
81
packages/client/src/test-utils/TestUtils.tsx
Normal file
81
packages/client/src/test-utils/TestUtils.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
10
packages/client/src/test-utils/getError.ts
Normal file
10
packages/client/src/test-utils/getError.ts
Normal 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!
|
||||
}
|
||||
2
packages/client/src/test-utils/index.ts
Normal file
2
packages/client/src/test-utils/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './TestUtils'
|
||||
export * from './getError'
|
||||
Loading…
x
Reference in New Issue
Block a user