Merge pull request #48 from sharetribe/sdk-duck

SDK duck
This commit is contained in:
Kimmo Puputti 2017-02-22 11:22:19 +02:00 committed by GitHub
commit d08c44e3a1
7 changed files with 422 additions and 3 deletions

View file

@ -8,5 +8,6 @@ import { reducer as form } from 'redux-form';
import Auth from './Auth.duck';
import FlashNotification from './FlashNotification.duck';
import LocationFilter from './LocationFilter.duck';
import sdkReducer from './sdk.duck';
export { form, Auth, FlashNotification, LocationFilter };
export { form, Auth, FlashNotification, LocationFilter, sdkReducer as data };

172
src/ducks/sdk.duck.js Normal file
View file

@ -0,0 +1,172 @@
/* eslint-disable no-constant-condition, no-console */
import { call, put, take, fork } from 'redux-saga/effects';
import { updatedEntities } from '../util/data';
const requestAction = actionType => params => ({ type: actionType, payload: { params } });
const successAction = actionType => data => ({ type: actionType, payload: data });
const errorAction = actionType => error => ({ type: actionType, payload: error, error: true });
// ================ Action types ================ //
export const SHOW_LISTINGS_REQUEST = 'app/sdk/SHOW_LISTINGS_REQUEST';
export const SHOW_LISTINGS_SUCCESS = 'app/sdk/SHOW_LISTINGS_SUCCESS';
export const SHOW_LISTINGS_ERROR = 'app/sdk/SHOW_LISTINGS_ERROR';
export const QUERY_LISTINGS_REQUEST = 'app/sdk/QUERY_LISTINGS_REQUEST';
export const QUERY_LISTINGS_SUCCESS = 'app/sdk/QUERY_LISTINGS_SUCCESS';
export const QUERY_LISTINGS_ERROR = 'app/sdk/QUERY_LISTINGS_ERROR';
export const SEARCH_LISTINGS_REQUEST = 'app/sdk/SEARCH_LISTINGS_REQUEST';
export const SEARCH_LISTINGS_SUCCESS = 'app/sdk/SEARCH_LISTINGS_SUCCESS';
export const SEARCH_LISTINGS_ERROR = 'app/sdk/SEARCH_LISTINGS_ERROR';
export const SHOW_MARKETPLACE_REQUEST = 'app/sdk/SHOW_MARKETPLACE_REQUEST';
export const SHOW_MARKETPLACE_SUCCESS = 'app/sdk/SHOW_MARKETPLACE_SUCCESS';
export const SHOW_MARKETPLACE_ERROR = 'app/sdk/SHOW_MARKETPLACE_ERROR';
export const SHOW_USERS_REQUEST = 'app/sdk/SHOW_USERS_REQUEST';
export const SHOW_USERS_SUCCESS = 'app/sdk/SHOW_USERS_SUCCESS';
export const SHOW_USERS_ERROR = 'app/sdk/SHOW_USERS_ERROR';
// ================ Reducer ================ //
const initialState = {
// Error instance placeholders for each endpoint
showListingsError: null,
queryListingsError: null,
searchListingsError: null,
showMarketplaceError: null,
showUsersError: null,
// Database of all the fetched entities.
entities: {},
};
const merge = (state, payload) => {
const entities = updatedEntities(state.entities, payload.data);
return { ...state, entities };
};
export default function sdkReducer(state = initialState, action = {}) {
const { type, payload } = action;
switch (type) {
case SHOW_LISTINGS_REQUEST:
return { ...state, showListingsError: null };
case SHOW_LISTINGS_SUCCESS:
return merge(state, payload);
case SHOW_LISTINGS_ERROR:
console.error(payload);
return { ...state, showListingsError: payload };
case QUERY_LISTINGS_REQUEST:
return { ...state, queryListingsError: null };
case QUERY_LISTINGS_SUCCESS:
return merge(state, payload);
case QUERY_LISTINGS_ERROR:
console.error(payload);
return { ...state, queryListingsError: payload };
case SEARCH_LISTINGS_REQUEST:
return { ...state, searchListingsError: null };
case SEARCH_LISTINGS_SUCCESS:
return merge(state, payload);
case SEARCH_LISTINGS_ERROR:
console.error(payload);
return { ...state, searchListingsError: payload };
case SHOW_MARKETPLACE_REQUEST:
return { ...state, showMarketplaceError: null };
case SHOW_MARKETPLACE_SUCCESS:
return merge(state, payload);
case SHOW_MARKETPLACE_ERROR:
console.error(payload);
return { ...state, showMarketplaceError: payload };
case SHOW_USERS_REQUEST:
return { ...state, showUsersError: null };
case SHOW_USERS_SUCCESS:
return merge(state, payload);
case SHOW_USERS_ERROR:
console.error(payload);
return { ...state, showUsersError: payload };
default:
return state;
}
}
// ================ Action creators ================ //
export const showListings = requestAction(SHOW_LISTINGS_REQUEST);
export const showListingsSuccess = successAction(SHOW_LISTINGS_SUCCESS);
export const showListingsError = errorAction(SHOW_LISTINGS_ERROR);
export const queryListings = requestAction(QUERY_LISTINGS_REQUEST);
export const queryListingsSuccess = successAction(QUERY_LISTINGS_SUCCESS);
export const queryListingsError = errorAction(QUERY_LISTINGS_ERROR);
export const searchListings = requestAction(SEARCH_LISTINGS_REQUEST);
export const searchListingsSuccess = successAction(SEARCH_LISTINGS_SUCCESS);
export const searchListingsError = errorAction(SEARCH_LISTINGS_ERROR);
export const showMarketplace = requestAction(SHOW_MARKETPLACE_REQUEST);
export const showMarketplaceSuccess = successAction(SHOW_MARKETPLACE_SUCCESS);
export const showMarketplaceError = errorAction(SHOW_MARKETPLACE_ERROR);
export const showUsers = requestAction(SHOW_USERS_REQUEST);
export const showUsersSuccess = successAction(SHOW_USERS_SUCCESS);
export const showUsersError = errorAction(SHOW_USERS_ERROR);
// ================ Worker sagas ================ //
function* callEndpoint(sdkMethod, success, error, action) {
const params = action.payload.params;
try {
const response = yield call(sdkMethod, params);
yield put(success(response));
} catch (e) {
yield put(error(e));
}
}
// ================ Watcher sagas ================ //
// TODO: Think about structuring this file to avoid repetition without
// too much metaprogramming that makes the code harder to understand.
const endpoints = {
[SHOW_LISTINGS_REQUEST]: {
method: sdk => sdk.listings.show,
success: showListingsSuccess,
error: showListingsError,
},
[QUERY_LISTINGS_REQUEST]: {
method: sdk => sdk.listings.query,
success: queryListingsSuccess,
error: queryListingsError,
},
[SEARCH_LISTINGS_REQUEST]: {
method: sdk => sdk.listings.search,
success: searchListingsSuccess,
error: searchListingsError,
},
[SHOW_MARKETPLACE_REQUEST]: {
method: sdk => sdk.marketplace.show,
success: showMarketplaceSuccess,
error: showMarketplaceError,
},
[SHOW_USERS_REQUEST]: {
method: sdk => sdk.users.show,
success: showUsersSuccess,
error: showUsersError,
},
};
export function* watchSdk(sdk) {
const requestTypes = Object.keys(endpoints);
while (true) {
const action = yield take(requestTypes);
const { method, success, error } = endpoints[action.type];
yield fork(callEndpoint, method(sdk), success, error, action);
}
}

View file

@ -13,12 +13,21 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { bindActionCreators } from 'redux';
import { createInstance, types } from 'sharetribe-sdk';
import { ClientApp, renderApp } from './app';
import configureStore from './store';
import { matchPathname } from './util/routes';
import * as sample from './util/sample';
import createRootSaga from './sagas';
import config from './config';
import {
showListings,
queryListings,
searchListings,
showMarketplace,
showUsers,
} from './ducks/sdk.duck';
import './index.css';
@ -35,7 +44,11 @@ if (typeof window !== 'undefined') {
// Expose stuff for the browser REPL
if (config.dev) {
window.app = { config, sdk, sdkTypes: types };
const actions = bindActionCreators(
{ showListings, queryListings, searchListings, showMarketplace, showUsers },
store.dispatch,
);
window.app = { config, sdk, sdkTypes: types, actions, store, sample };
}
}

View file

@ -1,8 +1,9 @@
import { watchAuth } from './ducks/Auth.duck';
import { watchLoadListings } from './containers/SearchPage/SearchPage.duck';
import { watchSdk } from './ducks/sdk.duck';
const createRootSaga = sdk => function* rootSaga() {
yield [watchAuth(sdk), watchLoadListings(sdk)];
yield [watchAuth(sdk), watchLoadListings(sdk), watchSdk(sdk)];
};
export default createRootSaga;

54
src/util/data.js Normal file
View file

@ -0,0 +1,54 @@
/**
* Combine the given relationships objects
*
* See: http://jsonapi.org/format/#document-resource-object-relationships
*/
export const combinedRelationships = (oldRels, newRels) => {
if (!oldRels && !newRels) {
// Special case to avoid adding an empty relationships object when
// none of the resource objects had any relationships.
return null;
}
return { ...oldRels, ...newRels };
};
/**
* Combine the given resource objects
*
* See: http://jsonapi.org/format/#document-resource-objects
*/
export const combinedResourceObjects = (oldRes, newRes) => {
const { id, type } = oldRes;
if (newRes.id.uuid !== id.uuid || newRes.type !== type) {
throw new Error('Cannot merge resource objects with different ids or types');
}
const attributes = newRes.attributes || oldRes.attributes;
const attrs = attributes ? { attributes } : null;
const relationships = combinedRelationships(oldRes.relationships, newRes.relationships);
const rels = relationships ? { relationships } : null;
return { id, type, ...attrs, ...rels };
};
/**
* Combine the resource objects form the given api response to the
* existing entities.
*/
export const updatedEntities = (oldEntities, apiResponse) => {
const { data, included = [] } = apiResponse;
const objects = (Array.isArray(data) ? data : [data]).concat(included);
/* eslint-disable no-param-reassign */
const newEntities = objects.reduce(
(entities, curr) => {
const { id, type } = curr;
entities[type] = entities[type] || {};
const entity = entities[type][id.uuid];
entities[type][id.uuid] = entity ? combinedResourceObjects(entity, curr) : curr;
return entities;
},
oldEntities,
);
/* eslint-enable no-param-reassign */
return newEntities;
};

165
src/util/data.test.js Normal file
View file

@ -0,0 +1,165 @@
import { types } from 'sharetribe-sdk';
import { combinedRelationships, combinedResourceObjects, updatedEntities } from './data';
const { UUID } = types;
describe('data utils', () => {
describe('combinedRelationships()', () => {
it('handles no rels', () => {
expect(combinedRelationships(undefined, undefined)).toEqual(null);
});
it('takes existings rels if no new rels are given', () => {
const listingRels = {
author: { data: { id: new UUID('author-id'), type: 'author' } },
images: {
data: [
{ id: new UUID('image1'), type: 'image' },
{ id: new UUID('image2'), type: 'image' },
],
},
};
expect(combinedRelationships(listingRels, undefined)).toEqual(listingRels);
});
it('takes new rels if no existing rels are given', () => {
const listingRels = {
author: { data: { id: new UUID('author-id'), type: 'author' } },
images: { data: { id: new UUID('image1'), type: 'image' } },
};
expect(combinedRelationships(undefined, listingRels)).toEqual(listingRels);
});
it('merges two nonempty rels', () => {
const authorRel = { data: { id: new UUID('author-id'), type: 'author' } };
const imagesRel = {
data: [
{ id: new UUID('image1'), type: 'image' },
{ id: new UUID('image2'), type: 'image' },
],
};
const newImagesRel = { data: { id: new UUID('image1'), type: 'image' } };
const reviewsRel = {
data: [
{ id: new UUID('review1'), type: 'review' },
{ id: new UUID('review2'), type: 'review' },
],
};
const oldListingRels = { author: authorRel, images: imagesRel };
const newListingRels = { images: newImagesRel, reviews: reviewsRel };
expect(combinedRelationships(oldListingRels, newListingRels)).toEqual({
author: authorRel,
images: newImagesRel,
reviews: reviewsRel,
});
});
});
describe('combinedResourceObjects()', () => {
it("throws if ids don't match", () => {
const listing1 = { id: new UUID('listing1'), type: 'listing' };
const listing2 = { id: new UUID('listing2'), type: 'listing' };
expect(() => combinedResourceObjects(listing1, listing2)).toThrow(
'Cannot merge resource objects with different ids or types',
);
});
it("throws if types don't match", () => {
const listing1 = { id: new UUID('listing1'), type: 'listing' };
const author1 = { id: new UUID('author1'), type: 'author' };
expect(() => combinedResourceObjects(listing1, author1)).toThrow(
'Cannot merge resource objects with different ids or types',
);
});
it("doesn't add attributes or relationships keys unnecessarily", () => {
const res1 = { id: new UUID('listing1'), type: 'listing' };
const res2 = { id: new UUID('listing1'), type: 'listing' };
const merged = combinedResourceObjects(res1, res2);
expect(merged).toEqual(res1);
const keys = Object.keys(merged).sort();
expect(keys).toEqual(['id', 'type']);
});
it('merges new attributes', () => {
const res1 = {
id: new UUID('listing1'),
type: 'listing',
attributes: { title: 'Listing 1 title', description: 'Listing 1 description' },
};
const res2 = {
id: new UUID('listing1'),
type: 'listing',
attributes: { title: 'Changed title', description: 'Some description' },
};
expect(combinedResourceObjects(res1, res2)).toEqual(res2);
});
it("keeps old attributes if new does't have any", () => {
const res1Attributes = { title: 'Listing 1 title', description: 'Listing 1 description' };
const res2Relationships = { author: { data: { id: new UUID('author1'), type: 'author' } } };
const res1 = { id: new UUID('listing1'), type: 'listing', attributes: res1Attributes };
const res2 = { id: new UUID('listing1'), type: 'listing', relationships: res2Relationships };
expect(combinedResourceObjects(res1, res2)).toEqual({
id: new UUID('listing1'),
type: 'listing',
attributes: res1Attributes,
relationships: res2Relationships,
});
});
});
describe('updatedEntities()', () => {
it('adds a single entity', () => {
const listing1 = { id: new UUID('listing1'), type: 'listing' };
const response = { data: listing1 };
const entities = updatedEntities({}, response);
expect(entities).toEqual({ listing: { listing1 } });
});
it('add multiple entities', () => {
const listing1 = { id: new UUID('listing1'), type: 'listing' };
const listing2 = { id: new UUID('listing2'), type: 'listing' };
const response = { data: [listing1, listing2] };
const entities = updatedEntities({}, response);
expect(entities).toEqual({ listing: { listing1, listing2 } });
});
it('handles a more complex merge', () => {
const listing1 = {
id: new UUID('listing1'),
type: 'listing',
attributes: { title: 'Listing 1 title', description: 'Listing 1 description' },
};
const listing2 = {
id: new UUID('listing2'),
type: 'listing',
attributes: { title: 'Listing 2 title', description: 'Listing 2 description' },
};
const initialResponse = { data: [listing1, listing2] };
const initialEntities = updatedEntities({}, initialResponse);
expect(initialEntities).toEqual({ listing: { listing1, listing2 } });
const author1 = { id: new UUID('author1'), type: 'author' };
const author1WithAttributes = {
id: new UUID('author1'),
type: 'author',
attributes: { name: 'Author 1 name' },
};
const listing2Updated = {
id: new UUID('listing2'),
type: 'listing',
attributes: { title: 'New listing 2 title', description: 'new listing 2 description' },
relationships: { author: { data: author1 } },
};
const included = [author1WithAttributes];
const response = { data: [listing2Updated], included };
const entities = updatedEntities(initialEntities, response, true);
expect(entities).toEqual({
listing: { listing1, listing2: listing2Updated },
author: { author1: author1WithAttributes },
});
});
});
});

13
src/util/sample.js Normal file
View file

@ -0,0 +1,13 @@
import { types } from 'sharetribe-sdk';
export const marketplaceId = new types.UUID('16c6a4b8-88ee-429b-835a-6725206cd08c');
export const userJoeId = new types.UUID('3c073fae-6172-4e75-8b92-f560d58cd47c');
export const userJaneId = new types.UUID('7b98dd96-74c7-4ddc-9f46-38c0f91c4a19');
export const listing1Id = new types.UUID('c6ff7190-bdf7-47a0-8a2b-e3136e74334f');
export const listing2Id = new types.UUID('9009efe1-25ec-4ed5-9413-e80c584ff6bf');
export const listing3Id = new types.UUID('8918693a-7a58-4d46-8324-f7996ef7579b');
export const listing4Id = new types.UUID('5e1f2086-522c-46f3-87b4-451c6770c833');
export const listing5Id = new types.UUID('f5130a7f-4b8b-453b-98e5-78e38ad02c3f');
export const listing6Id = new types.UUID('927a30a2-3a69-4b0d-9c2e-a41744488703');
export const image1Id = new types.UUID('4617c584-edfd-49e9-be43-2f1d50fb6e35');
export const image2Id = new types.UUID('948b1926-67f9-402a-a7c7-f8ff3090c249');