Commit 1842cc79 authored by S Anand's avatar S Anand
Browse files

ENH: Add $.ajaxchain(). Fixes #116

parent 6fe9a2e8
Pipeline #68415 passed with stage
in 2 minutes and 9 seconds
......@@ -40,6 +40,8 @@ Components:
Utilities:
- AJAX utilities: [ajax.min.js](dist/ajax.min.js)
- [$.ajaxchain](#ajaxchain) chains AJAX requests, loading multiple items in sequence
- Leaflet utilities: [leaflet.min.js](dist/leaflet.min.js)
- [L.TopoJSON](#ltopojson) loads TopoJSON files just like GeoJSON. Requires [topojson](https://github.com/topojson/topojson)
- Event library: [event.min.js](dist/event.min.js)
......@@ -902,6 +904,138 @@ In edit mode, show HTML input bindings like Dropdown, Datepicker, Number fields.
</script>
```
<!-- ----------------------------------------------------------------------- -->
# $.ajaxchain
Chains AJAX requests. [$.ajax][ajax] fetches a single page, like this:
```js
$.ajax({
url: 'formhandler', // Fetch "formhandler" URL
data: {_offset: 0} // with ?_offset=0
})
```
`$.ajaxchain` keeps fetching more page using a `chain:` function.
```js
var ajaxchain_instance = $.ajaxchain({
url: 'formhandler', // Fetch "formhandler" URL
data: {_offset: 0, _limit: 10}, // with ?_offset=0&_limit=10
chain: function(response, request) { // When the response is retrieved
if (response.length > 0) // if the response is non-empty
return {data: {_offset: request.data._offset + 10} // fetch the next page
},
limit: 10, // Get at most 10 pages (default)
// any other $.ajax options can be passed
})
```
The flow when `$.ajaxchain(request)` is called is:
1. Fetch URL using [$.ajax][ajax] using the request options
2. If the page limit is not reached, call the `.chain(response, request, xhr)` function.
- `request` is the request sent to [$.ajax][ajax]
- `response` is the response from [$.ajax][ajax]
- `xhr` is the [jqXHR][jqxhr] object
3. If `.chain()` returns a non-empty object
- update the `request` with the response of `.chain()`
- call `$.ajaxchain(request)` with the new request
- Otherwise, stop.
# $.ajaxchain options
`$.ajaxchain()` accepts the same options as [$.ajax][ajax] with a few additional options:
- `chain`: a function that returns updates to the request object
- `limit`: the maximum number of pages to fetch
The `chain:` function can be used with [FormHandler](https://learn.gramener.com/guide/formhandler/).
```js
chain: function(response, request) {
if (response.length > 0) // If the response is not empty
return {data: {_offset: request.data._offset + 10}} // Fetch the next page
}
```
You can use a set of pre-defined helper functions for chaining:
- `chain: $.ajaxchain.list()` chains a list of URLs.
- `chain: $.ajaxchain.list([url1, url2, url3])` fetches `url1`, `url2` and `url3` one after another
- `chain: $.ajaxchain.cursor(target, source)` uses a page cursor or token to identify the next page
- Requires [lodash](https://lodash.com/)
- The `source` is the [object path](https://lodash.com/docs/#get) from the
response that has the next cursor value. For example: `a.b` means `response.a.b`
- The `target` is the update to be made to the `request`. For example,
`data.token` sets `?token=`. `headers.X-Token` sets the `X-Token` header.
- Google APIs like [YouTube PlaylistItems](https://developers.google.com/youtube/v3/docs/playlistItems/list)
can use `chain: $.ajaxchain.cursor('data.pageToken', 'nextPageToken')`.
It fetches the next URL with `?pageToken=` as the `nextPageToken` key from the response.
- [Twitter APIs](https://developer.twitter.com/en/docs/ads/general/guides/pagination.html)
can use `chain: $.ajaxchain.cursor('data.cursor', 'next_cursor')`
- [Facebook APIs](https://developers.facebook.com/docs/graph-api/using-graph-api/#paging)
can use `chain: $.ajaxchain.cursor('url', 'paging.next')`
You can also construct your own `chain:` functions. For example:
On [YouTube](https://developers.google.com/youtube/v3/docs/playlistItems/list):
```js
chain: function(response, request) {
if (response.nextPageToken)
return {data: {pageToken: response.textPageToken}}
}
```
If the results are at `/page/1`, `/page/2`, etc:
```js
url: `/page/1`,
chain: function(response, request) {
if (response.length > 0)
return {url: `/page/` + (+request.url.split('/')[-1] + 1)}
}
```
# $.ajaxchain events
To access the response, we can use the `ajaxchain_instance` events. There are 3 events:
- `done` is fired when ALL pages have been loaded. Event attributes are:
- `response`: list of responses returned by each [$.ajax][ajax] request
- `request`: list of requests passed to each [$.ajax][ajax]
- `load` is fired when each page is loaded. Event attributes are:
- `request`: the parameters passed to [$.ajax][ajax] request
- `response`: data returned by [$.ajax][ajax] request
- `xhr`: the [jqXHR][jqxhr] object
- `error` is fired when there is an error. Event attributes are:
- `request`: the parameters passed to [$.ajax][ajax] request that failed
- `xhr`: the [jqXHR][jqxhr] object that failed
- `exception`: any exception thrown when calling the `chain` function
For example:
```js
ajaxchain_instance
.on('done', function(e) { // Called after ALL pages are loaded, or on error
// Responses are in e.response[0], e.response[1], etc
// Requests are in e.request[0], e.request[1], etc
})
.on('load', function(e) { // Called when each page is loaded
// e.response has the response
// e.request has the request
})
.on('error', function(e) { // Called when there's an error
// e.request has the request
// e.exception is set if the .change() function threw an exception
})
```
[ajax]: http://api.jquery.com/jQuery.ajax/
[jqxhr]: http://api.jquery.com/Types/#jqXHR
<!-- ----------------------------------------------------------------------- -->
# $.template
......
export { version } from './src/package.js'
import { ajaxchain } from './src/ajaxchain.js'
if (typeof jQuery != 'undefined') {
jQuery.extend(jQuery, {
ajaxchain: ajaxchain
})
}
......@@ -13,3 +13,4 @@ import './index-dropdown.js'
import './index-event.js'
import './index-leaflet.js'
import './index-urlchange.js'
import './index-ajax.js'
......@@ -109,6 +109,11 @@ export default [
output: { file: "dist/urlchange.min.js", format: "umd", name: "g1" },
plugins: [uglify()]
},
{
input: "index-ajax",
output: { file: "dist/ajax.min.js", format: "umd", name: "g1" },
plugins: [uglify()]
},
{
input: "index-dropdown",
plugins: [
......
/* globals $, _ */
export function ajaxchain(request, $self, requests, responses) {
$self = $self || $('<div>')
requests = requests || []
responses = responses || []
if (!('limit' in request))
request.limit = 10
request.limit--
$.ajax(request)
.done(function (response, status, xhr) {
requests.push(request)
responses.push(response)
$self.trigger({ type: 'load', request: request, response: response, xhr: xhr })
if (request.chain && request.limit > 0) {
try {
var updates = request.chain(response, request, xhr)
} catch (e) {
$self.trigger({ type: 'error', request: request, xhr: xhr, exception: e })
// eslint-disable-next-line no-console
console.warn('$.ajaxchain: chain() exception', e)
}
if ($.isPlainObject(updates) && !$.isEmptyObject(updates)) {
var new_request = $.extend(true, {}, request, updates)
var next = ajaxchain(new_request, $self, requests, responses)
}
}
if (!next)
$self.trigger({ type: 'done', request: requests, response: responses })
})
.fail(function (xhr, testStatus, error) {
$self.trigger({ type: 'error', request: request, xhr: xhr })
$self.trigger({ type: 'done', request: requests, response: responses })
// eslint-disable-next-line no-console
console.warn('$.ajaxchain: ajax error', error)
})
return $self
}
// Chain through a list of URLs in order
ajaxchain.list = function (urls) {
return function (response, request) {
var next = urls.indexOf(request.url) + 1
if (next < urls.length)
return { url: urls[next] }
}
}
// Used by Twitter, YouTube
// Google: .cursor('data.pageToken', 'nextPageToken')
// Twitter: .cursor('data.cursor', 'next_cursor')
// Facebook: .cursor('url', 'paging.next')
if (typeof _ == 'undefined')
ajaxchain.cursor = function () {
throw new Error('ajaxchain.cursor requires lodash')
}
else
ajaxchain.cursor = function (target, source) {
return function (response) {
var key = _.get(response, source)
if (key)
return _.set({}, target, key)
}
}
{
"file": "ajaxchain/a1.json",
"items": [1, 2, 3],
"page": {"next": "ajaxchain/a2.json"}
}
{
"file": "ajaxchain/a2.json",
"items": [4, 5, 6],
"page": {"next": "ajaxchain/a3.json"}
}
{
"file": "ajaxchain/a3.json",
"items": [7, 8, 9]
}
<!DOCTYPE html>
<html>
<head>
<title>ajaxchain tests</title>
<script src="../node_modules/jquery/dist/jquery.min.js"></script>
<script src="../node_modules/lodash/lodash.min.js"></script>
<script src="../dist/ajax.min.js"></script>
</head>
<body>
<script src="tape.js"></script>
<script>
tape.onFinish(function () { window.renderComplete = true })
</script>
<script>
var urls = ["ajaxchain/a1.json", "ajaxchain/a2.json", "ajaxchain/a3.json"]
tape('$.ajaxchain fetches chained URLs based on list of files', function (t) {
$.ajaxchain({
url: urls[0],
dataType: 'json',
chain: $.ajaxchain.list(urls)
}).on('load', function (e) {
t.equal(e.request.dataType, 'json')
t.equal(e.response.file, e.request.url)
}).on('done', function (e) {
t.equal(e.response.length, urls.length)
t.deepEqual(e.response.map(r => r.file), urls)
t.deepEqual(e.request.map(r => r.url), urls)
t.end()
}).on('error', function (e) {
t.fail('Unexpected error: ' + e.errorType)
})
})
tape('$.ajaxchain fetches chained URLs based on next parameter', function (t) {
$.ajaxchain({
url: 'ajaxchain/a1.json',
dataType: 'json',
chain: $.ajaxchain.cursor('url', 'page.next')
}).on('load', function (e) {
t.equal(e.request.dataType, 'json')
t.equal(e.response.file, e.request.url)
}).on('done', function (e) {
t.equal(e.response.length, urls.length)
t.deepEqual(e.response.map(r => r.file), urls)
t.deepEqual(e.request.map(r => r.url), urls)
t.end()
}).on('error', function (e) {
t.fail('Unexpected error: ' + e.errorType)
})
})
tape('$.ajaxchain raises error on missing file', function (t) {
var failed = false
$.ajaxchain({
url: urls[0],
dataType: 'json',
chain: $.ajaxchain.list(['ajaxchain/a1.json', 'ajaxchain/a2.json', 'missing.json'])
}).on('load', function (e) {
t.equal(e.request.dataType, 'json')
t.equal(e.response.file, e.request.url)
}).on('error', function (e) {
t.equal(e.request.url, 'missing.json')
t.equal(e.xhr.status, 404)
t.notOk('exception' in e)
failed += 1
}).on('done', function (e) {
t.equal(e.response.length, 2)
t.equal(failed, 1)
t.end()
})
})
tape('$.ajaxchain raises error on chain exception', function (t) {
var failed = false
$.ajaxchain({
url: urls[0],
dataType: 'json',
chain: function (response, request) {
// This will raise an exception
response.nonexistent.value
}
}).on('load', function (e) {
t.equal(e.request.dataType, 'json')
t.equal(e.response.file, e.request.url)
}).on('error', function (e) {
t.equal(e.request.url, urls[0])
t.equal(e.xhr.status, 200)
t.ok('exception' in e)
failed = true
}).on('done', function (e) {
t.equal(e.response.length, 1)
t.ok(failed)
t.end()
})
})
tape('$.ajaxchain limits pages', function (t) {
$.ajaxchain({
url: urls[0],
dataType: 'json',
chain: $.ajaxchain.list(urls),
limit: 2
}).on('load', function (e) {
t.equal(e.request.dataType, 'json')
t.equal(e.response.file, e.request.url)
}).on('done', function (e) {
t.equal(e.response.length, 2)
t.deepEqual(e.response.map(r => r.file), urls.slice(0, 2))
t.deepEqual(e.request.map(r => r.url), urls.slice(0, 2))
t.end()
}).on('error', function (e) {
t.fail('Unexpected error: ' + e.errorType)
})
})
</script>
</body>
</html>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment