Skip to content

Commit

Permalink
Generic redux (#620)
Browse files Browse the repository at this point in the history
* adding types

* adding the rest

* adding demo

* adding comments

* forcing uppercase

* camelcase id

* update lockfile

* swapping demo to comments
  • Loading branch information
enjeyw committed Mar 10, 2021
1 parent 0019e7f commit 1d7d896
Show file tree
Hide file tree
Showing 10 changed files with 43,943 additions and 95 deletions.
8 changes: 6 additions & 2 deletions app/client/api/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ export type Method =
| "delete"
| "DELETE";

interface Body {
export interface Body {
[key: string]: any;
}

export interface Query {
[key: string]: any;
}

Expand All @@ -18,7 +22,7 @@ export interface ApiClientType {
isAuthed?: boolean;
isTFA?: boolean;
isForm?: boolean;
query?: null | object;
query?: null | Query;
body?: null | Body;
path?: null | number;
errorHandling?: boolean;
Expand Down
69 changes: 69 additions & 0 deletions app/client/genericState/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Body, Query } from "../api/client/types";
import {
ActionGenerator,
APILifecycleActionTypesInterface,
CreateRequestAction,
LoadRequestAction,
ModifyRequestAction,
Registration
} from "./types";

class APILifeCycleActionType implements APILifecycleActionTypesInterface {
constructor(stage: string) {
this.stage = stage;
}

stage: string;
request = (name: string) => `${this.stage}_${name.toUpperCase()}_REQUEST`;
success = (name: string) => `${this.stage}_${name.toUpperCase()}_SUCCESS`;
failure = (name: string) => `${this.stage}_${name.toUpperCase()}_FAILURE`;
}

export const loadActionTypes = new APILifeCycleActionType("LOAD");
export const createActionTypes = new APILifeCycleActionType("CREATE");
export const modifyActionTypes = new APILifeCycleActionType("MODIFY");

export const deepUpdateObjectsActionType: ActionGenerator = name =>
`DEEP_UPDATE_${name.toUpperCase()}`;
export const replaceUpdateObjectsActionType: ActionGenerator = name =>
`REPLACE_UPDATE_${name.toUpperCase()}`;
export const replaceIdListActionType: ActionGenerator = name =>
`REPLACE_${name.toUpperCase()}_ID_LIST`;

// These functions are designed to be called directly from inside a dispatch,
// and they will return an appropriately shaped action for the given Rest API... action? (load, create, modify)
export const apiActions = {
load: function<CB, MB>(
reg: Registration<CB, MB>,
path?: number,
query?: Query
): LoadRequestAction {
return {
type: loadActionTypes.request(reg.name),
payload: { path, query }
};
},

create: function<CB, MB>(
reg: Registration<CB, MB>,
body?: CB,
query?: Query
): CreateRequestAction {
return {
type: createActionTypes.request(reg.name),
payload: { query, body }
};
},

modify: function<CB, MB>(
reg: Registration<CB, MB>,
path: number,
body?: MB,
query?: Query
): ModifyRequestAction {
return {
type: modifyActionTypes.request(reg.name),
payload: { path, query, body }
};
}
};
28 changes: 28 additions & 0 deletions app/client/genericState/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { apiClient } from "../api/client/apiClient";
import { ApiClientType } from "../api/client/types";

export type GetRequest = Pick<ApiClientType, "url" | "query" | "path">;
export type PutRequest = Pick<ApiClientType, "url" | "query" | "path" | "body">;
export type PostRequest = Pick<ApiClientType, "url" | "query" | "body">;
export const genericGetAPI = ({ url, query, path }: GetRequest) =>
apiClient({
method: "GET",
url: `/${url}/`,
query: query,
path: path
});
export const genericPutAPI = ({ url, query, path, body }: PutRequest) =>
apiClient({
method: "PUT",
url: `/${url}/`,
query: query,
path: path,
body: body
});
export const genericPostAPI = ({ url, query, body }: PostRequest) =>
apiClient({
method: "POST",
url: `/${url}/`,
query: query,
body: body
});
153 changes: 153 additions & 0 deletions app/client/genericState/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import {
createActionTypes,
loadActionTypes,
modifyActionTypes
} from "./actions";
import { sagaFactory } from "./sagas";
import { combineReducers, ReducersMapObject } from "redux";
import {
byIdReducerFactory,
idListReducerFactory,
lifecycleReducerFactory
} from "./reducers";
import {
EndpointedRegistration,
RegistrationMapping,
Registration,
EndpointedRegistrationMapping
} from "./types";

export { apiActions } from "./actions";
export {
Registration,
LoadRequestAction,
CreateRequestAction,
ModifyRequestAction
} from "./types";
export { Body } from "../api/client/types";

function hasEndpoint(
reg: Registration | EndpointedRegistration
): reg is EndpointedRegistration {
return (reg as EndpointedRegistration).endpoint !== undefined;
}

/**
* Creates a set of reducers for each object defined in the registration mapping.
* By default creates a map of each instance of an object indexed by the instance's ID. This map is deep-merged
* whenever a REST API call triggered by _any_ of the registered objects results in updated data for that object
* Additionally if an API endpoint is defined, will also create lifecycle reducers corresponding
* to loading, creating and modifying an object, as well as an ordered ID list that is updated when new data is loaded
*
* @param {RegistrationMapping} registrations - a mapping of each object to be registered
* @returns An array of reducers
*/
export const createReducers = <R extends RegistrationMapping>(
registrations: R
): ReducersMapObject<R> => {
const base: ReducersMapObject = {};

Object.keys(registrations).map(key => {
let reg = { ...registrations[key], name: key };

let reducers = {
byId: byIdReducerFactory(reg)
};

if (hasEndpoint(reg)) {
reducers = {
...reducers,
...{
loadStatus: lifecycleReducerFactory(loadActionTypes, reg),
createStatus: lifecycleReducerFactory(createActionTypes, reg),
modifyStatus: lifecycleReducerFactory(modifyActionTypes, reg),
idList: idListReducerFactory(reg)
}
};
}

base[reg.name] = combineReducers(reducers);
});

return base;
};

/**
* Creates REST API sagas for loading, creating and modifying objects. An object must have an endpoint defined for
* a saga to be created
* @param {RegistrationMapping} registrations - a mapping of each object to be registered
* @returns {any[]}
*/
export const createSagas = (registrations: RegistrationMapping) => {
let sagaList: any[] = [];

let endpointedRegistrations: EndpointedRegistrationMapping = {};

//First ensure all registrations have a name and filter out all non-endpointed registrations
//Done in that order rather than using .filter for type safety
Object.keys(registrations).map(key => {
let reg = { ...registrations[key], name: key };
if (hasEndpoint(reg)) {
endpointedRegistrations[key] = reg;
}
});

Object.keys(endpointedRegistrations).map(key => {
sagaList.push(
sagaFactory(endpointedRegistrations[key], endpointedRegistrations)()
);
});

return sagaList;
};

/**
* ~~~~~For example~~~~~
*
*
* interface CreateUserBody {
* first_name: string;
* last_name: string;
* public_serial_number: string;
* // And so on
* }
*
* interface ModifyUserBody {
* //Defaults to the same as create body, but can be different
* first_name: string;
* last_name: string;
* some_other_thing: boolean;
* // And so on
* }
*
* interface SempoObjects extends RegistrationMapping {
* UserExample: Registration<CreateUserBody, ModifyUserBody>;
* }
*
* export const sempoObjects: SempoObjects = {
* UserExample: {
* name: "UserExample",
* endpoint: "user",
* schema: userSchema
* }
*
* let baseReducers = createReducers(sempoObjects);
* let sagalist = createSagas(sempoObjects);
*
* const appReducer = combineReducers({
* ...baseReducers
* });
*
* export default function* rootSaga() {
* yield all([
* generatedSagas()
* ]);
* }
*
* Then for dispatch in component:
* const mapDispatchToProps = (dispatch: any): DispatchProps => {
* return {
* loadUsers: () => dispatch(apiActions.load(sempoObjects.UserExample))
* }
* }
*/
77 changes: 77 additions & 0 deletions app/client/genericState/reducers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { DEEEEEEP } from "../utils";
import {
APILifecycleActionTypesInterface,
byIdState,
IdListState,
Registration,
RequestingState
} from "./types";
import {
deepUpdateObjectsActionType,
replaceIdListActionType,
replaceUpdateObjectsActionType
} from "./actions";

export const lifecycleReducerFactory = (
actionType: APILifecycleActionTypesInterface,
reg: Registration
): ((state: RequestingState | undefined, action: any) => RequestingState) => {
const initialLoaderState = {
isRequesting: false,
success: false,
error: null
};
return (
state: RequestingState | undefined = initialLoaderState,
action: any
): RequestingState => {
switch (action.type) {
case actionType.request(reg.name):
return { ...state, isRequesting: true };

case actionType.success(reg.name):
return { ...state, isRequesting: false, success: true };

case actionType.failure(reg.name):
return {
...state,
isRequesting: false,
success: false,
error: action.error
};

default:
return state;
}
};
};

export const byIdReducerFactory = (
reg: Registration
): ((state: byIdState | undefined, action: any) => byIdState) => {
return (state: byIdState | undefined = {}, action: any): byIdState => {
switch (action.type) {
case deepUpdateObjectsActionType(reg.name):
return DEEEEEEP(state, action.objects);

case replaceUpdateObjectsActionType(reg.name):
return action.objects;

default:
return state;
}
};
};

export const idListReducerFactory = (
reg: Registration
): ((state: IdListState | undefined, action: any) => IdListState) => {
return (state: IdListState | undefined = [], action: any): IdListState => {
switch (action.type) {
case replaceIdListActionType(reg.name):
return (state = action.idList);
default:
return state;
}
};
};
Loading

0 comments on commit 1d7d896

Please sign in to comment.