Skip to content

Instantly share code, notes, and snippets.

@austinmao
Last active February 24, 2016 04:23
Show Gist options
  • Save austinmao/2b6835414ba740911a5f to your computer and use it in GitHub Desktop.
Save austinmao/2b6835414ba740911a5f to your computer and use it in GitHub Desktop.
connects to server api specified in helpers/apis, loads, normalizes (flattens) the data, and then stores in redux
/**
* @name apis.js
* @fileOverview api endpoints and schemas
* @exports {endpoint, schema, entity} for each api
*/
import _ from 'lodash'; // eslint-disable-line id-length
import schema, {entities} from 'helpers/normalize';
const singularEndpoints = {
campaign: { endpoint: '/campaign' },
creative: { endpoint: '/creative' },
image: { endpoint: '/image' },
imageTemplate: { endpoint: '/imageTemplate' },
layer: { endpoint: '/layer' },
layerTemplate: { endpoint: '/layerTemplate' },
photoItem: { endpoint: '/photoItem' },
textItem: { endpoint: '/textItem' },
drawingItem: { endpoint: '/drawingItem' },
photoItemVersion: { endpoint: '/photoItem/version' },
};
// create object of plural version of singularEndpoints
const pluralEndpoints = _.mapKeys(_.cloneDeep(singularEndpoints), (val, key) => entities[key]);
// create apis object that schemas will be added to
const apis = {
...singularEndpoints,
...pluralEndpoints
};
// add {schema, entity} to each object
Object.keys(apis).forEach(key => {
// set { api.schema: schema[API] }
apis[key].schema = schema[key];
// set module for tracking whether or not the entity is loaded
apis[key].module = key;
// set where data can be found in state.data.entities[key]
// this makes it plural if it's not already plural
apis[key].entity = key.slice(-1) === 's' ? key : key + 's';
});
export default apis;
import {normalize} from 'normalizr';
export default function clientMiddleware(client) {
return ({dispatch, getState}) => next => action => {
if (typeof action === 'function') {
return action(dispatch, getState);
}
const { promise, types, schema, ...rest } = action; // eslint-disable-line no-redeclare
if (!promise) {
return next(action);
}
const [REQUEST, SUCCESS, FAILURE] = types;
next({...rest, type: REQUEST});
const actionPromise = promise(client);
actionPromise.then(
(result) => next({
...rest,
// normalize result if schema is passed
result: schema ? normalize(result, schema) : result,
type: SUCCESS
}),
(error) => next({...rest, error, type: FAILURE})
).catch((error)=> {
console.error('MIDDLEWARE ERROR:', error);
next({...rest, error, type: FAILURE});
});
return actionPromise;
};
}
/**
* @name connectApi
* @fileOverview connects to server api specified in helpers/apis, loads,
* normalizes (flattens) the data, and then stores in redux
*/
import React, { Component } from 'react';
import {load} from 'redux/modules/data';
import _ from 'lodash'; // eslint-disable-line id-length
import {asyncConnect} from 'redux-async-connect';
/**
* async load data. Note: When this decorator is used, it MUST be the first (outermost) decorator.
* Otherwise, we cannot find and call the fetchData and fetchDataDeffered methods.
* @param {object} api - store entities path, api endpoint, and schema
* @param {bool} shouldDefer - whether to load before or after page load. defaults to true.
* @param {bool} shoulReload - whether to always load data even if it is already in state. defaults to false.
* @param {object} paramsMap - map params from redux-router to construct api request
* @example { id: 'campaignId' } will perform api request with id equal to `campaigns/:campaignId` in url path
* @return {func} - decorated component
*/
export default function connectApi(api, paramsMap, shouldReload = false) {
return function wrapWithFetchData(WrappedComponent) {
/* set static function to dispatch loading of api endpoint */
@asyncConnect([{
deferred: true,
promise: (options) => {
const {
store: {dispatch, getState},
params // url params
} = options;
const {
data: {isLoading, isLoaded}, // data state
} = getState(); // current state
const {entity} = api; // entity to load
// map the url params to an api request object
const query = {};
if (paramsMap) {
/**
* @function creates query object to request data from server
* @param {string} paramKey - name of param from redux-router. e.g., 'campaignId'
* @param {string} apiKey - name of key in server. e.g., 'id'
*/
_.forEach(paramsMap, (paramKey, apiKey) => {
query[apiKey] = params[paramKey];
});
}
// load if data is not loaded or shouldReload is passed
if (!isLoaded[entity] || !isLoading[entity] || shouldReload) {
return dispatch(load(api, query)); // load from server api with constructed query
}
}
}])
class ConnectApi extends Component {
render() {
const wrappedProps = {api, paramsMap};
return <WrappedComponent {...this.props} {...wrappedProps} />;
}
}
return ConnectApi;
};
}
/**
* data.duck.js
* reducers for async isLoading and isSaving of data from api endpoints
*/
import {api as apis} from 'helpers/apis';
import _ from 'lodash'; // eslint-disable-line id-length
const LOAD = 'aditive/data/LOAD';
const LOAD_SUCCESS = 'aditive/data/LOAD_SUCCESS';
const LOAD_FAIL = 'aditive/data/LOAD_FAIL';
const SAVE = 'aditive/data/SAVE';
const SAVE_SUCCESS = 'aditive/data/SAVE_SUCCESS';
const SAVE_FAIL = 'aditive/data/SAVE_FAIL';
const DEL = 'aditive/data/DEL';
const DEL_SUCCESS = 'aditive/data/DEL_SUCCESS';
const DEL_FAIL = 'aditive/data/DEL_FAIL';
// set initial state with isLoaded off and blank entities
const initialState = (() => {
// set empty entities
const isLoading = {};
const isLoaded = {};
const entities = {};
// make every entity not isLoaded and set their initial data as empty
_.forEach(apis, ent => {
isLoading[ent.module] = false;
isLoaded[ent.module] = false;
entities[ent.entity] = {};
});
return {
isLoading,
isLoaded,
entities,
loadError: {},
saveError: {},
result: {},
};
})();
export default function reducer(state = initialState, action = {}) {
const {module, result} = action;
// merge entities into entities state without overwriting previously loaded entities
const ents = {...state.entities};
if (ents && result && result.entities) {
_.forEach(result.entities, (data, ent) => {
ents[ent] = {
...ents[ent],
...data
};
});
}
switch (action.type) {
case LOAD:
return {
...state,
isLoading: {
...state.isLoading,
[module]: true
},
};
case LOAD_SUCCESS:
return {
...state,
isLoading: {
...state.isLoading,
[module]: false,
},
isLoaded: {
...state.isLoaded,
[module]: true,
},
loadError: {
...state.loadError,
[module]: undefined
},
result: {
...state.result,
[module]: [...result.result]
},
entities: ents,
};
case LOAD_FAIL:
// return error or state if not string
return typeof action.error === 'string' ? {
...state,
isLoading: {
...state.isLoading,
[module]: false,
},
isLoaded: {
...state.isLoaded,
[module]: false,
},
loadError: {
...state.loadError,
[module]: action.error,
},
} : state;
case SAVE:
return {
...state,
isSaving: {
...state.isSaving,
[module]: true,
},
};
case SAVE_SUCCESS:
return {
...state,
isSaving: {
...state.isSaving,
[module]: false,
},
isSaved: {
...state.isSaved,
[module]: true,
},
saveError: {
...state.saveError,
[module]: undefined
},
result: {
...state.result,
[module]: [...result.result]
},
entities: ents,
};
case SAVE_FAIL:
return typeof action.error === 'string' ? {
...state,
isSaving: {
...state.isSaving,
[module]: false,
},
isSaved: {
...state.isSaved,
[module]: false,
},
saveError: {
...state.saveError,
[module]: action.error,
},
} : state;
default:
return state;
}
}
/* normalize data responses from api */
export function normalize(api, data) {
const {schema, entity, module} = api;
return {
type: SAVE_SUCCESS,
schema,
entity,
module,
result: {
result: data,
},
};
}
/* async load data and normalize */
export function load(api, params) {
const {endpoint, schema, entity, module} = api;
return {
types: [LOAD, LOAD_SUCCESS, LOAD_FAIL],
promise: (client) => client.get(endpoint, {params}),
schema,
entity,
module
};
}
/* async post data and normalize the response */
export function save(api, method, data) {
const {endpoint, schema, entity, module} = api;
if (method !== 'post' && method !== 'put') {
throw new Error('method needs to be `post` or `put`');
}
return {
types: [SAVE, SAVE_SUCCESS, SAVE_FAIL],
// submit with post or put
promise: (client) => client[method](endpoint, {data}),
schema,
entity,
module
};
}
/* async post data and normalize the response */
export function del(api, id) {
const {endpoint, schema, entity, module} = api;
if (!id || typeof id !== 'string') {
throw new Error('id must be a string');
}
return {
types: [DEL, DEL_SUCCESS, DEL_FAIL],
// submit with post or put
promise: (client) => client.del(endpoint + '/' + id),
schema,
entity,
module
};
}
import {Schema, arrayOf} from 'normalizr';
// We use this Normalizr schemas to transform API responses from a nested form
// to a flat form where repos and users are placed in `entities`, and nested
// JSON objects are replaced with their IDs. This is very convenient for
// consumption by reducers, because we can easily build a normalized tree
// and keep it updated as we fetch more data.
// Read more about Normalizr: https://github.com/gaearon/normalizr
/**
* new schemas
*/
/* STEP 1: */
// edit entities to create new schemas for normalizr
// format is { [schema]: [data.entity] } which is also { singular: plural }
// TODO: add user, creator, etc.
export const entities = {
campaign: 'campaigns',
creative: 'creatives',
image: 'images',
imageTemplate: 'imageTemplates',
layer: 'layers',
layerTemplate: 'layerTemplates',
photoItem: 'photoItems',
photoItemVersion: 'photoItemVersions',
textItem: 'textItems',
drawingItem: 'drawingItems',
};
/* schema for API responses */
// set consistent idAttribute
const id = { idAttribute: 'id' };
// create schema for each of entities. set the name of the schema as the plural version.
const schema = {};
Object.keys(entities).forEach(key => {
schema[key] = new Schema(entities[key], id);
});
/* STEP 2: define nested schemas for normalizr */
// set different possible items that layers can have
const layerItems = {
_photoItem: schema.photoItem,
_textItem: schema.textItem,
_drawingItem: schema.drawingItem,
};
schema.image.define({ campaign: schema.campaign, layers: arrayOf(schema.layer) });
schema.imageTemplate.define({ layers: arrayOf(schema.layerTemplate) });
schema.creative.define({ campaign: schema.campaign, images: arrayOf(schema.image) });
// set photoitemSchema before layers add items to their definition
schema.photoItem.define({ versions: arrayOf(schema.photoItemVersion) });
schema.layer.define({...layerItems, _layerTemplate: schema.layerTemplate});
schema.layerTemplate.define({ ...layerItems});
/* add plural keys to schema as arrayOf each schema. for example, image will then be arrayOf(image) */
const singularEntities = Object.keys(entities);
singularEntities.forEach(singularEntity => {
const pluralEntity = entities[singularEntity]; // get name of pluralEntity
const singularSchema = schema[singularEntity]; // get schema of singularEntity
schema[pluralEntity] = arrayOf(singularSchema); // set plural key as [singularSchema]
});
export default schema;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment