diff --git a/client/.eslintrc.yml b/client/.eslintrc.yml index 86855c6..732235f 100644 --- a/client/.eslintrc.yml +++ b/client/.eslintrc.yml @@ -22,6 +22,7 @@ globals: rules: import/no-named-as-default: 0 + import/prefer-default-export: 0 no-console: 0 no-param-reassign: 0 no-shadow: 0 # FIXME extract generic Edit component diff --git a/client/src/api/client.js b/client/src/api/client.js index 68b3c0c..a183a67 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -11,18 +11,11 @@ import { zipObject, } from 'lodash'; -import { denormalize, normalize } from './normalize'; +import { normalize } from './normalize'; -export const GET_ONE = 'GET_ONE'; -export const GET_LIST = 'GET_LIST'; -export const GET_MANY = 'GET_MANY'; -export const CREATE = 'CREATE'; -export const UPDATE = 'UPDATE'; -export const DELETE = 'DELETE'; -export const AUTH_LOGIN = 'AUTH_LOGIN'; -export const AUTH_LOGOUT = 'AUTH_LOGOUT'; +export { denormalize } from './normalize'; -const client = axios.create({ +export const client = axios.create({ baseURL: '/', headers: { Accept: 'application/vnd.api+json', @@ -56,9 +49,9 @@ client.interceptors.request.use( const stringifyParams = params => qs.stringify(params, { format: 'RFC1738', arrayFormat: 'brackets' }); -const withParams = (url, params) => `${url}?${stringifyParams(params)}`; +export const withParams = (url, params) => `${url}?${stringifyParams(params)}`; -const normalizeResponse = (response) => { +export const normalizeResponse = (response) => { const { data = [], included = [] } = response.data; const dataByType = groupBy(castArray(data).concat(included), 'type'); @@ -73,7 +66,7 @@ const normalizeResponse = (response) => { })); }; -const normalizeErrors = (response) => { +export const normalizeErrors = (response) => { throw get(response, 'response.data.errors') .reduce((errors, error) => { const attribute = /\/data\/[a-z]*\/(.*)$/.exec(get(error, 'source.pointer'))[1]; @@ -81,63 +74,3 @@ const normalizeErrors = (response) => { return errors; }, {}); }; - -export default (requestType, payload, meta) => { - const { - url = `${meta.key}`, - include, - } = meta; - - const params = payload; - - switch (requestType) { - case GET_ONE: - return client({ - url: withParams(`${url}/${payload.id}`, params), - method: 'GET', - data: JSON.stringify(payload), - }).then(normalizeResponse); - case GET_MANY: - case GET_LIST: - return client({ - url: withParams(`${url}`, params), - method: 'GET', - data: JSON.stringify(payload), - }).then(normalizeResponse).then(res => ({ ...res, params })); - case CREATE: - return client({ - url: withParams(url, { include }), - method: 'POST', - data: denormalize(meta.key, payload), - }).then(normalizeResponse).catch(normalizeErrors); - case UPDATE: { - return client({ - url: withParams(`${url}/${payload.id}`, { include }), - method: 'PUT', - data: denormalize(meta.key, payload), - }).then(normalizeResponse).catch(normalizeErrors); - } - case DELETE: - return client({ - url: withParams(`${url}/${payload.id}`), - method: 'DELETE', - }).then(() => ({ data: payload })); - case AUTH_LOGIN: - return client({ - url: 'auth/sign_in', - method: 'POST', - data: payload, - }).then(response => ({ - ...response.data.data, - ...pick(response.headers, ['access-token', 'client']), - })); - case AUTH_LOGOUT: - return client({ - url: 'auth/sign_out', - method: 'DELETE', - data: payload, - }); - default: - throw new Error(`No client handler for ${requestType}`); - } -}; diff --git a/client/src/api/index.js b/client/src/api/index.js index bd479c8..4f1cce4 100644 --- a/client/src/api/index.js +++ b/client/src/api/index.js @@ -1,2 +1 @@ export * from './client'; -export client from './client'; diff --git a/client/src/components/App.js b/client/src/components/App.js index 995258f..d588445 100644 --- a/client/src/components/App.js +++ b/client/src/components/App.js @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import { isEmpty } from 'lodash'; import { Collapse, Container, Navbar, NavbarToggler, Nav, NavItem, NavLink } from 'reactstrap'; -import { getUser, logout } from '../store/api'; +import { getUser, logout } from '../store/auth'; export class App extends Component { state = { diff --git a/client/src/components/Auth/Login.js b/client/src/components/Auth/Login.js index 0c92b7b..1dba9cd 100644 --- a/client/src/components/Auth/Login.js +++ b/client/src/components/Auth/Login.js @@ -5,7 +5,7 @@ import { SubmissionError } from 'redux-form'; import { CardSingle } from '../UI'; import LoginForm from './LoginForm'; -import { login } from '../../store/api'; +import { login } from '../../store/auth'; export class Login extends Component { onSubmit = values => this.props.login(values) diff --git a/client/src/components/Routes.js b/client/src/components/Routes.js index 26bdd96..18ded5b 100644 --- a/client/src/components/Routes.js +++ b/client/src/components/Routes.js @@ -2,7 +2,7 @@ import React, { PureComponent, PropTypes } from 'react'; import { Router, Route, IndexRoute } from 'react-router'; import { UserAuthWrapper } from 'redux-auth-wrapper'; -import { getUser } from '../store/api'; +import { getUser } from '../store/auth'; import App from './App'; import Dashboard from './Dashboard'; import { PostList, PostEdit } from './Posts'; diff --git a/client/src/store/api/actions.js b/client/src/store/api/actions.js index 9fcbdb5..d5af28d 100644 --- a/client/src/store/api/actions.js +++ b/client/src/store/api/actions.js @@ -1,47 +1,51 @@ import { - GET_ONE, - GET_LIST, - GET_MANY, - CREATE, - UPDATE, - DELETE, - AUTH_LOGIN, - AUTH_LOGOUT, client, + withParams, + normalizeResponse, + normalizeErrors, + denormalize, } from '../../api'; -export const STARTED = 'STARTED'; -export const SUCCESS = 'SUCCESS'; -export const FAILED = 'FAILED'; +import { + createAsyncActionType, + createAsyncAction, +} from '../utils'; + +export const GET_ONE = createAsyncActionType('GET_ONE'); +export const GET_LIST = createAsyncActionType('GET_LIST'); +export const GET_MANY = createAsyncActionType('GET_MANY'); +export const CREATE = createAsyncActionType('CREATE'); +export const UPDATE = createAsyncActionType('UPDATE'); +export const DELETE = createAsyncActionType('DELETE'); -export const actionType = (request, status) => `@@api/${request}/${status}`; +export const fetchOne = createAsyncAction(GET_ONE, (payload, meta) => client({ + url: withParams(meta.url, { include: meta.include, ...payload }), + method: 'GET', + data: JSON.stringify(payload), +}).then(normalizeResponse)); -const createAction = (request, status) => (key, payload, meta = {}) => ({ - type: actionType(request, status), - payload, - meta: { ...meta, status }, - error: status === FAILED ? true : undefined, +export const fetchList = createAsyncAction(GET_LIST, (payload, meta) => { + const params = { include: meta.include, ...payload }; + return client({ + url: withParams(meta.url, params), + method: 'GET', + data: JSON.stringify(payload), + }).then(normalizeResponse).then(res => ({ ...res, params })); }); -const createAsyncAction = request => (key, payload = {}, _meta = {}) => (dispatch) => { - const meta = { ..._meta, key, request }; - dispatch(createAction(request, STARTED)(key, payload, meta)); - return client(request, payload, meta) - .then((response) => { - dispatch(createAction(request, SUCCESS)(key, response, meta)); - return response; - }) - .catch((error) => { - dispatch(createAction(request, FAILED)(key, error, meta)); - throw error; - }); -}; +export const createResource = createAsyncAction(CREATE, (payload, meta) => client({ + url: withParams(meta.url, { include: meta.include }), + method: 'POST', + data: denormalize(meta.key, payload), +}).then(normalizeResponse).catch(normalizeErrors)); + +export const updateResource = createAsyncAction(UPDATE, (payload, meta) => client({ + url: withParams(`${meta.url}/${payload.id}`, { include: meta.include }), + method: 'PUT', + data: denormalize(meta.key, payload), +}).then(normalizeResponse).catch(normalizeErrors)); -export const fetchOne = createAsyncAction(GET_ONE); -export const fetchList = createAsyncAction(GET_LIST); -export const fetchMany = createAsyncAction(GET_MANY); -export const createResource = createAsyncAction(CREATE); -export const updateResource = createAsyncAction(UPDATE); -export const deleteResource = createAsyncAction(DELETE); -export const login = createAsyncAction(AUTH_LOGIN); -export const logout = createAsyncAction(AUTH_LOGOUT); +export const deleteResource = createAsyncAction(DELETE, (payload, meta) => client({ + url: withParams(`${meta.url}/${payload.id}`), + method: 'DELETE', +}).then(() => ({ data: payload }))); diff --git a/client/src/store/api/reducer.js b/client/src/store/api/reducer.js index 1e923ce..267e791 100644 --- a/client/src/store/api/reducer.js +++ b/client/src/store/api/reducer.js @@ -1,10 +1,11 @@ import imm from 'object-path-immutable'; import { get, - map, - keys, - keyBy, isEmpty, + keyBy, + keys, + map, + pick, without, } from 'lodash'; @@ -15,21 +16,8 @@ import { CREATE, UPDATE, DELETE, - AUTH_LOGIN, - AUTH_LOGOUT, -} from '../../api'; - -import { - STARTED, - SUCCESS, - FAILED, - actionType, } from './actions'; -const initialState = { - user: JSON.parse(localStorage.getItem('user') || '{}'), -}; - const addNormalized = (newState, payload) => { keys(payload.normalized).forEach((key) => { payload.normalized[key].forEach((item) => { @@ -39,57 +27,47 @@ const addNormalized = (newState, payload) => { return newState; }; -export default (state = initialState, action) => { - const { type, payload, meta } = action; +const initialState = {}; + +export default (state = initialState, { type, payload, meta }) => { const { key, list = 'list' } = meta || {}; let newState = state; switch (type) { - case actionType(GET_ONE, SUCCESS): { + case GET_ONE.SUCCESS: { return addNormalized(newState, payload); } - case actionType(GET_LIST, STARTED): { + case GET_LIST.STARTED: { return imm.set(newState, [key, list, 'loading'], true); } - case actionType(GET_LIST, SUCCESS): { + case GET_LIST.SUCCESS: { newState = addNormalized(newState, payload); - newState = imm.set(newState, [key, list, 'ids'], map(payload.data, 'id')); - newState = imm.set(newState, [key, list, 'params'], payload.params); - newState = imm.set(newState, [key, list, 'links'], payload.links); - newState = imm.set(newState, [key, list, 'meta'], payload.meta); - newState = imm.set(newState, [key, list, 'loading'], false); - return newState; + return imm.set(newState, [key, list], { + ids: map(payload.data, 'id'), + loading: false, + ...pick(payload, ['params', 'links', 'meta']), + }); } - case actionType(GET_MANY, SUCCESS): { + case GET_MANY.SUCCESS: { return addNormalized(newState, payload); } - case actionType(CREATE, SUCCESS): { + case CREATE.SUCCESS: { newState = addNormalized(newState, payload); if (list) { newState = imm.push(newState, [key, list, 'ids'], payload.data.id); } return newState; } - case actionType(UPDATE, SUCCESS): { + case UPDATE.SUCCESS: { return addNormalized(newState, payload); } - case actionType(DELETE, SUCCESS): { + case DELETE.SUCCESS: { newState = imm.del(newState, [key, 'byId', payload.data.id]); newState = imm.set(newState, [key, list, 'ids'], without(get(newState, [key, list, 'ids']), payload.data.id), ); return newState; } - case actionType(AUTH_LOGIN, SUCCESS): { - localStorage.setItem('user', JSON.stringify(payload)); - newState = imm.set(newState, ['user'], payload); - return newState; - } - case actionType(AUTH_LOGOUT, SUCCESS): { - localStorage.removeItem('user'); - newState = imm.set(newState, ['user'], {}); - return newState; - } default: return state; } diff --git a/client/src/store/api/selectors.js b/client/src/store/api/selectors.js index b9b7b7e..61c0960 100644 --- a/client/src/store/api/selectors.js +++ b/client/src/store/api/selectors.js @@ -1,7 +1,4 @@ -import { get, isEmpty } from 'lodash'; - -export const getUser = state => - get(state, ['api', 'user']) || {}; +import { compact, get, isEmpty } from 'lodash'; export const getOne = (state, resourceName, id) => get(state, ['api', resourceName, 'byId', id]) || {}; @@ -16,10 +13,20 @@ export const getMany = (state, resourceName, ids) => { : (ids || Object.keys(byId)).map(id => byId[id]); }; +const emptyList = { + data: [], + ids: [], + links: {}, + meta: {}, + params: { page: {}, filter: {} }, + loading: true, +}; + export const getList = (state, resourceName, listName = 'list') => { const byId = get(state, ['api', resourceName, 'byId']) || {}; const list = get(state, ['api', resourceName, listName]) || {}; + return !list.ids - ? { data: [], ids: [], links: {}, params: { page: {}, filter: {} }, loading: true, empty: true } - : { ...list, empty: false, data: list.ids.map(id => byId[id]) }; + ? { ...emptyList, empty: true } + : { ...emptyList, empty: false, ...list, data: compact(list.ids.map(id => byId[id])) }; }; diff --git a/client/src/store/auth/actions.js b/client/src/store/auth/actions.js new file mode 100644 index 0000000..c80bcb0 --- /dev/null +++ b/client/src/store/auth/actions.js @@ -0,0 +1,22 @@ +import { pick } from 'lodash'; + +import { client } from '../../api'; +import { createAsyncActionType, createAsyncAction } from '../utils'; + +export const AUTH_LOGIN = createAsyncActionType('AUTH_LOGIN'); +export const AUTH_LOGOUT = createAsyncActionType('AUTH_LOGOUT'); + +export const login = createAsyncAction(AUTH_LOGIN, payload => client({ + url: 'auth/sign_in', + method: 'POST', + data: payload, +}).then(response => ({ + ...response.data.data, + ...pick(response.headers, ['access-token', 'client']), +}))); + +export const logout = createAsyncAction(AUTH_LOGOUT, payload => client({ + url: 'auth/sign_out', + method: 'DELETE', + data: payload, +})); diff --git a/client/src/store/auth/index.js b/client/src/store/auth/index.js new file mode 100644 index 0000000..f230fb1 --- /dev/null +++ b/client/src/store/auth/index.js @@ -0,0 +1,2 @@ +export * from './actions'; +export * from './selectors'; diff --git a/client/src/store/auth/reducer.js b/client/src/store/auth/reducer.js new file mode 100644 index 0000000..4520ca9 --- /dev/null +++ b/client/src/store/auth/reducer.js @@ -0,0 +1,25 @@ +import imm from 'object-path-immutable'; + +import { + AUTH_LOGIN, + AUTH_LOGOUT, +} from './actions'; + +const initialState = { + user: JSON.parse(localStorage.getItem('user') || '{}'), +}; + +export default (state = initialState, { type, payload }) => { + switch (type) { + case AUTH_LOGIN.SUCCESS: { + localStorage.setItem('user', JSON.stringify(payload)); + return imm.set(state, ['user'], payload); + } + case AUTH_LOGOUT.SUCCESS: { + localStorage.removeItem('user'); + return imm.set(state, ['user'], {}); + } + default: + return state; + } +}; diff --git a/client/src/store/auth/selectors.js b/client/src/store/auth/selectors.js new file mode 100644 index 0000000..940fdc5 --- /dev/null +++ b/client/src/store/auth/selectors.js @@ -0,0 +1,4 @@ +import { get } from 'lodash'; + +export const getUser = state => + get(state, ['auth', 'user']) || {}; diff --git a/client/src/store/configureStore.js b/client/src/store/configureStore.js index 40fc73d..07f5ab0 100644 --- a/client/src/store/configureStore.js +++ b/client/src/store/configureStore.js @@ -3,6 +3,7 @@ import thunkMiddleware from 'redux-thunk'; import { routerReducer, routerMiddleware } from 'react-router-redux'; import { reducer as formReducer } from 'redux-form'; import api from './api/reducer'; +import auth from './auth/reducer'; const composeEnhancers = process.env.NODE_ENV === 'production' @@ -13,6 +14,7 @@ const composeEnhancers = export default (history) => { const reducer = combineReducers({ api, + auth, routing: routerReducer, form: formReducer, }); diff --git a/client/src/store/utils.js b/client/src/store/utils.js new file mode 100644 index 0000000..e0cae46 --- /dev/null +++ b/client/src/store/utils.js @@ -0,0 +1,21 @@ +export const createAsyncActionType = actionName => ['STARTED', 'SUCCESS', 'FAILED'] + .reduce((acc, status) => ({ ...acc, [status]: `@@api/${actionName}/${status}` }), {}); + +export const createAction = type => (payload, meta = {}) => ({ + type, payload, meta, error: type.endsWith('FAILED') ? true : undefined, +}); + +export const createAsyncAction = (actionType, requestFn) => + (key, payload = {}, _meta = {}) => (dispatch) => { + const meta = { ..._meta, key, url: (_meta.url || key) }; + dispatch(createAction(actionType.STARTED)(payload, meta)); + return requestFn(payload, meta) + .then((response) => { + dispatch(createAction(actionType.SUCCESS)(response, meta)); + return response; + }) + .catch((error) => { + dispatch(createAction(actionType.FAILED)(error, meta)); + throw error; + }); + };