Add basic for pjax navigation

This commit is contained in:
Samy Pesse
2016-09-26 17:26:36 +02:00
parent 7fa3e34f9f
commit ff42b58f77
6 changed files with 238 additions and 5 deletions
+106 -2
View File
@@ -1,13 +1,116 @@
const ACTION_TYPES = require('./TYPES');
const getPayload = require('../lib/getPayload');
const SUPPORTED = (
typeof window !== 'undefined' &&
window.history && window.history.pushState && window.history.replaceState &&
// pushState isn't reliable on iOS until 5.
!navigator.userAgent.match(/((iPod|iPhone|iPad).+\bOS\s+[1-4]\D|WebApps\/.+CFNetwork)/)
);
let PUSH_ID = 0;
/**
* Generate a new state to be pushed or replaced
* @param {Object}
*/
function genState() {
return {
id: (PUSH_ID++)
};
}
/**
* Push a new url into the navigation
* @param {String} uri
* @return {Action} action
*/
function pushURI(uri) {
return () => {
const state = genState();
if (SUPPORTED) {
window.history.pushState(state, '', uri);
} else {
redirect(uri);
}
};
}
/**
* Replace current state in navigation
* @param {String} uri
* @return {Action} action
*/
function replaceURI(uri) {
return () => {
const state = genState();
if (SUPPORTED) {
window.history.replaceState(state, '', uri);
} else {
redirect(uri);
}
};
}
/**
* Hard redirection
* @param {String} uri
* @return {Action} action
*/
function redirect(uri) {
return () => {
window.location.href = uri;
};
}
/**
* Fetch a new page and update the store accordingly
* @param {String} uri
* @param {Boolean} options.replace
* @return {Action} action
*/
function fetchPage(uri) {
return (dispatch, getState) => {
function fetchPage(uri, options) {
const { replace } = options;
return (dispatch, getState) => {
const prevURI = location.href;
dispatch({ type: ACTION_TYPES.PAGE_FETCH_START });
if (replace) {
dispatch(replaceURI(uri));
} else {
dispatch(pushURI(uri));
}
window.fetch(uri, {
credentials: 'include'
})
.then(
response => {
return response.text();
}
)
.then(
html => {
const payload = getPayload(html);
if (!payload) {
throw new Error('No payload found in page');
}
dispatch({ type: ACTION_TYPES.PAGE_FETCH_END, payload });
}
)
.catch(
error => {
dispatch(replaceURI(prevURI));
dispatch(redirect(uri));
dispatch({ type: ACTION_TYPES.PAGE_FETCH_ERROR, error });
}
);
};
}
@@ -21,6 +124,7 @@ function fetchArticle(article) {
}
module.exports = {
pushURI,
fetchPage,
fetchArticle
};
+2 -1
View File
@@ -1,4 +1,5 @@
const React = require('react');
const ReactRedux = require('react-redux');
const SummaryArticleShape = require('../shapes/SummaryArticle');
const Link = React.createClass({
@@ -35,4 +36,4 @@ const Link = React.createClass({
}
});
module.exports = Link;
module.exports = ReactRedux.connect()(Link);
@@ -0,0 +1,112 @@
const React = require('react');
const ReactRedux = require('react-redux');
const navigation = require('../actions/navigation');
/**
* Check if an element is inside a link
* @param {DOMElement} el
* @param {String} name
* @return {DOMElement|undefined
*/
function findParentByTagName(el, name) {
while (el && el !== document) {
if (el.tagName && el.tagName.toUpperCase() === name) {
return el;
}
el = el.parentNode;
}
return false;
}
/**
* Internal: Return the `href` component of given URL object with the hash
* portion removed.
*
* @param {Location|HTMLAnchorElement} location
* @return {String}
*/
function stripHash(location) {
return location.href.replace(/#.*/, '');
}
/**
* Test if a click event should be handled,
* return the new url if it's a normal lcick
*/
function getHrefForEvent(event) {
const link = findParentByTagName(event.target, 'A');
if (!link)
return;
// Middle click, cmd click, and ctrl click should open
// links in a new tab as normal.
if (event.which > 1 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
return;
// Ignore cross origin links
if (location.protocol !== link.protocol || location.hostname !== link.hostname)
return;
// Ignore case when a hash is being tacked on the current URL
if (link.href.indexOf('#') > -1 && stripHash(link) == stripHash(location))
return;
// Ignore event with default prevented
if (event.defaultPrevented)
return;
// Explicitly ignored
if (link.getAttribute('data-nopjax'))
return;
return link.href;
}
/*
Wrapper to bind all navigation events to fetch pages.
*/
const PJAXWrapper = React.createClass({
propTypes: {
children: React.PropTypes.node,
dispatch: React.PropTypes.func
},
onClick(event) {
const { dispatch } = this.props;
const href = getHrefForEvent(event);
if (!href) {
return;
}
event.preventDefault();
dispatch(navigation.fetchPage(href));
},
onPopState(event) {
const { dispatch } = this.props;
event.preventDefault();
dispatch(navigation.fetchPage(location.href, { replace: true }));
},
componentDidMount() {
document.addEventListener('click', this.onClick, false);
window.addEventListener('popstate', this.onPopState, false);
},
componentWillUnmount() {
document.removeEventListener('click', this.onClick, false);
window.removeEventListener('popstate', this.onPopState, false);
},
render() {
return React.Children.only(this.props.children);
}
});
module.exports = ReactRedux.connect()(PJAXWrapper);
@@ -1,3 +1,5 @@
const ACTION_TYPES = require('../actions/TYPES');
const composeReducer = require('../composeReducer');
const createReducer = require('../createReducer');
+12 -1
View File
@@ -1,4 +1,5 @@
const { Record } = require('immutable');
const ACTION_TYPES = require('../actions/TYPES');
const DEFAULTS = {
title: '',
@@ -16,5 +17,15 @@ class PageState extends Record(DEFAULTS) {
}
module.exports = (state, action) => {
return PageState.create(state);
state = PageState.create(state);
switch (action.type) {
case ACTION_TYPES.PAGE_FETCH_END:
return state.merge(action.payload.page);
default:
return state;
}
};
+4 -1
View File
@@ -1,6 +1,7 @@
const React = require('react');
const { Provider } = require('react-redux');
const { InjectedComponent } = require('./components/InjectedComponent');
const PJAXWrapper = require('./components/PJAXWrapper');
/**
* Render the application for a store
@@ -10,7 +11,9 @@ const { InjectedComponent } = require('./components/InjectedComponent');
function renderWithStore(store) {
return (
<Provider store={store}>
<InjectedComponent matching={{ role: 'Body' }} />
<PJAXWrapper>
<InjectedComponent matching={{ role: 'Body' }} />
</PJAXWrapper>
</Provider>
);
}