Simplify types for CRUDActions & CRUDReducer

This commit is contained in:
Jerko Steiner 2019-04-02 11:44:34 +08:00
parent 8b6f90235e
commit 580fb368e6
9 changed files with 395 additions and 321 deletions

View File

@ -1,6 +1,7 @@
import {createCRUDActions} from './CRUDActions'
import React from 'react'
import {AnyAction} from 'redux'
import {CRUDActions, CRUDReducer, ICRUDMethod} from './'
import {CRUDReducer, ICRUDMethod} from './'
import {HTTPClientMock, TestUtils, getError} from '../test-utils'
import {IMethod} from '@rondo/common'
import {IPendingAction} from '../actions'
@ -55,13 +56,13 @@ describe('CRUD', () => {
}
const http = new HTTPClientMock<ITestAPI>()
const actions = CRUDActions.fromTwoRoutes({
const actions = createCRUDActions(
http,
listRoute: '/one/:oneId/two',
specificRoute: '/one/:oneId/two/:twoId',
actionName: 'TEST',
})
const crudReducer = new CRUDReducer<ITwo>('TEST')
'/one/:oneId/two',
'/one/:oneId/two/:twoId',
'TEST',
)
const crudReducer = new CRUDReducer<ITwo, 'TEST'>('TEST')
const Crud = crudReducer.reduce
const test = new TestUtils()
@ -109,13 +110,23 @@ describe('CRUD', () => {
}
function getUrl(method: ICRUDMethod) {
return method === 'post' || method === 'getMany'
return method === 'save' || method === 'findMany'
? '/one/1/two'
: '/one/1/two/2'
}
function getHTTPMethod(method: ICRUDMethod): IMethod {
return method === 'getMany' ? 'get' : method
switch (method) {
case 'save':
return 'post'
case 'update':
return 'put'
case 'remove':
return 'delete'
case 'findOne':
case 'findMany':
return 'get'
}
}
describe('Promise rejections', () => {
@ -123,29 +134,29 @@ describe('CRUD', () => {
method: ICRUDMethod
params: any
}> = [{
method: 'get',
method: 'findOne',
params: {
params: {oneId: 1, twoId: 2},
},
}, {
method: 'getMany',
method: 'findMany',
params: {
params: {oneId: 1},
},
}, {
method: 'post',
method: 'save',
params: {
body: {name: 'test'},
params: {oneId: 1, twoId: 2},
},
}, {
method: 'put',
method: 'update',
params: {
body: {name: 'test'},
params: {oneId: 1, twoId: 2},
},
}, {
method: 'delete',
method: 'remove',
params: {
body: {},
params: {oneId: 1, twoId: 2},
@ -160,9 +171,10 @@ describe('CRUD', () => {
http.mockAdd({
url: getUrl(method),
method: getHTTPMethod(method),
data: method === 'put' || method === 'post' || method === 'delete'
data: method === 'save'
|| method === 'update' || method === 'remove'
? testCase.params.body
: undefined,
: undefined,
}, {error: 'Test Error'}, 400)
})
@ -196,25 +208,25 @@ describe('CRUD', () => {
body?: any
response: any
}> = [{
method: 'getMany',
method: 'findMany',
params: {oneId: 1, twoId: 2},
response: [entity],
}, {
method: 'get',
method: 'findOne',
params: {oneId: 1, twoId: 2},
response: entity,
}, {
method: 'post',
method: 'save',
params: {oneId: 1},
body: {name: entity.name},
response: entity,
}, {
method: 'put',
method: 'update',
params: {oneId: 1, twoId: 2},
body: {name: entity.name},
response: entity,
}, {
method: 'delete',
method: 'remove',
params: {oneId: 1, twoId: 2},
response: {id: entity.id},
}]
@ -243,12 +255,12 @@ describe('CRUD', () => {
}))
await action.payload
const state = store.getState()
expect(state.Crud.status.getMany.isLoading).toBe(false)
if (method === 'delete') {
expect(state.Crud.status.findMany.isLoading).toBe(false)
if (method === 'remove') {
expect(state.Crud.ids).toEqual([])
expect(state.Crud.byId[entity.id]).toBe(undefined)
} else {
if (method !== 'put') {
if (method !== 'update') {
expect(state.Crud.ids).toEqual([entity.id])
}
expect(state.Crud.byId[entity.id]).toEqual(entity)
@ -259,20 +271,20 @@ describe('CRUD', () => {
describe('POST then DELETE', () => {
const postTestCase = testCases.find(t => t.method === 'post')!
const deleteTestCase = testCases.find(t => t.method === 'delete')!
const saveTestCase = testCases.find(t => t.method === 'save')!
const removeTestCase = testCases.find(t => t.method === 'remove')!
beforeEach(() => {
http.mockAdd({
url: getUrl(postTestCase.method),
method: getHTTPMethod(postTestCase.method),
data: postTestCase.body,
}, postTestCase.response)
url: getUrl(saveTestCase.method),
method: getHTTPMethod(saveTestCase.method),
data: saveTestCase.body,
}, saveTestCase.response)
http.mockAdd({
url: getUrl(deleteTestCase.method),
method: getHTTPMethod(deleteTestCase.method),
data: deleteTestCase.body,
}, deleteTestCase.response)
url: getUrl(removeTestCase.method),
method: getHTTPMethod(removeTestCase.method),
data: removeTestCase.body,
}, removeTestCase.response)
})
afterEach(() => {
@ -280,15 +292,15 @@ describe('CRUD', () => {
})
it('removes id and entity from state', async () => {
const action1 = store.dispatch(actions.post({
params: postTestCase.params,
body: postTestCase.body,
const action1 = store.dispatch(actions.save({
params: saveTestCase.params,
body: saveTestCase.body,
}))
await action1.payload
expect(store.getState().Crud.ids).toEqual([entity.id])
const action2 = store.dispatch(actions.delete({
params: deleteTestCase.params,
body: deleteTestCase.body,
const action2 = store.dispatch(actions.remove({
params: removeTestCase.params,
body: removeTestCase.body,
}))
await action2.payload
expect(store.getState().Crud.ids).toEqual([])

View File

@ -1,119 +1,159 @@
import {IRoutes} from '@rondo/common'
import {ICRUDAction} from './ICRUDAction'
import {ICRUDMethod} from './ICRUDMethod'
import {IHTTPClient, ITypedRequestParams} from '../http'
import {IRoutes} from '@rondo/common'
export type Optional<T> = T extends {} ? T : undefined
interface ICRUDActionTypes {
readonly get: string
readonly put: string
readonly post: string
readonly delete: string
readonly getMany: string
}
type Filter<T, U> = T extends U ? T : never
export class CRUDActions<
type Action<T, ActionType extends string, Method extends ICRUDMethod> =
Filter<ICRUDAction<T, ActionType>, {method: Method, status: 'pending'}>
export class SaveActionCreator<
T extends IRoutes,
POST extends keyof T & string,
GET_MANY extends keyof T & string,
GET extends keyof T & string,
PUT extends keyof T & string,
DELETE extends keyof T & string,
Route extends keyof T & string,
ActionType extends string,
> {
readonly actionTypes: ICRUDActionTypes
constructor(
readonly http: IHTTPClient<T>,
readonly postRoute: POST,
readonly getManyRoute: GET_MANY,
readonly getRoute: GET,
readonly putRoute: PUT,
readonly deleteRoute: DELETE,
readonly actionName: string,
) {
this.actionTypes = this.getActionTypes()
}
readonly route: Route,
readonly type: ActionType,
) {}
static fromTwoRoutes<
R,
S extends keyof R & string,
L extends keyof R & string,
>(params: {
http: IHTTPClient<R>,
specificRoute: S,
listRoute: L,
actionName: string,
},
) {
const {http, specificRoute, listRoute, actionName} = params
return new CRUDActions<R, L, L, S, S, S>(
http,
listRoute,
listRoute,
specificRoute,
specificRoute,
specificRoute,
actionName,
)
}
getActionTypes(): ICRUDActionTypes {
const {actionName} = this
save = (params: {
body: T[Route]['post']['body'],
params: T[Route]['post']['params'],
}): Action<T[Route]['post']['response'], ActionType, 'save'> => {
return {
get: actionName + '_GET_PENDING',
put: actionName + '_PUT_PENDING',
post: actionName + '_POST_PENDING',
delete: actionName + '_DELETE_PENDING',
getMany: actionName + '_GET_MANY_PENDING',
}
}
get = (params: {
query: Optional<T[GET]['get']['query']>,
params: T[GET]['get']['params'],
}) => {
return {
payload: this.http.get(this.getRoute, params.query, params.params),
type: this.actionTypes.get,
}
}
post = (params: {
body: T[POST]['post']['body'],
params: T[POST]['post']['params'],
}) => {
return {
payload: this.http.post(this.postRoute, params.body, params.params),
type: this.actionTypes.post,
}
}
put = (params: {
body: T[PUT]['put']['body'],
params: T[PUT]['put']['params'],
}) => {
return {
payload: this.http.put(this.putRoute, params.body, params.params),
type: this.actionTypes.put,
}
}
delete = (params: {
body: T[DELETE]['delete']['body'],
params: T[DELETE]['delete']['params'],
}) => {
return {
payload: this.http.delete(this.deleteRoute, params.body, params.params),
type: this.actionTypes.delete,
}
}
getMany = (params: {
query: Optional<T[GET_MANY]['get']['query']>,
params: T[GET_MANY]['get']['params'],
}) => {
return {
payload: this.http.get(this.getManyRoute, params.query, params.params),
type: this.actionTypes.getMany,
payload: this.http.post(this.route, params.body, params.params),
type: this.type,
method: 'save',
status: 'pending',
}
}
}
export class FindOneActionCreator<
T extends IRoutes,
Route extends keyof T & string,
ActionType extends string,
> {
constructor(
readonly http: IHTTPClient<T>,
readonly route: Route,
readonly type: ActionType,
) {}
findOne = (params: {
query: Optional<T[Route]['get']['query']>,
params: T[Route]['get']['params'],
}): Action<T[Route]['get']['response'], ActionType, 'findOne'> => {
return {
payload: this.http.get(this.route, params.query, params.params),
type: this.type,
method: 'findOne',
status: 'pending',
}
}
}
export class UpdateActionCreator<
T extends IRoutes,
Route extends keyof T & string,
ActionType extends string
> {
constructor(
readonly http: IHTTPClient<T>,
readonly route: Route,
readonly type: ActionType,
) {}
update = (params: {
body: T[Route]['put']['body'],
params: T[Route]['put']['params'],
}): Action<T[Route]['put']['response'], ActionType, 'update'> => {
return {
payload: this.http.put(this.route, params.body, params.params),
type: this.type,
method: 'update',
status: 'pending',
}
}
}
export class RemoveActionCreator<
T extends IRoutes,
Route extends keyof T & string,
ActionType extends string,
> {
constructor(
readonly http: IHTTPClient<T>,
readonly route: Route,
readonly type: ActionType,
) {}
remove = (params: {
body: T[Route]['delete']['body'],
params: T[Route]['delete']['params'],
}): Action<T[Route]['delete']['response'], ActionType, 'remove'> => {
return {
payload: this.http.delete(this.route, params.body, params.params),
type: this.type,
method: 'remove',
status: 'pending',
}
}
}
export class FindManyActionCreator<
T extends IRoutes,
Route extends keyof T & string,
ActionType extends string,
> {
constructor(
readonly http: IHTTPClient<T>,
readonly route: Route,
readonly type: ActionType,
) {}
findMany = (params: {
query: Optional<T[Route]['get']['query']>,
params: T[Route]['get']['params'],
}): Action<T[Route]['get']['response'], ActionType, 'findMany'> => {
return {
payload: this.http.get(this.route, params.query, params.params),
type: this.type,
method: 'findMany',
status: 'pending',
}
}
}
export function createCRUDActions<
T extends IRoutes,
EntityRoute extends keyof T & string,
ListRoute extends keyof T & string,
ActionType extends string,
>(
http: IHTTPClient<T>,
entityRoute: EntityRoute,
listRoute: ListRoute,
actionType: ActionType,
) {
const {save} = new SaveActionCreator(http, entityRoute, actionType)
const {update} = new UpdateActionCreator(http, listRoute, actionType)
const {remove} = new RemoveActionCreator(http, listRoute, actionType)
const {findOne} = new FindOneActionCreator(http, listRoute, actionType)
const {findMany} = new FindManyActionCreator(http, entityRoute, actionType)
return {save, update, remove, findOne, findMany}
}

View File

@ -1,9 +1,11 @@
import {IAction} from '../actions'
import {IAction, IResolvedAction} from '../actions'
import {ICRUDAction} from './ICRUDAction'
import {ICRUDMethod} from './ICRUDMethod'
import {indexBy, without} from '@rondo/common'
export type ICRUDMethod = 'put' | 'post' | 'delete' | 'get' | 'getMany'
type Filter<T, U> = T extends U ? T : never
export interface ICRUDIdable {
export interface ICRUDEntity {
readonly id: number
}
@ -12,42 +14,27 @@ export interface ICRUDMethodStatus {
readonly error: string
}
export interface ICRUDState<T extends ICRUDIdable> {
export interface ICRUDState<T extends ICRUDEntity> {
readonly ids: ReadonlyArray<number>
readonly byId: Record<number, T>
status: ICRUDStatus
}
export interface ICRUDStatus {
readonly post: ICRUDMethodStatus
readonly put: ICRUDMethodStatus
readonly delete: ICRUDMethodStatus
readonly get: ICRUDMethodStatus
readonly getMany: ICRUDMethodStatus
readonly save: ICRUDMethodStatus
readonly update: ICRUDMethodStatus
readonly remove: ICRUDMethodStatus
readonly findOne: ICRUDMethodStatus
readonly findMany: ICRUDMethodStatus
}
export interface ICRUDActions {
readonly post: string
readonly put: string
readonly delete: string
readonly get: string
readonly getMany: string
}
export interface ICRUDAction<P, T extends string = string> extends IAction<T> {
payload: P,
}
export class CRUDReducer<T extends ICRUDIdable> {
export class CRUDReducer<
T extends ICRUDEntity,
ActionType extends string,
> {
readonly defaultState: ICRUDState<T>
readonly actionTypes: ReturnType<CRUDReducer<T>['getActionTypes']>
constructor(
readonly actionName: string,
readonly pendingExtension = '_PENDING',
readonly resolvedExtension = '_RESOLVED',
readonly rejectedExtension = '_REJECTED',
) {
constructor(readonly actionName: ActionType) {
const defaultMethodStatus = this.getDefaultMethodStatus()
this.defaultState = {
@ -55,15 +42,13 @@ export class CRUDReducer<T extends ICRUDIdable> {
byId: {},
status: {
post: defaultMethodStatus,
put: defaultMethodStatus,
delete: defaultMethodStatus,
get: defaultMethodStatus,
getMany: defaultMethodStatus,
save: defaultMethodStatus,
update: defaultMethodStatus,
remove: defaultMethodStatus,
findOne: defaultMethodStatus,
findMany: defaultMethodStatus,
},
}
this.actionTypes = this.getActionTypes()
}
getDefaultMethodStatus(): ICRUDMethodStatus {
@ -73,59 +58,6 @@ export class CRUDReducer<T extends ICRUDIdable> {
}
}
protected getPromiseActionNames(type: string) {
return {
pending: type + this.pendingExtension,
resolved: type + this.resolvedExtension,
rejected: type + this.rejectedExtension,
}
}
protected getActionTypes() {
const {actionName} = this
return {
put: this.getPromiseActionNames(actionName + '_PUT'),
post: this.getPromiseActionNames(actionName + '_POST'),
delete: this.getPromiseActionNames(actionName + '_DELETE'),
get: this.getPromiseActionNames(actionName + '_GET'),
getMany: this.getPromiseActionNames(actionName + '_GET_MANY'),
}
}
protected getUpdatedStatus(
state: ICRUDStatus,
method: ICRUDMethod,
status: ICRUDMethodStatus,
): ICRUDStatus {
return {
...state,
[method]: status,
}
}
protected getMethod(actionType: string): ICRUDMethod {
const {get, put, post, delete: _delete, getMany} = this.actionTypes
switch (actionType) {
case get.pending:
case get.rejected:
return 'get'
case put.pending:
case put.rejected:
return 'put'
case post.pending:
case post.rejected:
return 'post'
case _delete.pending:
case _delete.rejected:
return 'delete'
case getMany.pending:
case getMany.rejected:
return 'getMany'
default:
throw new Error('Unknown action type: ' + actionType)
}
}
protected getSuccessStatus(): ICRUDMethodStatus {
return {
isLoading: false,
@ -133,94 +65,156 @@ export class CRUDReducer<T extends ICRUDIdable> {
}
}
reduce = (state: ICRUDState<T> | undefined, action: ICRUDAction<T | T[]>)
: ICRUDState<T> => {
handleRejected = (
state: ICRUDState<T>,
action: Filter<ICRUDAction<T, ActionType>, {status: 'rejected'}>,
): ICRUDState<T> => {
return {
...state,
status: {
...state.status,
[action.method]: {
isLoading: false,
error: action.payload.message,
},
},
}
}
handleLoading = (
state: ICRUDState<T>,
action: Filter<ICRUDAction<T, ActionType>, {status: 'pending'}>,
): ICRUDState<T> => {
return {
...state,
status: {
...state.status,
[action.method]: {
isLoading: true,
error: '',
},
},
}
}
handleFindOne = (
state: ICRUDState<T>,
action: Filter<
ICRUDAction<T, ActionType>, {method: 'findOne', status: 'resolved'}>,
): ICRUDState<T> => {
const {payload} = action
return {
...state,
ids: [...state.ids, payload.id],
byId: {
[payload.id]: payload,
},
status: {
...state.status,
[action.method]: this.getSuccessStatus(),
},
}
}
handleSave = (
state: ICRUDState<T>,
action: Filter<
ICRUDAction<T, ActionType>, {method: 'save', status: 'resolved'}>,
): ICRUDState<T> => {
const {payload} = action
return {
...state,
ids: [...state.ids, payload.id],
byId: {
[payload.id]: payload,
},
status: {
...state.status,
[action.method]: this.getSuccessStatus(),
},
}
}
handleUpdate = (
state: ICRUDState<T>,
action: Filter<
ICRUDAction<T, ActionType>, {method: 'update', status: 'resolved'}>,
): ICRUDState<T> => {
const {payload} = action
return {
...state,
byId: {
[payload.id]: payload,
},
status: {
...state.status,
[action.method]: this.getSuccessStatus(),
},
}
}
handleRemove = (
state: ICRUDState<T>,
action: Filter<
ICRUDAction<T, ActionType>, {method: 'remove', status: 'resolved'}>,
): ICRUDState<T> => {
const {payload} = action
return {
...state,
ids: state.ids.filter(id => id !== payload.id),
byId: without(state.byId, payload.id),
status: {
...state.status,
[action.method]: this.getSuccessStatus(),
},
}
}
handleFindMany = (
state: ICRUDState<T>,
action: Filter<
ICRUDAction<T, ActionType>, {method: 'findMany', status: 'resolved'}>,
): ICRUDState<T> => {
const {payload} = action
return {
...state,
ids: payload.map(item => item.id),
byId: indexBy(payload, 'id' as any),
status: {
...state.status,
[action.method]: this.getSuccessStatus(),
},
}
}
reduce = (
state: ICRUDState<T> | undefined,
action: ICRUDAction<T, ActionType>,
): ICRUDState<T> => {
const {defaultState} = this
state = state || defaultState
const {get, put, post, delete: _delete, getMany} = this.actionTypes
if (action.type !== this.actionName) {
return state
}
switch (action.type) {
case put.pending:
case post.pending:
case _delete.pending:
case getMany.pending:
case get.pending:
const pendingMethod = this.getMethod(action.type)
return {
...state,
status: this.getUpdatedStatus(state.status, pendingMethod, {
isLoading: true,
error: '',
}),
}
case put.rejected:
case post.rejected:
case _delete.rejected:
case getMany.rejected:
case get.rejected:
const rejectedMethod = this.getMethod(action.type)
const rejectedAction = action as any
return {
...state,
status: this.getUpdatedStatus(state.status, rejectedMethod, {
isLoading: false,
error: rejectedAction.error
? rejectedAction.error.message
: 'An error occurred',
}),
}
case get.resolved:
const getPayload = action.payload as T
return {
...state,
ids: [...state.ids, getPayload.id],
byId: {
[getPayload.id]: getPayload,
},
status: this.getUpdatedStatus(
state.status, 'get', this.getSuccessStatus()),
}
case post.resolved:
const postPayload = action.payload as T
return {
...state,
ids: [...state.ids, postPayload.id],
byId: {
[postPayload.id]: postPayload,
},
status: this.getUpdatedStatus(
state.status, 'post', this.getSuccessStatus()),
}
case put.resolved:
const putPayload = action.payload as T
return {
...state,
byId: {
[putPayload.id]: putPayload,
},
status: this.getUpdatedStatus(
state.status, 'put', this.getSuccessStatus()),
}
case _delete.resolved:
const deletePayload = action.payload as T
return {
...state,
ids: state.ids.filter(id => id !== deletePayload.id),
byId: without(state.byId, deletePayload.id),
status: this.getUpdatedStatus(
state.status, 'delete', this.getSuccessStatus()),
}
case getMany.resolved:
const getManyPayload = action.payload as T[]
return {
...state,
ids: getManyPayload.map(item => item.id),
byId: indexBy(getManyPayload, 'id' as any),
status: this.getUpdatedStatus(
state.status, 'getMany', this.getSuccessStatus()),
switch (action.status) {
case 'pending':
return this.handleLoading(state, action)
case 'rejected':
return this.handleRejected(state, action)
case 'resolved':
switch (action.method) {
case 'save':
return this.handleSave(state, action)
case 'update':
return this.handleUpdate(state, action)
case 'remove':
return this.handleRemove(state, action)
case 'findOne':
return this.handleFindOne(state, action)
case 'findMany':
return this.handleFindMany(state, action)
}
default:
return state

View File

@ -0,0 +1,24 @@
import {IAsyncAction} from '../actions'
import {ICRUDMethod} from './ICRUDMethod'
export type ICRUDSaveAction<T, ActionType extends string> =
IAsyncAction<T, ActionType> & {method: Extract<ICRUDMethod, 'save'>}
export type ICRUDUpdateAction<T, ActionType extends string> =
IAsyncAction<T, ActionType> & {method: Extract<ICRUDMethod, 'update'>}
export type ICRUDRemoveAction<T, ActionType extends string> =
IAsyncAction<T, ActionType> & {method: Extract<ICRUDMethod, 'remove'>}
export type ICRUDFindOneAction<T, ActionType extends string> =
IAsyncAction<T, ActionType> & {method: Extract<ICRUDMethod, 'findOne'>}
export type ICRUDFindManyAction<T, ActionType extends string> =
IAsyncAction<T[], ActionType> & {method: Extract<ICRUDMethod, 'findMany'>}
export type ICRUDAction<T, ActionType extends string> =
ICRUDSaveAction<T, ActionType>
| ICRUDUpdateAction<T, ActionType>
| ICRUDRemoveAction<T, ActionType>
| ICRUDFindOneAction<T, ActionType>
| ICRUDFindManyAction<T, ActionType>

View File

@ -0,0 +1 @@
export type ICRUDMethod = 'save' | 'update' | 'findOne' | 'findMany' | 'remove'

View File

@ -1,2 +1,4 @@
export * from './CRUDActions'
export * from './CRUDReducer'
export * from './ICRUDAction'
export * from './ICRUDMethod'

View File

@ -26,6 +26,7 @@ export function Login(
default:
// async actions
switch (action.status) {
// FIXME this will trigger for all async actions with status pending
case 'pending':
return {
...state,

View File

@ -64,7 +64,7 @@ describe('PromiseMiddleware', () => {
status: 'pending',
type,
}, {
error,
payload: error,
status: 'rejected',
type,
}])

View File

@ -35,16 +35,16 @@ export class PromiseMiddleware {
payload
.then(result => {
store.dispatch({
...action,
payload: result,
status: 'resolved',
type,
})
})
.catch(err => {
store.dispatch({
error: err,
...action,
payload: err,
status: 'rejected',
type,
})
})