Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client/.eslintrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 6 additions & 73 deletions client/src/api/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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');

Expand All @@ -73,71 +66,11 @@ 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];
set(errors, attribute.split('/'), error.title);
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}`);
}
};
1 change: 0 additions & 1 deletion client/src/api/index.js
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
export * from './client';
export client from './client';
2 changes: 1 addition & 1 deletion client/src/components/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Auth/Login.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
80 changes: 42 additions & 38 deletions client/src/store/api/actions.js
Original file line number Diff line number Diff line change
@@ -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 })));
60 changes: 19 additions & 41 deletions client/src/store/api/reducer.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import imm from 'object-path-immutable';
import {
get,
map,
keys,
keyBy,
isEmpty,
keyBy,
keys,
map,
pick,
without,
} from 'lodash';

Expand All @@ -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) => {
Expand All @@ -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;
}
Expand Down
19 changes: 13 additions & 6 deletions client/src/store/api/selectors.js
Original file line number Diff line number Diff line change
@@ -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]) || {};
Expand All @@ -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])) };
};
22 changes: 22 additions & 0 deletions client/src/store/auth/actions.js
Original file line number Diff line number Diff line change
@@ -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,
}));
Loading