diff --git a/packages/client/src/crud/CRUD.test.tsx b/packages/client/src/crud/CRUD.test.tsx index d1525d4..b2a350d 100644 --- a/packages/client/src/crud/CRUD.test.tsx +++ b/packages/client/src/crud/CRUD.test.tsx @@ -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() - const actions = CRUDActions.fromTwoRoutes({ + const actions = createCRUDActions( http, - listRoute: '/one/:oneId/two', - specificRoute: '/one/:oneId/two/:twoId', - actionName: 'TEST', - }) - const crudReducer = new CRUDReducer('TEST') + '/one/:oneId/two', + '/one/:oneId/two/:twoId', + 'TEST', + ) + const crudReducer = new CRUDReducer('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([]) diff --git a/packages/client/src/crud/CRUDActions.ts b/packages/client/src/crud/CRUDActions.ts index 1f89b16..9c46d9d 100644 --- a/packages/client/src/crud/CRUDActions.ts +++ b/packages/client/src/crud/CRUDActions.ts @@ -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 extends {} ? T : undefined -interface ICRUDActionTypes { - readonly get: string - readonly put: string - readonly post: string - readonly delete: string - readonly getMany: string -} +type Filter = T extends U ? T : never -export class CRUDActions< +type Action = + Filter, {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, - 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, - specificRoute: S, - listRoute: L, - actionName: string, - }, - ) { - const {http, specificRoute, listRoute, actionName} = params - return new CRUDActions( - 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 => { 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, - 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, - 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, + readonly route: Route, + readonly type: ActionType, + ) {} + + findOne = (params: { + query: Optional, + params: T[Route]['get']['params'], + }): Action => { + 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, + readonly route: Route, + readonly type: ActionType, + ) {} + + update = (params: { + body: T[Route]['put']['body'], + params: T[Route]['put']['params'], + }): Action => { + 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, + readonly route: Route, + readonly type: ActionType, + ) {} + + remove = (params: { + body: T[Route]['delete']['body'], + params: T[Route]['delete']['params'], + }): Action => { + 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, + readonly route: Route, + readonly type: ActionType, + ) {} + + findMany = (params: { + query: Optional, + params: T[Route]['get']['params'], + }): Action => { + 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, + 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} +} diff --git a/packages/client/src/crud/CRUDReducer.ts b/packages/client/src/crud/CRUDReducer.ts index a378773..afb677c 100644 --- a/packages/client/src/crud/CRUDReducer.ts +++ b/packages/client/src/crud/CRUDReducer.ts @@ -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 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 { +export interface ICRUDState { readonly ids: ReadonlyArray readonly byId: Record 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 extends IAction { - payload: P, -} - -export class CRUDReducer { +export class CRUDReducer< + T extends ICRUDEntity, + ActionType extends string, +> { readonly defaultState: ICRUDState - readonly actionTypes: ReturnType['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 { 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 { } } - 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 { } } - reduce = (state: ICRUDState | undefined, action: ICRUDAction) - : ICRUDState => { + handleRejected = ( + state: ICRUDState, + action: Filter, {status: 'rejected'}>, + ): ICRUDState => { + return { + ...state, + status: { + ...state.status, + [action.method]: { + isLoading: false, + error: action.payload.message, + }, + }, + } + } + + handleLoading = ( + state: ICRUDState, + action: Filter, {status: 'pending'}>, + ): ICRUDState => { + return { + ...state, + status: { + ...state.status, + [action.method]: { + isLoading: true, + error: '', + }, + }, + } + } + + handleFindOne = ( + state: ICRUDState, + action: Filter< + ICRUDAction, {method: 'findOne', status: 'resolved'}>, + ): ICRUDState => { + const {payload} = action + return { + ...state, + ids: [...state.ids, payload.id], + byId: { + [payload.id]: payload, + }, + status: { + ...state.status, + [action.method]: this.getSuccessStatus(), + }, + } + } + + handleSave = ( + state: ICRUDState, + action: Filter< + ICRUDAction, {method: 'save', status: 'resolved'}>, + ): ICRUDState => { + const {payload} = action + return { + ...state, + ids: [...state.ids, payload.id], + byId: { + [payload.id]: payload, + }, + status: { + ...state.status, + [action.method]: this.getSuccessStatus(), + }, + } + } + + handleUpdate = ( + state: ICRUDState, + action: Filter< + ICRUDAction, {method: 'update', status: 'resolved'}>, + ): ICRUDState => { + const {payload} = action + return { + ...state, + byId: { + [payload.id]: payload, + }, + status: { + ...state.status, + [action.method]: this.getSuccessStatus(), + }, + } + } + + handleRemove = ( + state: ICRUDState, + action: Filter< + ICRUDAction, {method: 'remove', status: 'resolved'}>, + ): ICRUDState => { + 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, + action: Filter< + ICRUDAction, {method: 'findMany', status: 'resolved'}>, + ): ICRUDState => { + 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 | undefined, + action: ICRUDAction, + ): ICRUDState => { 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 diff --git a/packages/client/src/crud/ICRUDAction.ts b/packages/client/src/crud/ICRUDAction.ts new file mode 100644 index 0000000..9b3a236 --- /dev/null +++ b/packages/client/src/crud/ICRUDAction.ts @@ -0,0 +1,24 @@ +import {IAsyncAction} from '../actions' +import {ICRUDMethod} from './ICRUDMethod' + +export type ICRUDSaveAction = + IAsyncAction & {method: Extract} + +export type ICRUDUpdateAction = + IAsyncAction & {method: Extract} + +export type ICRUDRemoveAction = + IAsyncAction & {method: Extract} + +export type ICRUDFindOneAction = + IAsyncAction & {method: Extract} + +export type ICRUDFindManyAction = + IAsyncAction & {method: Extract} + +export type ICRUDAction = + ICRUDSaveAction + | ICRUDUpdateAction + | ICRUDRemoveAction + | ICRUDFindOneAction + | ICRUDFindManyAction diff --git a/packages/client/src/crud/ICRUDMethod.ts b/packages/client/src/crud/ICRUDMethod.ts new file mode 100644 index 0000000..fda0bcc --- /dev/null +++ b/packages/client/src/crud/ICRUDMethod.ts @@ -0,0 +1 @@ +export type ICRUDMethod = 'save' | 'update' | 'findOne' | 'findMany' | 'remove' diff --git a/packages/client/src/crud/index.ts b/packages/client/src/crud/index.ts index 9bd6f62..7fac91d 100644 --- a/packages/client/src/crud/index.ts +++ b/packages/client/src/crud/index.ts @@ -1,2 +1,4 @@ export * from './CRUDActions' export * from './CRUDReducer' +export * from './ICRUDAction' +export * from './ICRUDMethod' diff --git a/packages/client/src/login/LoginReducer.ts b/packages/client/src/login/LoginReducer.ts index 2518cd8..30cc038 100644 --- a/packages/client/src/login/LoginReducer.ts +++ b/packages/client/src/login/LoginReducer.ts @@ -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, diff --git a/packages/client/src/middleware/PromiseMiddleware.test.ts b/packages/client/src/middleware/PromiseMiddleware.test.ts index 97f94fd..5cb0c86 100644 --- a/packages/client/src/middleware/PromiseMiddleware.test.ts +++ b/packages/client/src/middleware/PromiseMiddleware.test.ts @@ -64,7 +64,7 @@ describe('PromiseMiddleware', () => { status: 'pending', type, }, { - error, + payload: error, status: 'rejected', type, }]) diff --git a/packages/client/src/middleware/PromiseMiddleware.ts b/packages/client/src/middleware/PromiseMiddleware.ts index 7a49404..1e6e45e 100644 --- a/packages/client/src/middleware/PromiseMiddleware.ts +++ b/packages/client/src/middleware/PromiseMiddleware.ts @@ -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, }) })