2.3.0 • Published 5 years ago

with-resources v2.3.0

Weekly downloads
16
License
MIT
Repository
github
Last release
5 years ago

with-resources

A higher order component to help fetch resources

npm

This React HOC facilitates fetching and grouping data into categories called resources which is freely defined by user. User just needs to supply the data manager which is in charge of fetching the data for each resource defined.

Requirements

This tool has the following requirements:

  • peerDependencies:
"react": "^16.0.0",
"react-redux": "^5.0.0",
"redux": ">=4 <5",
"redux-observable": "^1.0.0",
"rxjs": ">=6.0.0-beta.0 <7"

Installation

Make sure that you have installed all the peer dependencies

yarn add with-resources

or

npm install with-resources --save

Development Setup

Resource Types

with-resources requires an object resourceTypes to prepare the resources. It must be in the following format:

{
  USERS: 'users',
  DIGITAL_ASSETS: 'digitalAssets',
}

keys are in CAPS_SNAKE_CASE and referred in code; values are in camelCase and serve as key to select the corresponding data manager or getter for a particular resource. Both should end with 's' to indicate plurality.

Data manager

Each resource needs their own data manager to fetch data. Each data manager must adhere to the following format to interface with with-resources:

const DM = async ({ method, input }) => ({ [method]: await DM[method](input) });

and have individual functions to fetch data corresponding to each method (CRUD following RESTful convention):

/*
  C - Create
  adapter: {
    fe2be: to massage body from FE to BE format
    be2fe: to massage response from BE to FE format
  }
*/
DM.create = async ({ content }) =>
  R.pipe(
    adapter.fe2be,
    payload =>
      fetch(`${endpoint}/${resource}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload)
      })
        .then(res => res.json())
        .then(adapter.be2fe)
        .catch(err => ({ err }))
  )(content);
/*
  R - retrieve
*/
DM.retrieveOne = async () =>
  fetch(`${endpoint}/${resource}`, { method: "GET" })
    .then(res => res.json())
    .then(adapter.be2fe)
    .catch(err => ({ err }));
/*
  R - retrieve
  queries: [
    { name: "page", value: 0 },
    { name: "pageSize", value: 10 },
  ]
  getQueriesString: convert to query string: "?page=1&pageSize=10"
*/
DM.retrieveMany = async ({ params: { queries } }) =>
  R.pipe(
    getQueriesString,
    queriesString =>
      fetch(`${endpoint}/${resource}${queriesString}`, { method: "GET" })
        .then(res => res.json())
        .then(adapter.be2fe)
        .catch(err => ({ err }))
  )(queries);
/*
  U - Update
  ids: [
    { name: "resourceId", value: 1234 },
  ]
  getIdsObject: convert to { resourceId: 1234 }
*/
DM.update = async ({ params: { ids }, content }) =>
  R.pipe(
    R.juxt([
      R.pipe(
        R.prop("ids"),
        getIdsObject
      ),
      R.prop("content")
    ]),
    ([{ resourceId }, content]) =>
      R.pipe(
        adapter.fe2be,
        payload =>
          fetch(`${endpoint}/${resource}/${resourceId}`, {
            method: "PATCH",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(payload)
          })
            .then(res => res.json())
            .then(adapter.be2fe)
            .catch(err => ({ err }))
      )(content)
  )({ ids, content });
/*
  D - Delete
*/
DM.delete = async ({ params: { ids } }) =>
  R.pipe(
    getIdsObject,
    ({ resourceId }) =>
      fetch(`${endpoint}/${resource}/${resourceId}`, { method: "DELETE" })
        .then(res => res.json())
        .catch(err => ({ err }))
  )(ids);

data/managers/index.js

import resourceType1 from "./resourceType1";
import resourceType2 from "./resourceType2";

export default {
  resourceType1,
  resourceType2
};
Redux Store

data/store.js

import { createStore, applyMiddleware, compose } from "redux";
import { createEpicMiddleware, combineEpics } from "redux-observable";
import setupResources from "with-resources";
import DM from "./managers";

const epicMiddleware = createEpicMiddleware();
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;

const { reducer, epics } = setupResources({
  resourceTypes: {
    RESOURCE_TYPE_1: "resourceType1",
    RESOURCE_TYPE_2: "resourceType2"
  },
  DM
});

const store = createStore(
  reducer,
  composeEnhancers(applyMiddleware(epicMiddleware))
);
epicMiddleware.run(combineEpics(...epics));

export default store;

API & Usage

Default export of with-resources

The default export of with-resources (setupResources) receives a config object:

{ resourceTypes = {}, reduxPath = [], DM }

  • resourceTypes is the object containing the resources you want to set up (see above)
  • reduxPath is the location of your root resources in redux store (depending on where you put the reducer returned from setupResources)
  • DM is the object containing the data managers for each resources
HOC: withResources(operations)

Each operation represents one request to a resource. It has the following format:

{
  resourceType: resourceTypes.USERS,
  method: 'retrieveFriends',
  // input: mapper or object below
  input: {
    params: {
      ids: [
        { name: 'userId', value: 777 },
      ],
      queries: [
        { name: 'page', value: 1 },
        { name: 'pageSize', value: 10 },
      ],
    },
    content: {},
  },
  options: {
    autorun: false,
    runOnInputChange: true,
    reset: false,
    useLast: false,
  },
}

Each operation has the analogy to a RESTful resource request:

  • resourceType: the resource to request, it can be retrieved via resourceTypes which can be imported from with-resources after calling setupResources

  • method: follow CRUD convention, the DM for the requested resourceType must have the implementation for the specified method

  • input: allow customizing of url parameters such as id and query string, content is useful for C & U method. input can be a mapper function, receiving parent props and yielding the described object

  • options:

    • autorun: auto execute the request when component mounted, and whenever the input object changes (runOnInputChange must be true). Default is false
    • runOnInputChange: effective when autorun is true. Default is true. If you want to execute the input mapper function once, set runOnInputChange to false
    • reset: useful when you need to reset the redux state for a method of a resourceType. Default is false
    • useLast: use the last retrieved result if any (see Caching mechanism)

The operation above can be understood as making the following RESTful api request:

GET /endpoint/users/777/friends?page=1&pageSize=10
Injected props

withResources will inject a pair of data and actionCreators to the wrapped component

  • data: an object with the following format:
{
  status: { loading, success, error }, // combined status of all operations
  users: {
    retrieveFriends: {
      status: { loading, success, error }, // individual status of method
      // data fetched
    },
    // other methods
  }
}
  • actionCreators: a list of bound & readily dispatched action creators grouped by resource types, including ajax, clearCache, reset
{
  users: { ajax, clearCache, reset },
  // other resource type
}

Instead of setting autorun to true, you can use the injected actionCreatorsresourceType.ajax to fetch data at your own will. It receives a cargo (and optional callbacks) in the following format:

cargo: {
  method: 'retrieveFriends',
  input: {
    params: {
      ids: [
        { name: 'userId', value: 777 },
      ],
      queries: [
        { name: 'page', value: 1 },
        { name: 'pageSize', value: 10 },
      ],
    },
  },
  options: {
    useLast: true,
  }
},
onSuccess: ({ data: { retrieveFriends: { list = [] } = {} } }) => {
  // ...
},
onFailure: ({ error }) => {
  // ...
},
Resource Getters (Optional)

Instead of traversing the redux store path to get the data you want, i.e. R.pathOr([], [resourceTypes.USERS, 'retrieveFriends', 'list'], data)

First, you can supply a list of getters to with-resources and use them to get the data, i.e. gettersOf(resourceTypes.USERS).getFriends()(data), gettersOf can be imported from with-resources

data/getters/users.js

import * as R from 'ramda';
import { resourceTypes } from 'with-resources';

/*
**************************************************
  State Getters
**************************************************
*/
const RESOURCE_TYPE = resourceTypes.USERS;

// supply true to root if you use getFriends with redux state
const getFriends = ({ root, defaultValue = [] } = {}) => R.ifElse(
  R.anyPass([
    R.isNil,
    R.isEmpty,
    R.pipe(
      R.path(
        R.concat(root ? ['resources'] : [], [
          RESOURCE_TYPE,
          'retrieveFriends',
          'status',
          'success',
        ]),
      ),
      R.not,
    ),
  ]),
  R.always(defaultValue),
  R.path(R.concat(root ? ['resources'] : [], [RESOURCE_TYPE, 'retrieveFriends', 'list'])),
);

export default {
  getFriends,
};

Next, you need to tell webpack where to look for the resources' getters by replacing the context (since with-resources use dynamic import of resources' getters files)

webpack.config.js

plugins: [
    new webpack.ContextReplacementPlugin(/getters/, path.resolve(__dirname, 'src/data/getters')),
  ],
]

The idea behind it is not just for quick access of data, but for maintenance purpose. Let's say, you access the same piece of data in many places in your app, changing the location of your root resources in redux requires rectifying the access path for all the places.

By default, each resource has 3 predefined getters, namely getState, getMethod, getStatus

getState(state) gives you access to the resource in redux by supplying the redux state

gettersOf(resourcesTypes.USERS).getState(state)

getMethod({ root })(method) receives a config object (root = true means accessing from redux state), and a method (i.e retrieveFriends), and gives you access to the data of method in redux

gettersOf(resourcesTypes.USERS).getMethod()('retrieveFriends')(data).list

getStatus({ root })(method) receives a config object (root = true means accessing from redux state), and a method (i.e retrieveFriends), and gives you access to the status of method

gettersOf(resourcesTypes.USERS).getStatus()('retrieveFriends')(data).success
Caching mechanism

Caching mechanism has the following characteristics:

  • It is an opt-in mechanism, i.e. with-resources always return newly fetched data unless user specifies useLast: true.
  • It is done per entire resource, i.e. if you clear cache (using action CLEAR_CACHE), the cache for the entire resource will be cleared (not just any particular method of the resource).
  • Timeout: default to 15mins (Note: CLEAR_CACHE should be called on new session to limit the caching to session-based)

Examples

The animal example uses withResources to fetch image of fox, cat or dog.

Start the server by running

npm run example:server

or

yarn example:server

Then, start the web app at localhost:7000 by running

npm run example:animal

or

yarn example:animal

React Hook

with-resources also provides experimental hook, i.e. useResources

Usage

Before using useResources, you need to meet the following requirements:

  • Install version ^16.7.0-alpha.2 of react
  • supply the store as the context to with-resources

index.js

import { StoreContext } from 'with-resources';
import store from './data/store';
import AppHook from './app.hook';

<StoreContext.Provider value={store}>
  <App />
</StoreContext.Provider>

Inside app.hook.js

...
const { data, actionCreators } = useResources([
  {
    resourceType: resourceTypes.ANIMALS,
    method: 'retrieveOne',
    input: useMemo(
      () => ({
        params: {
          queries: [{ name: 'kind', value: 'fox' }],
        },
      }),
      [],
    ),
    options: { autorun: true },
  },
]);

Make sure that you use useMemo to pass the same object input to useResources on every render, otherwise useResources will trigger a new request with the new object input.

Server Side Rendering

Coming soon 🚧🚧🚧

Author

Charlie Chau – chaunhihien@gmail.com

Distributed under the MIT license. See LICENSE for more information.

https://github.com/charshin

2.3.0

5 years ago

2.2.1

5 years ago

2.2.0

5 years ago

2.1.0

5 years ago

2.0.0

5 years ago

1.0.4

5 years ago

1.0.3

5 years ago

1.0.2

5 years ago

1.0.1

5 years ago

1.0.0

5 years ago