├── .gitignore ├── .jscsrc ├── .travis.yml ├── README.md ├── karma.conf.js ├── package.json ├── src └── data-fetch-mixin.js └── tests └── data-fetch-mixin.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | tests/coverage 4 | -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "excludeFiles": [ 3 | ".git/**", 4 | "node_modules/**", 5 | "tests/coverage/**" 6 | ], 7 | "fileExtensions": [".js"], 8 | "preset": "google", 9 | 10 | "disallowMultipleVarDecl": null 11 | } 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4" 4 | after_success: 5 | - npm run coveralls 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-data-fetch [![Build Status](https://travis-ci.org/skidding/react-data-fetch.svg?branch=master)](https://travis-ci.org/skidding/react-data-fetch) [![Coverage Status](https://coveralls.io/repos/skidding/react-data-fetch/badge.svg?branch=master)](https://coveralls.io/r/skidding/react-data-fetch?branch=master) 2 | A good-enough data fetching mixin for React components. No models, no stores, 3 | just data. 4 | 5 | Bare functionality for fetching server-side JSON data inside a React omponent. 6 | Uses basic Ajax requests and setInterval for polling. 7 | 8 | ```js 9 | { 10 | "component": "List", 11 | "dataUrl": "/api/users.json", 12 | // Refresh users every 5 seconds 13 | "pollInterval": 5000 14 | } 15 | ``` 16 | 17 | Props: 18 | 19 | - **dataUrl** - A URL to fetch data from. Once data is received it will be set 20 | inside the component's _state_, under the `data` key, and will 21 | cause a reactive re-render. 22 | - **pollInterval** - An interval in milliseconds for polling the data URL. 23 | Defaults to 0, which means no polling. 24 | 25 | Context methods: 26 | 27 | - **getDataUrl**: The data URL can be generated dynamically by composing it 28 | using other props, inside a custom method that receives 29 | the next props as arguments and returns the data URL. The 30 | expected method name is "getDataUrl" and overrides the 31 | dataUrl prop when implemented. 32 | 33 | The DataFetch mixin introduces a jQuery dependency, its built-in JSONP support 34 | is worth the money. 35 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | config.set({ 3 | basePath: 'tests/', 4 | browsers: ['PhantomJS'], 5 | coverageReporter: { 6 | type: 'lcov', 7 | dir: 'coverage/' 8 | }, 9 | files: [ 10 | '**/*.js' 11 | ], 12 | frameworks: ['mocha', 'chai', 'sinon-chai', 'es6-shim'], 13 | preprocessors: { 14 | '**/*.js': ['webpack'] 15 | }, 16 | reporters: ['mocha', 'coverage'], 17 | webpack: { 18 | module: { 19 | postLoaders: [{ 20 | test: /\.js$/, 21 | exclude: /(node_modules|tests)\//, 22 | loader: 'istanbul-instrumenter' 23 | }] 24 | } 25 | }, 26 | webpackMiddleware: { 27 | noInfo: true 28 | } 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-data-fetch", 3 | "version": "0.5.0", 4 | "description": "A good-enough data fetching mixin for React components", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/skidding/react-data-fetch.git" 8 | }, 9 | "dependencies": { 10 | "jquery": "^2.1.3" 11 | }, 12 | "devDependencies": { 13 | "chai": "^1.10.0", 14 | "coveralls": "^2.11.2", 15 | "istanbul": "^0.3.13", 16 | "istanbul-instrumenter-loader": "^0.1.2", 17 | "jscs": "^1.12.0", 18 | "karma": "^0.13.10", 19 | "karma-chai": "^0.1.0", 20 | "karma-cli": "0.0.4", 21 | "karma-coverage": "^0.2.7", 22 | "karma-es6-shim": "^1.0.0", 23 | "karma-mocha": "^0.1.10", 24 | "karma-mocha-reporter": "^1.0.2", 25 | "karma-phantomjs-launcher": "^0.1.4", 26 | "karma-sinon-chai": "^0.3.0", 27 | "karma-webpack": "^1.7.0", 28 | "lodash.random": "^3.2.0", 29 | "mocha": "^2.1.0", 30 | "sinon": "^1.12.2", 31 | "sinon-chai": "^2.6.0", 32 | "webpack": "^1.12.2" 33 | }, 34 | "main": "src/data-fetch-mixin.js", 35 | "scripts": { 36 | "pretest": "jscs ./", 37 | "test": "karma start --single-run", 38 | "coveralls": "cat tests/coverage/*/lcov.info | node_modules/coveralls/bin/coveralls.js" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/data-fetch-mixin.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | 3 | /** 4 | * Bare functionality for fetching server-side JSON data inside a React 5 | * component. 6 | * @typedef {Object} DataFetchMixin 7 | * 8 | * @param {String} dataUrl A URL to fetch data from. Once data is received it 9 | * will be set inside the component's state, under the data key, and will 10 | * cause a reactive re-render. 11 | * @param {Number} [pollInterval=0] An interval in milliseconds for polling the 12 | * data URL. 0 means no polling. 13 | * 14 | * @param {Function} getDataUrl The data URL can be generated dynamically by 15 | * composing it using other props, inside a custom method that receives the 16 | * next props as arguments and returns the data URL. The expected method 17 | * name is "getDataUrl" and overrides the dataUrl prop when implemented. 18 | */ 19 | 20 | /** 21 | * @param {Object} [options] 22 | * @param {Bool} [options.crossDomain=false] If `true`, the requests will 23 | * contain the cookies set for the other domain. 24 | * @param {Function} [onError] If given, it will be called whenever a request 25 | * fails. See http://devdocs.io/jquery/jquery.ajax for details on what 26 | * params will be passed. 27 | * 28 | * @returns {DataFetchMixin} 29 | */ 30 | module.exports = function(options) { 31 | options = options || {}; 32 | 33 | options = $.extend({ 34 | crossDomain: false, 35 | onError: function() {} 36 | }, options); 37 | 38 | return { 39 | getDefaultProps: function() { 40 | return { 41 | // Enable polling by setting a value bigger than zero, in ms 42 | pollInterval: 0 43 | }; 44 | }, 45 | 46 | getInitialState: function() { 47 | return { 48 | isFetchingData: false, 49 | dataError: null 50 | }; 51 | }, 52 | 53 | componentWillMount: function() { 54 | this._xhrRequests = []; 55 | 56 | // The dataUrl prop points to a source of data than will extend the initial 57 | // state of the component, once it will be fetched 58 | this._resetData(this.props); 59 | 60 | if (this._shouldWePoll(this.props)) { 61 | this._startPolling(this.props); 62 | } 63 | }, 64 | 65 | componentWillReceiveProps: function(nextProps) { 66 | /** 67 | * A component can have its configuration replaced at any time so we need 68 | * to fetch data again. We may also need to reset/stop polling. 69 | */ 70 | var dataUrlChanged = this.props.dataUrl !== nextProps.dataUrl, 71 | pollIntervalChanged = this.props.pollInterval !== 72 | nextProps.pollInterval; 73 | 74 | if (dataUrlChanged || pollIntervalChanged) { 75 | this._clearPolling(); 76 | 77 | if (dataUrlChanged) { 78 | this._resetData(nextProps); 79 | } 80 | 81 | if (this._shouldWePoll(nextProps)) { 82 | this._startPolling(nextProps); 83 | } 84 | } 85 | }, 86 | 87 | componentWillUnmount: function() { 88 | // We abort any on-going requests when unmounting to make sure their 89 | // callbacks will no longer be called. The error callback will still be 90 | // called because of the abort action itself, so we use this flag to know 91 | // to ignore it altogether from this point on 92 | this._ignoreXhrRequestCallbacks = true; 93 | 94 | this._clearDataRequests(); 95 | 96 | this._clearPolling(); 97 | }, 98 | 99 | refreshData: function() { 100 | /** 101 | * Hit the same data URL again. 102 | */ 103 | this._resetData(this.props); 104 | }, 105 | 106 | stopFetching: function() { 107 | this._clearDataRequests(); 108 | }, 109 | 110 | stopPolling: function() { 111 | this._clearPolling(); 112 | }, 113 | 114 | resumePolling: function() { 115 | this._clearPolling(); 116 | this._startPolling(this.props); 117 | }, 118 | 119 | receiveDataFromServer: function(data) { 120 | this.setState({ 121 | isFetchingData: false, 122 | data: data 123 | }); 124 | }, 125 | 126 | _resetData: function(props) { 127 | /** 128 | * Hit the dataUrl and fetch data. 129 | * 130 | * Before starting to fetch data we reset any ongoing requests. 131 | * 132 | * @param {Object} props 133 | * @param {String} props.dataUrl The URL that will be hit for data. The URL 134 | * can be generated dynamically by composing it through other props, 135 | * inside a custom method that receives the next props as arguments and 136 | * returns the data URL. The expected method name is "getDataUrl" and 137 | * overrides the dataUrl prop when implemented 138 | */ 139 | var dataUrl = this._getDataUrl(props); 140 | 141 | this._clearDataRequests(); 142 | 143 | if (dataUrl) { 144 | this._fetchDataFromServer(dataUrl, this.receiveDataFromServer); 145 | } 146 | }, 147 | 148 | _clearDataRequests: function() { 149 | // Cancel any on-going request. 150 | while (this._xhrRequests.length > 0) { 151 | this._xhrRequests.pop().abort(); 152 | } 153 | }, 154 | 155 | _startPolling: function(props) { 156 | var url = this._getDataUrl(props); 157 | 158 | var callback = function() { 159 | this._fetchDataFromServer(url, this.receiveDataFromServer); 160 | }; 161 | 162 | this._pollInterval = setInterval(callback.bind(this), props.pollInterval); 163 | }, 164 | 165 | _clearPolling: function() { 166 | clearInterval(this._pollInterval); 167 | this._pollInterval = null; 168 | }, 169 | 170 | _getDataUrl: function(props) { 171 | return typeof(this.getDataUrl) === 'function' ? 172 | this.getDataUrl(props) : props.dataUrl; 173 | }, 174 | 175 | _fetchDataFromServer: function(url, onSuccess) { 176 | this.setState({ 177 | isFetchingData: true, 178 | dataError: null 179 | }); 180 | 181 | var request, 182 | onComplete, 183 | onError; 184 | 185 | onComplete = function() { 186 | this._xhrRequests = this._xhrRequests.filter(function(xhrRequest) { 187 | return xhrRequest !== request; 188 | }); 189 | }; 190 | 191 | var instance = this; 192 | 193 | /** 194 | * @this {Object} $.ajax context. 195 | * 196 | * @param {Object} xhr jQuery XHR object. 197 | * @param {String} status The type of error. 198 | * @param {String} err The error message. 199 | */ 200 | onError = function(xhr, status, err) { 201 | if (instance._ignoreXhrRequestCallbacks) { 202 | return; 203 | } 204 | 205 | instance.setState({ 206 | isFetchingData: false, 207 | dataError: { 208 | url: url, 209 | statusCode: xhr.status, 210 | statusText: status, 211 | message: err.toString(), 212 | response: xhr.responseJSON 213 | } 214 | }); 215 | 216 | options.onError.call(this, xhr, status, err); 217 | }; 218 | 219 | request = $.ajax({ 220 | url: url, 221 | // Even though not recommended, some $.ajaxSettings might default to 222 | // POST requests. See http://api.jquery.com/jquery.ajaxsetup/ 223 | type: 'GET', 224 | dataType: 'json', 225 | xhrFields: { 226 | withCredentials: options.crossDomain 227 | }, 228 | complete: onComplete.bind(this), 229 | success: onSuccess, 230 | error: onError 231 | }); 232 | 233 | this._xhrRequests.push(request); 234 | }, 235 | 236 | _shouldWePoll: function(props) { 237 | return props.pollInterval > 0; 238 | } 239 | }; 240 | }; 241 | -------------------------------------------------------------------------------- /tests/data-fetch-mixin.js: -------------------------------------------------------------------------------- 1 | var random = require('lodash.random'), 2 | $ = require('jquery'), 3 | DataFetch = require('../src/data-fetch-mixin.js'); 4 | 5 | describe('DataFetch mixin', function() { 6 | var ajaxStub, fakeComponent; 7 | 8 | beforeEach(function() { 9 | ajaxStub = { 10 | abort: function() {} 11 | }; 12 | 13 | // Mock jQuery forcefully, $.ajax is not even a function because jQuery 14 | // doesn't detect a DOM 15 | $.ajax = sinon.stub().returns(ajaxStub); 16 | 17 | // Mock React API 18 | fakeComponent = { 19 | setState: sinon.spy(), 20 | props: {} 21 | }; 22 | }); 23 | 24 | describe('same domain', function() { 25 | beforeEach(function() { 26 | Object.assign(fakeComponent, DataFetch()); 27 | }); 28 | 29 | it('should call $.ajax with dataUrl prop on mount', function() { 30 | fakeComponent.props.dataUrl = 'my-api.json'; 31 | 32 | fakeComponent.componentWillMount(); 33 | 34 | expect($.ajax.args[0][0].url).to.equal('my-api.json'); 35 | }); 36 | 37 | it('should not call $.ajax when dataUrl is equal', function() { 38 | fakeComponent.props.dataUrl = 'my-api.json'; 39 | fakeComponent.componentWillMount(); 40 | 41 | fakeComponent.componentWillReceiveProps({ 42 | dataUrl: 'my-api.json' 43 | }); 44 | 45 | expect($.ajax.callCount).to.equal(1); 46 | }); 47 | 48 | it('should not send cross-domain cookies', function() { 49 | fakeComponent.props.dataUrl = 'my-api.json'; 50 | 51 | fakeComponent.componentWillMount(); 52 | 53 | expect($.ajax).to.have.been.calledWith( 54 | sinon.match.has('xhrFields', 55 | sinon.match.has('withCredentials', false))); 56 | }); 57 | 58 | it('should call $.ajax when dataUrl prop changes', function() { 59 | ajaxStub.abort = function() {}; 60 | 61 | fakeComponent.props.dataUrl = 'my-api.json'; 62 | fakeComponent.componentWillMount(); 63 | 64 | fakeComponent.componentWillReceiveProps({ 65 | dataUrl: 'my-api2.json' 66 | }); 67 | 68 | expect($.ajax.lastCall.args[0].url).to.equal('my-api2.json'); 69 | }); 70 | 71 | it('should abort first call when changing dataUrl', function() { 72 | ajaxStub.abort = sinon.spy(); 73 | 74 | fakeComponent.props.dataUrl = 'my-api.json'; 75 | fakeComponent.componentWillMount(); 76 | 77 | fakeComponent.componentWillReceiveProps({ 78 | dataUrl: 'my-api2.json' 79 | }); 80 | 81 | expect(ajaxStub.abort).to.have.been.called; 82 | }); 83 | 84 | it('should call $.ajax with getDataUrl method if defined', function() { 85 | fakeComponent.getDataUrl = sinon.stub().returns('my-custom-api.json'); 86 | 87 | fakeComponent.componentWillMount(); 88 | 89 | expect(fakeComponent.getDataUrl).to.have.been.called; 90 | expect($.ajax.args[0][0].url).to.equal('my-custom-api.json'); 91 | }); 92 | 93 | it('should call getDataUrl with props', function() { 94 | fakeComponent.getDataUrl = sinon.spy(); 95 | fakeComponent.props.someProp = true; 96 | 97 | fakeComponent.componentWillMount(); 98 | 99 | expect(fakeComponent.getDataUrl).to.have.been.calledWith({ 100 | someProp: true 101 | }); 102 | }); 103 | 104 | it('should call $.ajax with receiveDataFromServer callback', function() { 105 | fakeComponent.props.dataUrl = 'my-api.json'; 106 | fakeComponent.componentWillMount(); 107 | 108 | expect($.ajax.args[0][0].success) 109 | .to.equal(fakeComponent.receiveDataFromServer); 110 | }); 111 | 112 | it('should populate state.data with returned data', function() { 113 | fakeComponent.receiveDataFromServer({ 114 | name: 'John Doe', 115 | age: 42 116 | }); 117 | 118 | var setStateArgs = fakeComponent.setState.args[0][0]; 119 | expect(setStateArgs.data.name).to.equal('John Doe'); 120 | expect(setStateArgs.data.age).to.equal(42); 121 | }); 122 | 123 | it('should call $.ajax again when refreshData is called', function() { 124 | ajaxStub.abort = function() {}; 125 | 126 | fakeComponent.props.dataUrl = 'my-api.json'; 127 | fakeComponent.componentWillMount(); 128 | 129 | fakeComponent.refreshData(); 130 | 131 | expect($.ajax.lastCall.args[0].url).to.equal('my-api.json'); 132 | }); 133 | 134 | it('should set isFetchingData true when mounting', function() { 135 | fakeComponent.props.dataUrl = 'my-api.json'; 136 | 137 | fakeComponent.componentWillMount(); 138 | 139 | var setStateArgs = fakeComponent.setState.args[0][0]; 140 | expect(setStateArgs.isFetchingData).to.equal(true); 141 | }); 142 | 143 | it('should set isFetchingData false when receiving data', function() { 144 | fakeComponent.receiveDataFromServer({}); 145 | 146 | var setStateArgs = fakeComponent.setState.args[0][0]; 147 | expect(setStateArgs.isFetchingData).to.equal(false); 148 | }); 149 | 150 | it('should set isFetchingData false if request errors', function() { 151 | fakeComponent.props.dataUrl = 'my-api.json'; 152 | 153 | fakeComponent.componentWillMount(); 154 | 155 | var onError = $.ajax.args[0][0].error; 156 | onError({}, 503, 'foobar'); 157 | 158 | var setStateArgs = fakeComponent.setState.lastCall.args[0]; 159 | expect(setStateArgs.isFetchingData).to.equal(false); 160 | }); 161 | 162 | it('should not set state if request errors after unmount', function() { 163 | ajaxStub.abort = function() {}; 164 | 165 | fakeComponent.props.dataUrl = 'my-api.json'; 166 | 167 | fakeComponent.componentWillMount(); 168 | fakeComponent.componentWillUnmount(); 169 | 170 | var prevCallCount = fakeComponent.setState.callCount; 171 | 172 | var onError = $.ajax.args[0][0].error; 173 | onError({}, 503, 'foobar'); 174 | 175 | expect(fakeComponent.setState.callCount).to.equal(prevCallCount); 176 | }); 177 | 178 | it('should set isFetchingData to false in initial state', function() { 179 | var initialState = fakeComponent.getInitialState(); 180 | 181 | expect(initialState.isFetchingData).to.equal(false); 182 | }); 183 | 184 | it('should not try to abort completed requests', function() { 185 | ajaxStub.abort = sinon.spy(); 186 | fakeComponent.props.dataUrl = 'my-api.json'; 187 | 188 | fakeComponent.componentWillMount(); 189 | 190 | var onComplete = $.ajax.args[0][0].complete; 191 | onComplete(); 192 | 193 | fakeComponent.componentWillUnmount(); 194 | 195 | expect(ajaxStub.abort).to.not.have.been.called; 196 | }); 197 | 198 | it('should set dataError to null in initial state', function() { 199 | var initialState = fakeComponent.getInitialState(); 200 | 201 | expect(initialState.dataError).to.equal(null); 202 | }); 203 | 204 | describe('dataError set in this.state for failed requests', function() { 205 | var url = 'www.foo.bar', 206 | setStateArgs, 207 | err = {toString: sinon.stub()}, 208 | xhrObj = { 209 | status: 404, 210 | statusText: 'Not found', 211 | responseText: '{"foo": "bar"}' 212 | }; 213 | 214 | beforeEach(function() { 215 | fakeComponent.props.dataUrl = url; 216 | 217 | fakeComponent.componentWillMount(); 218 | 219 | var onError = $.ajax.args[0][0].error; 220 | onError(xhrObj, xhrObj.status, err); 221 | 222 | setStateArgs = fakeComponent.setState.lastCall.args[0]; 223 | }); 224 | 225 | afterEach(function() { 226 | err.toString.reset(); 227 | }); 228 | 229 | it('should save the url of a failed request', function() { 230 | expect(setStateArgs.dataError.url).to.equal(url); 231 | }); 232 | 233 | it('should save the statusCode of a failed request', function() { 234 | expect(setStateArgs.dataError.statusCode).to.equal(xhrObj.status); 235 | }); 236 | 237 | it('should save the statusText of a failed request', function() { 238 | expect(setStateArgs.dataError.statusText).to.equal(xhrObj.status); 239 | }); 240 | 241 | it('should save the message of a failed request', function() { 242 | expect(err.toString.callCount).to.equal(1); 243 | }); 244 | 245 | it('should save the response of a failed request', function() { 246 | expect(setStateArgs.response).to.equal(xhrObj.responseJSON); 247 | }); 248 | }); 249 | 250 | describe('dataError should reset', function() { 251 | beforeEach(function() { 252 | fakeComponent.props.dataUrl = 'foo'; 253 | 254 | fakeComponent.componentWillMount(); 255 | 256 | var onError = $.ajax.args[0][0].error; 257 | ajaxStub.abort = function() {}; 258 | onError({}, 404, 'foobar'); 259 | }); 260 | 261 | it('should reset dataError when refreshing data', function() { 262 | fakeComponent.refreshData(); 263 | 264 | var setStateArgs = fakeComponent.setState.lastCall.args[0]; 265 | 266 | expect(setStateArgs.dataError).to.equal(null); 267 | }); 268 | 269 | it('should reset dataError when receiving new dataUrl', function() { 270 | fakeComponent.componentWillReceiveProps({ 271 | dataUrl: 'bar' 272 | }); 273 | 274 | var setStateArgs = fakeComponent.setState.lastCall.args[0]; 275 | 276 | expect(setStateArgs.dataError).to.equal(null); 277 | }); 278 | }); 279 | 280 | describe('when stopping fetching', function() { 281 | var nativeClearInterval = clearInterval; 282 | 283 | beforeEach(function() { 284 | ajaxStub.abort = sinon.spy(); 285 | 286 | fakeComponent.props.dataUrl = 'my-api.json'; 287 | fakeComponent.componentWillMount(); 288 | 289 | fakeComponent.stopFetching(); 290 | }); 291 | 292 | it('should abort ajax call', function() { 293 | expect(ajaxStub.abort).to.have.been.called; 294 | }); 295 | }); 296 | 297 | describe('polling', function() { 298 | var clock; 299 | 300 | beforeEach(function() { 301 | clock = sinon.useFakeTimers(); 302 | }); 303 | 304 | afterEach(function() { 305 | clock.restore(); 306 | }); 307 | 308 | describe('wtih simple dataUrl', function() { 309 | beforeEach(function() { 310 | fakeComponent.props.dataUrl = 'my-api.json'; 311 | }); 312 | 313 | describe('when poll interval is given', function() { 314 | beforeEach(function() { 315 | fakeComponent.props.pollInterval = random(1000, 5000); 316 | 317 | fakeComponent.componentWillMount(); 318 | }); 319 | 320 | it('should start polling after mounting', function() { 321 | $.ajax.reset(); 322 | 323 | var times = random(2, 5); 324 | 325 | clock.tick(fakeComponent.props.pollInterval * times); 326 | 327 | expect($.ajax).to.have.callCount(times); 328 | }); 329 | 330 | it('should poll the right URL', function() { 331 | $.ajax.reset(); 332 | 333 | var times = random(2, 5); 334 | 335 | clock.tick(fakeComponent.props.pollInterval * times); 336 | 337 | expect($.ajax).to.have.always.been.calledWith( 338 | sinon.match.has('url', fakeComponent.props.dataUrl)); 339 | }); 340 | 341 | it('should stop polling when unmounting', function() { 342 | $.ajax.reset(); 343 | 344 | fakeComponent.componentWillUnmount(); 345 | 346 | var times = random(2, 5); 347 | 348 | clock.tick(fakeComponent.props.pollInterval * times); 349 | 350 | expect($.ajax).to.not.have.been.called; 351 | }); 352 | 353 | it('should stop polling when receiving pollInterval=0', function() { 354 | $.ajax.reset(); 355 | 356 | var times = random(2, 5), 357 | oldInterval = fakeComponent.props.pollInterval; 358 | 359 | fakeComponent.componentWillReceiveProps( 360 | Object.assign({}, fakeComponent.props, {pollInterval: 0})); 361 | 362 | clock.tick(oldInterval * times); 363 | 364 | expect($.ajax).to.not.have.been.called; 365 | }); 366 | 367 | it('should restart polling when receiving a new poll interval', 368 | function() { 369 | $.ajax.reset(); 370 | 371 | var times = random(2, 5), 372 | oldInterval = fakeComponent.props.pollInterval, 373 | newInterval = oldInterval * 2; 374 | 375 | fakeComponent.componentWillReceiveProps( 376 | Object.assign({}, fakeComponent.props, { 377 | pollInterval: newInterval 378 | })); 379 | 380 | clock.tick(newInterval * times); 381 | 382 | expect($.ajax).to.have.callCount(times); 383 | }); 384 | 385 | it('should stop polling when told to do so', function() { 386 | $.ajax.reset(); 387 | 388 | fakeComponent.stopPolling(); 389 | 390 | clock.tick(fakeComponent.props.pollInterval); 391 | 392 | expect($.ajax).to.not.have.been.called; 393 | }); 394 | 395 | it('should start polling when told to resume', function() { 396 | $.ajax.reset(); 397 | fakeComponent.stopPolling(); 398 | fakeComponent.resumePolling(); 399 | 400 | var times = random(2, 5); 401 | 402 | clock.tick(fakeComponent.props.pollInterval * times); 403 | 404 | expect($.ajax).to.have.callCount(times); 405 | }); 406 | 407 | it('should only poll once if told to resume multiple times', 408 | function() { 409 | $.ajax.reset(); 410 | fakeComponent.stopPolling(); 411 | 412 | for (var i = 0, n = random(2, 5); i < n; i++) { 413 | fakeComponent.resumePolling(); 414 | } 415 | 416 | var times = random(2, 5); 417 | 418 | clock.tick(fakeComponent.props.pollInterval * times); 419 | 420 | expect($.ajax).to.have.callCount(times); 421 | }); 422 | 423 | it('should poll the new URL when receiving one', function() { 424 | $.ajax.reset(); 425 | 426 | var times = random(2, 5), 427 | interval = random(1000, 5000), 428 | oldUrl = fakeComponent.props.dataUrl, 429 | newUrl = oldUrl + 'new'; 430 | 431 | fakeComponent.componentWillReceiveProps( 432 | Object.assign({}, fakeComponent.props, {dataUrl: newUrl})); 433 | 434 | clock.tick(interval * times); 435 | 436 | expect($.ajax).to.have.always.been.calledWith( 437 | sinon.match.has('url', newUrl)); 438 | }); 439 | }); 440 | 441 | describe('when poll interval is not given', function() { 442 | beforeEach(function() { 443 | fakeComponent.props.pollInterval = 0; 444 | 445 | fakeComponent.componentWillMount(); 446 | }); 447 | 448 | it('should not start polling after mounting', function() { 449 | $.ajax.reset(); 450 | 451 | var times = random(2, 5); 452 | 453 | clock.tick(fakeComponent.props.pollInterval * times); 454 | 455 | expect($.ajax).to.not.have.been.called; 456 | }); 457 | 458 | it('should start polling when receiving a poll interval', function() { 459 | $.ajax.reset(); 460 | 461 | var times = random(2, 5), 462 | interval = random(1000, 5000); 463 | 464 | fakeComponent.componentWillReceiveProps( 465 | Object.assign({}, fakeComponent.props, { 466 | pollInterval: interval 467 | })); 468 | 469 | clock.tick(interval * times); 470 | 471 | expect($.ajax).to.have.callCount(times); 472 | }); 473 | }); 474 | }); 475 | 476 | describe('with custom dataUrl', function() { 477 | beforeEach(function() { 478 | fakeComponent.getDataUrl = sinon.stub().returns('foobar.json'); 479 | fakeComponent.props.pollInterval = random(1000, 5000); 480 | 481 | fakeComponent.componentWillMount(); 482 | }); 483 | 484 | it('should poll the right URL', function() { 485 | $.ajax.reset(); 486 | 487 | var times = random(2, 5); 488 | 489 | clock.tick(fakeComponent.props.pollInterval * times); 490 | 491 | expect(fakeComponent.getDataUrl).to.have.been.calledWith( 492 | fakeComponent.props); 493 | 494 | expect($.ajax).to.have.always.been.calledWith( 495 | sinon.match.has('url', 'foobar.json')); 496 | }); 497 | }); 498 | }); 499 | }); 500 | 501 | describe('cross domain', function() { 502 | beforeEach(function() { 503 | Object.assign(fakeComponent, DataFetch({crossDomain: true})); 504 | }); 505 | 506 | it('should send cross-domain cookies', function() { 507 | fakeComponent.props.dataUrl = 'my-api.json'; 508 | 509 | fakeComponent.componentWillMount(); 510 | 511 | expect($.ajax).to.have.been.calledWith( 512 | sinon.match.has('xhrFields', 513 | sinon.match.has('withCredentials', true))); 514 | }); 515 | }); 516 | 517 | describe('error callback', function() { 518 | var errorCallback, context; 519 | 520 | beforeEach(function() { 521 | errorCallback = sinon.spy(); 522 | 523 | Object.assign(fakeComponent, DataFetch({onError: errorCallback})); 524 | 525 | fakeComponent.props.dataUrl = 'my-api.json'; 526 | 527 | fakeComponent.componentWillMount(); 528 | 529 | context = {url: 'my-api.json'}; 530 | 531 | $.ajax.args[0][0].error.call( 532 | context, {foo: 'bar'}, 42, 'foobared'); 533 | 534 | }); 535 | 536 | it('should call the error callback when a request errors', function() { 537 | expect(errorCallback).to.have.been.calledWith( 538 | {foo: 'bar'}, 42, 'foobared'); 539 | }); 540 | 541 | it('should call the error callback with the right context', function() { 542 | expect(errorCallback).to.have.been.calledOn(context); 543 | }); 544 | }); 545 | }); 546 | --------------------------------------------------------------------------------