diff --git a/lib/application.js b/lib/application.js index 27796cd..59e4e59 100755 --- a/lib/application.js +++ b/lib/application.js @@ -26,7 +26,8 @@ export default class Application { constructor() { this.routers = []; - this.DOMLoading = false; + this.isDOMLoaded = false; + this.isDOMReady = false; this.settings = new Settings(); } @@ -76,33 +77,40 @@ export default class Application { */ listen(callback) { - document.onreadystatechange = () => { - const uri = window.location.pathname + window.location.search; - const method = 'GET'; - const request = {method, uri}; - const response = {status: 200, statusText: 'OK'}; + window.onbeforeunload = () => { + this._callMiddlewareExited(); + }; - // gathers all routes impacted by the current browser location - const currentRoutes = this._routes(uri, method); + window.onpopstate = (event) => { + if (event.state) { + const {response, request} = event.state; + const currentRoutes = this._routes(request.uri, request.method); - // listen dom events - if (document.readyState === 'loading') { - this.DOMLoading = true; this._callMiddlewareEntered(currentRoutes, request); - } else if (document.readyState === 'interactive') { - if (!this.DOMLoading) { + this._callMiddlewareUpdated(currentRoutes, request, response); + } + }; + + document.onreadystatechange = () => { + const request = {method: 'GET', uri: window.location.pathname + window.location.search}; + const response = {status: 200, statusText: 'OK'}; + const currentRoutes = this._routes(); + // DOM state + if (document.readyState === 'loading' && !this.isDOMLoaded) { + this.isDOMLoaded = true; + this._callMiddlewareEntered(currentRoutes, request); + } else if (document.readyState === 'interactive' && !this.isDOMReady) { + if (!this.isDOMLoaded) { + this.isDOMLoaded = true; this._callMiddlewareEntered(currentRoutes, request); } + this.isDOMReady = true; this._callMiddlewareUpdated(currentRoutes, request, response); if (callback) { callback(request, response); } } }; - - window.addEventListener('beforeunload', () => { - this._callMiddlewareExited(); - }); } @@ -183,12 +191,13 @@ export default class Application { * @private */ - _routes(uri, method) { + _routes(uri=window.location.pathname + window.location.search, method='GET') { const currentRoutes = []; for (const router of this.routers) { const routes = router.routes(uri, method); currentRoutes.push(...routes); } + return currentRoutes; } @@ -297,13 +306,12 @@ export default class Application { /** - * Make an ajax request. + * Make an ajax request. Manage History#pushState if history object set. * * @private */ - _fetch({method, uri, headers, data}, resolve, reject) { - + _fetch({method, uri, headers, data, history}, resolve, reject) { const httpMethodTransformer = this.get(`http ${method} transformer`); if (httpMethodTransformer) { uri = httpMethodTransformer.uri ? httpMethodTransformer.uri({uri, headers, data}) : uri; @@ -323,6 +331,13 @@ export default class Application { // invokes http request this.settings.get('http requester').fetch({method, uri, headers, data}, (request, response) => { + if (history) { + if (history.state) { + response.historyState = history.state; + } + history.state = {request, response}; + window.history.pushState(history.state, history.title, history.uri); + } this._callMiddlewareUpdated(currentRoutes, request, response); if (resolve) { resolve(request, response); @@ -403,21 +418,24 @@ HTTP_METHODS.reduce((reqProto, method) => { * * // HTTP GET method * httpGet('/route1'); + * + * // HTTP GET method * httpGet({uri: '/route1', data: {'p1': 'val1'}); * // uri invoked => /route1?p1=val1 * - * // HTTP POST method - * httpPost('/user'); - * ... + * // HTTP GET method with browser history management + * httpGet({uri: '/api/users', history: {state: {foo: "bar"}, title: 'users page', uri: '/view/users'}); * - * @param {String|Object} uri or object containing uri, http headers, data + * Samples above can be applied on other HTTP methods. + * + * @param {String|Object} uri or object containing uri, http headers, data, history * @param {Function} success callback * @param {Function} failure callback * @public */ const httpMethodName = 'http'+method.charAt(0).toUpperCase() + method.slice(1).toLowerCase(); reqProto[httpMethodName] = function(request, resolve, reject) { - let {uri, headers, data} = request; + let {uri, headers, data, history} = request; if (!uri) { uri = request; } @@ -425,7 +443,8 @@ HTTP_METHODS.reduce((reqProto, method) => { uri, method, headers, - data + data, + history }, resolve, reject); }; diff --git a/test/application-test.js b/test/application-test.js index 5de87f4..70c3838 100755 --- a/test/application-test.js +++ b/test/application-test.js @@ -1,6 +1,7 @@ /*eslint-env mocha*/ import chai, {assert} from 'chai'; import sinon from 'sinon'; +//import jsdom from 'jsdom'; import frontexpress from '../lib/frontexpress'; import Requester from '../lib/requester'; @@ -25,18 +26,108 @@ describe('Application', () => { }); }); - describe('listen method', () => { - let eventFn = {}; + // // JSDOM cannot manage pushState/onpopstate/window.location + // see https://github.com/tmpvar/jsdom/issues/1565 + // + // describe('listen method with JSDOM', () => { + // before(() => { + // // Init DOM with a fake document + // // and uri (initial uri) allow to do pushState in jsdom + // jsdom.env({ + // html:` + // + // + // + // + // + // `, + // url: 'http://localhost:8080/', + // done(err, window) { + // global.window = window; + // global.document = window.document; + // window.console = global.console; + // } + // }); + // }); + // let requester; + // beforeEach(()=>{ + // requester = new Requester(); + // sinon.stub(requester, 'fetch', ({uri, method, headers, data}, resolve, reject) => { + // resolve( + // {uri, method, headers, data}, + // {status: 200, statusText: 'OK', responseText:''} + // ); + // }); + // }); + + // it('history management without state object', (done) => { + // const spy_pushState = sinon.spy(window.history, 'pushState'); + + // const app = frontexpress(); + // const m = frontexpress.Middleware(); + // const spy_middleware = sinon.stub(m, 'updated'); + + // app.set('http requester', requester); + // app.use('/api/route1', m); + // app.listen(); + + // const spy_onpopstate = sinon.spy(window, 'onpopstate'); + + // app.httpGet({uri:'/api/route1', history: { + // uri: '/route1', + // title: 'route1' + // }}, + // (req, res) => { + // // On request succeeded + // assert(spy_onpopstate.callCount === 0); + // assert(spy_pushState.calledOnce); + // assert(spy_middleware.calledOnce); + + // spy_middleware.reset(); + // window.history.back(); + // window.history.forward(); + // assert(spy_onpopstate.calledOnce); + // assert(spy_middleware.calledOnce); + + // done(); + // }); + // }); + // }); + + describe('listen method', () => { + // Here I cannot use jsdon to make these tests :( + // JSDOM cannot simulate readyState changes beforeEach(() => { + const browserHistory = [{uri: '/'}]; + let browserHistoryIndex = 0; + global.document = {}; global.window = { - addEventListener(eventType, callback) { - eventFn[eventType] = callback; - }, location: { pathname: '/route1', search: '?a=b' + }, + history: { + pushState(state, title, pathname) { + browserHistory.push({uri: pathname, state}); + browserHistoryIndex++; + global.window.location.pathname = browserHistory[browserHistoryIndex].uri; + }, + forward() { + browserHistoryIndex++; + global.window.location.pathname = browserHistory[browserHistoryIndex].uri; + if (browserHistory[browserHistoryIndex].state) { + window.onpopstate({state:browserHistory[browserHistoryIndex].state}); + } + }, + back() { + browserHistoryIndex--; + global.window.location.pathname = browserHistory[browserHistoryIndex].uri; + if (browserHistory[browserHistoryIndex].state) { + window.onpopstate({state: browserHistory[browserHistoryIndex].state}); + } + } } }; }); @@ -93,13 +184,102 @@ describe('Application', () => { app.use('/route1', m); app.listen(() => { //simulate beforeunload - eventFn['beforeunload'](); + window.onbeforeunload(); }); //simulate readystatechange document.readyState = 'interactive'; document.onreadystatechange(); }); + + it('history management without state object', (done) => { + const requester = new Requester(); + sinon.stub(requester, 'fetch', ({uri, method, headers, data}, resolve, reject) => { + resolve( + {uri, method, headers, data}, + {status: 200, statusText: 'OK', responseText:''} + ); + }); + + const spy_pushState = sinon.spy(window.history, 'pushState'); + + const app = frontexpress(); + const m = frontexpress.Middleware(); + const spy_middleware = sinon.stub(m, 'updated'); + + app.set('http requester', requester); + app.use('/api/route1', m); + app.listen(); + + const spy_onpopstate = sinon.spy(window, 'onpopstate'); + + app.httpGet({uri:'/api/route1', history: { + uri: '/route1', + title: 'route1' + }}, + (req, res) => { + // On request succeeded + assert(spy_onpopstate.callCount === 0); + assert(spy_pushState.calledOnce); + assert(spy_middleware.calledOnce); + + spy_middleware.reset(); + window.history.back(); + window.history.forward(); + assert(spy_onpopstate.calledOnce); + assert(spy_middleware.calledOnce); + + done(); + }); + }); + + it('history management with state object', (done) => { + let stateObj; + const requester = new Requester(); + sinon.stub(requester, 'fetch', ({uri, method, headers, data}, resolve, reject) => { + resolve( + {uri, method, headers, data}, + {status: 200, statusText: 'OK', responseText:''} + ); + }); + + const spy_pushState = sinon.spy(window.history, 'pushState'); + + const app = frontexpress(); + const m = frontexpress.Middleware(); + const spy_middleware = sinon.stub(m, 'updated', (req, res) => { + stateObj = res.historyState; + }); + + app.set('http requester', requester); + app.use('/api/route1', m); + app.listen(); + + const spy_onpopstate = sinon.spy(window, 'onpopstate'); + + app.httpGet({uri:'/api/route1', history: { + uri: '/route1', + title: 'route1', + state: {a: 'b', c: 'd'} + }}, + (req, res) => { + // On request succeeded + assert(spy_onpopstate.callCount === 0); + assert(spy_pushState.calledOnce); + assert(spy_middleware.calledOnce); + + spy_middleware.reset(); + window.history.back(); + window.history.forward(); + assert(spy_onpopstate.calledOnce); + assert(spy_middleware.calledOnce); + assert(stateObj); + assert(stateObj.a === 'b'); + assert(stateObj.c === 'd'); + + done(); + }); + }); }); describe('set/get setting method', () => {