Skip to content

Commit

Permalink
Merge pull request #6 from heroku/chains
Browse files Browse the repository at this point in the history
Chainable requests
  • Loading branch information
ryanbrainard committed Dec 11, 2015
2 parents c181d44 + 523ad04 commit 2e1aa8b
Show file tree
Hide file tree
Showing 5 changed files with 167 additions and 16 deletions.
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,48 @@ Similarly, `PromiseState.race()` can be used to return the first settled `Promis

PromiseState.all([userFetch, likesFetch.catch((reason) => [])])

## Chaining Requests

Inside of `connect()`, requests can be chained using `then()`, `catch()`, `andThen()` and `andCatch()` to trigger additional requests after a previous request is fulfilled. These are not to be confused with the similar sounding functions on `PromiseState`, which are on the response side, are synchronous, and are executed for every change of the `PromiseState`.

`then()` is helpful for cases where multiple requests are required to get the data needed by the component and the subsequent request relies on data from the previous request. For example, if you need to make a request to `/foos/${name}` to look up `foo.id` and then make a second request to `/bar-for-foos-by-id/${foo.id}` and return the whole thing as `barFetch` (the component will not have access to the intermediate `foo`):

connect(({ name }) => ({
barFetch: {
url: `/foos/${name}`,
then: (foo) => `/bar-for-foos-by-id/${foo.id}`
}
}))

`andThen()` is similar, but is intended for side effect requests where you still need access to the result of the first request and/or need to fanout to multiple requests:

connect(({ name }) => ({
fooFetch: {
url: `/foos/${name}`,
andThen: (foo) => {
barFetch: `/bar-for-foos-by-id/${foo.id}`
}
}
}))

This is also helpful for cases where a fetch function is changing data that is in some other fetch that is a collection. For example, if you have a list of `foo`s and you create a new `foo` and the list needs to be refreshed:

connect(({ name }) => ({
foosFetch: '/foos',
createFoo: (name) => {
method: 'POST',
url: '/foos',
andThen: () => {
foosFetch: {
url: '/foos',
refreshing: true
}
}
}
}))

`catch` and `andCatch` are similar, but for error cases.

## Accessing Headers & Metadata

Both request and response headers and other metadata are accessible. Custom request headers can be set on the request as an object:
Expand Down
22 changes: 13 additions & 9 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,19 @@ Instead, it *returns* a new, connected component class, for you to use.

* [`mapPropsToRequestsToProps(props): { prop: request, ... }`] *(Function)*: A pure function of props that specifies the requests to fetch data and the props to which to assign the results. This function is called every time props change, and if the requests materially change, the data will be refetched. Requests can be specified as plain URL strings, request objects, or functions. Plain URL strings are the most common and preferred format, but only support GET URLs with default options. For more advanced options, specify the request as an object the the following keys:

- `url` *(String)*: Required. HTTP URL from which to fetch the data.
- `method` *(String)*: HTTP method. Defaults to `GET`.
- `headers` *(Object)*: HTTP headers as simple key-value pairs. Defaults to `Accept` and `Content-Type` set to `application/json`.
- `credentials` *(String)*: Policy for credential to include with request. One of `omit`, `same-origin`, `include`. Defaults to `same-origin`. See [`Request.credentials`](https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials) for details.
- `body`: Any body that you want to add to your request; however, it must be replayable (i.e. not a one time use stream). Note that a request using the `GET` or `HEAD` method cannot have a body.
- `refreshInterval` *(Integer)*: Interval in milliseconds to poll for new data from the URL.
- `refreshing` *(Boolean)*: If true, the request is treated as a refresh. This is generally only used when overwriting an existing `PromiseState` and it is desired that the existing `value` not be cleared or changing into the `pending` state while the request is in flight. If no previous request was fulfilled, both `pending` and `refreshing` will be set.
- `force` *(Boolean)*: Forces the data to be always fetched when new props are received. Takes precedence over `comparison`.
- `comparison` *(Any)*: Custom value for comparing this request and the previous request when the props change. If the `comparison` values are *not* strictly equal, the data will be fetched again. In general, it is preferred to rely on the default that compares material changes to the request (i.e. URL, headers, body, etc); however, this is helpful in cases where the request should or should not be fetched again based on some other value. If `force` is true, `comparison` is not considered.
- `url` *(String)*: Required. HTTP URL from which to fetch the data.
- `method` *(String)*: HTTP method. Defaults to `GET`.
- `headers` *(Object)*: HTTP headers as simple key-value pairs. Defaults to `Accept` and `Content-Type` set to `application/json`.
- `credentials` *(String)*: Policy for credential to include with request. One of `omit`, `same-origin`, `include`. Defaults to `same-origin`. See [`Request.credentials`](https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials) for details.
- `body`: Any body that you want to add to your request; however, it must be replayable (i.e. not a one time use stream). Note that a request using the `GET` or `HEAD` method cannot have a body.
- `refreshInterval` *(Integer)*: Interval in milliseconds to poll for new data from the URL.
- `refreshing` *(Boolean)*: If true, the request is treated as a refresh. This is generally only used when overwriting an existing `PromiseState` and it is desired that the existing `value` not be cleared or changing into the `pending` state while the request is in flight. If no previous request was fulfilled, both `pending` and `refreshing` will be set.
- `force` *(Boolean)*: Forces the data to be always fetched when new props are received. Takes precedence over `comparison`.
- `comparison` *(Any)*: Custom value for comparing this request and the previous request when the props change. If the `comparison` values are *not* strictly equal, the data will be fetched again. In general, it is preferred to rely on the default that compares material changes to the request (i.e. URL, headers, body, etc); however, this is helpful in cases where the request should or should not be fetched again based on some other value. If `force` is true, `comparison` is not considered.
- `then(value, meta): request` *(Function)*: returns a request to fetch after fulfillment of this request and replaces this request. Takes the `value` and `meta` of this request as arguments.
- `catch(reason, meta): request` *(Function)*: returns a request to fetch after rejection of this request and replaces this request. Takes the `value` and `meta` of this request as arguments.
- `andThen(value, meta): { prop: request, ... }` *(Function)*: returns an object of request mappings to fetch after fulfillment of this request but does not replace this request. Takes the `value` and `meta` of this request as arguments.
- `andCatch(reason, meta): { prop: request, ... }` *(Function)*: returns an object of request mappings to fetch after rejection of this request but does not replace this request. Takes the `value` and `meta` of this request as arguments.

Requests specified as functions are not fetched immediately when props are received, but rather bound to the props and injected into the component to be called at a later time in response to user actions. Functions should be pure and return the same format as `mapPropsToRequestsToProps` itself. If a function maps a request to the same name as an existing prop, the prop will be overwritten. This is commonly used for taking some action that updates an existing `PromiseState`. Consider setting `refreshing: true` in such it situation.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-refetch",
"version": "0.4.2",
"version": "0.5.0",
"description": "A simple, declarative, and composable way to fetch data for React components.",
"main": "./lib/index.js",
"scripts": {
Expand Down
26 changes: 22 additions & 4 deletions src/components/connect.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,14 +174,32 @@ export default function connect(mapPropsToRequestsToProps, options = {}) {
}, mapping.refreshInterval)
}

this.setAtomicState(prop, startedAt, mapping, PromiseState.resolve(value, meta), refreshTimeout)
if (Function.prototype.isPrototypeOf(mapping.then)) {
this.refetchDatum(prop, coerceMapping(null, mapping.then(value, meta)))
return
}

this.setAtomicState(prop, startedAt, mapping, PromiseState.resolve(value, meta), refreshTimeout, () => {
if (Function.prototype.isPrototypeOf(mapping.andThen)) {
this.refetchDataFromMappings(mapping.andThen(value, meta))
}
})
}).catch(reason => {
this.setAtomicState(prop, startedAt, mapping, PromiseState.reject(reason, meta), null)
if (Function.prototype.isPrototypeOf(mapping.catch)) {
this.refetchDatum(coerceMapping(null, mapping.catch(reason, meta)))
return
}

this.setAtomicState(prop, startedAt, mapping, PromiseState.reject(reason, meta), null, () => {
if (Function.prototype.isPrototypeOf(mapping.andCatch)) {
this.refetchDataFromMappings(mapping.andCatch(reason, meta))
}
})
})
})
}

setAtomicState(prop, startedAt, mapping, datum, refreshTimeout) {
setAtomicState(prop, startedAt, mapping, datum, refreshTimeout, callback) {
this.setState((prevState) => {
if (startedAt < prevState.startedAts[prop]) {
return {}
Expand All @@ -206,7 +224,7 @@ export default function connect(mapPropsToRequestsToProps, options = {}) {
})
}

})
}, callback)
}

clearAllRefreshTimeouts() {
Expand Down
91 changes: 89 additions & 2 deletions test/components/connect.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ describe('React', () => {
expect(stubPending.props.testFetch.constructor).toEqual(PromiseState)

expect(stubPending.props.testFunc).toBeA('function')
expect(stubPending.props.deferredFetch).toEqual(null)
expect(stubPending.props.deferredFetch).toEqual(undefined)
stubPending.props.testFunc('A', 'B')
expect(stubPending.props.deferredFetch).toIncludeKeyValues({
fulfilled: false, pending: true, refreshing: false, reason: null, rejected: false, settled: false, value: null, meta: {}
Expand Down Expand Up @@ -181,7 +181,7 @@ describe('React', () => {
const decorated = TestUtils.findRenderedComponentWithType(container, Container)
expect(decorated.state.mappings.testFunc).toBeA('function')
expect(decorated.state.data.testFunc).toBeA('function')
expect(decorated.state.data.deferredFetch).toEqual(null)
expect(decorated.state.data.deferredFetch).toEqual(undefined)

decorated.state.data.testFunc('A', 'B')

Expand Down Expand Up @@ -228,6 +228,93 @@ describe('React', () => {
)
})

it('should call then mappings', (done) => {
const props = ({
foo: 'bar',
baz: 42
})

@connect(({ foo, baz }) => ({
firstFetch: {
url: `/first/${foo}`,
then: (v) => `/second/${baz}/${v['T']}`
}
}))
class Container extends Component {
render() {
return <Passthrough {...this.props} />
}
}

const container = TestUtils.renderIntoDocument(
<Container {...props} />
)

const decorated = TestUtils.findRenderedComponentWithType(container, Container)
expect(decorated.state.mappings.firstFetch.url).toEqual('/first/bar')
expect(decorated.state.mappings.firstFetch.then).toBeA('function')
expect(decorated.state.data.firstFetch).toIncludeKeyValues(
{ fulfilled: false, pending: true, reason: null, refreshing: false, rejected: false, settled: false, value: null }
)

setImmediate(() => {
expect(decorated.state.mappings.firstFetch.url).toEqual('/second/42/t')
expect(decorated.state.data.firstFetch).toIncludeKeyValues(
{ fulfilled: true, pending: false, reason: null, refreshing: false, rejected: false, settled: true, value: { 'T': 't' } }
)

done()
})
})

it('should call andThen mappings', (done) => {
const props = ({
foo: 'bar',
baz: 42
})

@connect(({ foo, baz }) => ({
firstFetch: {
url: `/first/${foo}`,
andThen: () => ({
thenFetch: `/second/${baz}`
})
}
}))
class Container extends Component {
render() {
return <Passthrough {...this.props} />
}
}

const container = TestUtils.renderIntoDocument(
<Container {...props} />
)

const decorated = TestUtils.findRenderedComponentWithType(container, Container)
expect(decorated.state.mappings.firstFetch.url).toEqual('/first/bar')
expect(decorated.state.mappings.firstFetch.andThen).toBeA('function')
expect(decorated.state.data.firstFetch).toIncludeKeyValues(
{ fulfilled: false, pending: true, reason: null, refreshing: false, rejected: false, settled: false, value: null }
)

expect(decorated.state.mappings.thenFetch).toEqual(undefined)
expect(decorated.state.data.thenFetch).toEqual(undefined)

setImmediate(() => {
expect(decorated.state.data.firstFetch).toIncludeKeyValues(
{ fulfilled: true, pending: false, reason: null, refreshing: false, rejected: false, settled: true, value: { 'T': 't' } }
)

expect(decorated.state.mappings.thenFetch.url).toEqual('/second/42')
expect(decorated.state.data.thenFetch).toIncludeKeyValues(
{ fulfilled: true, pending: false, reason: null, refreshing: false, rejected: false, settled: true, value: { 'T': 't' } }
)

done()
})
})

it('should refresh when refreshInterval is provided', (done) => {
const interval = 100000 // set sufficently far out to not happen during test

Expand Down

0 comments on commit 2e1aa8b

Please sign in to comment.