Added browser history management issue #1

This commit is contained in:
Camel Aissani 2016-07-17 13:37:35 +02:00
parent 9c7681e2b4
commit 390cd54a48
2 changed files with 232 additions and 33 deletions

View File

@ -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);
};

View File

@ -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
// // <base> and uri (initial uri) allow to do pushState in jsdom
// jsdom.env({
// html:`
// <html>
// <head>
// <base href="http://localhost:8080/"></base>
// </head>
// </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', () => {