Last active
February 24, 2016 04:23
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @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; | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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 | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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