UNPKG

@r/platform

Version:

A set of tools to enable easy universal rendering and page navigation on a React + Redux stack

438 lines (361 loc) 11.5 kB
import React from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { isEqual, isEmpty } from 'lodash/lang'; import * as navigationActions from './actions'; import { METHODS } from './router'; import { extractQuery, createQuery } from './pageUtils'; import shouldGoBack from './shouldGoBack'; const T = React.PropTypes; const isNewTabClick = (e) => e.metaKey || e.ctrlKey || e.button === 1 || e.button === 4; const findLinkParent = el => { if (el.tagName === 'A') { return el; } if (el.parentNode) { return findLinkParent(el.parentNode); } }; // ****** Anchor export class _Anchor extends React.Component { static propTypes = { href: T.string, noop: T.bool, className: T.string, style: T.object, navigateToPage: T.func, onClick: T.func, }; static defaultProps = { href: '#', noop: false, navigateToPage: () => {}, onClick: () => {}, }; handleClick = e => { if (isNewTabClick(e)) { return; } this.props.onClick(e); if (e.defaultPrevented) { return; } e.stopPropagation(); e.preventDefault(); const url = this.props.href.split('?')[0]; const queryParams = extractQuery(this.props.href); this.props.navigateToPage(url, queryParams); } render() { const { href, className, style, children } = this.props; return ( <a href={ href } className={ className } style={ style } onClick={ this.handleClick } > { children } </a> ); } } export class _BackAnchor extends React.Component { static propTypes = { href: T.string, backupHref: T.string, noop: T.bool, className: T.string, style: T.object, referrer: T.string, navigateToPage: T.func, onClick: T.func, }; static defaultProps = { href: '#', backupHref: '#', noop: false, referrer: '', navigateToPage: () => {}, onClick: () => {}, }; static AUTO_ROUTE = '__backanchor-auto-route'; handleClick = e => { if (isNewTabClick(e)) { return; } this.props.onClick(e); if (e.defaultPrevented) { return; } e.stopPropagation(); e.preventDefault(); const { urlHistory, currentIndex, href, referrer, backupHref } = this.props; const unParsedUrl = href === _BackAnchor.AUTO_ROUTE ? referrer || backupHref : href; const url = unParsedUrl.split('?')[0]; const queryParams = extractQuery(unParsedUrl); if (shouldGoBack(urlHistory, currentIndex, url, queryParams)) { history.back(); } else { this.props.navigateToPage(url, queryParams); } } render() { const { href, className, style, children, referrer, backupHref } = this.props; const renderHref = href === _BackAnchor.AUTO_ROUTE ? referrer || backupHref : href; return ( <a href={ renderHref } className={ className } style={ style } onClick={ this.handleClick } > { children } </a> ); } } const anchorSelector = createSelector( state => state.platform.history, state => state.platform.currentPageIndex, state => state.platform.currentPage.referrer, (urlHistory, currentIndex, referrer) => ({ urlHistory, currentIndex, referrer }) ); const anchorDispatcher = dispatch => ({ navigateToPage: (url, queryParams) => dispatch( navigationActions.navigateToUrl(METHODS.GET, url, { queryParams }) ), }); export const Anchor = connect(null, anchorDispatcher)(_Anchor); export const BackAnchor = connect(anchorSelector, anchorDispatcher)(_BackAnchor); BackAnchor.AUTO_ROUTE = _BackAnchor.AUTO_ROUTE; // ****** LinkJacker // _LinkHijacker is a component used to explicitly hijack all link clicks // in a given area and transform them into calls to navigationActions.navigateToUrl. // This is useful in situations where you have content thats created // outside of your app's react templates (e.g., user generated content or pages // stored in a wiki). // It purposefully doesn't render any markup and attaches its handlers to the child // element you pass in, allowing full control of the markup. export class _LinkHijacker extends React.Component { static propTypes = { children: T.element.isRequired, onLinkClick: T.func, // you can use a normal onclick handler attached // to your content node. Or, if you'd like to only run a click handler // when a an a tag within this element tree has been clicked, // you can use `onLinkClick` urlRegexp: T.instanceOf(RegExp), // a regexp used to validate a url for // navigating to. It is expected that the regexp will handle capturing // the proper path we want to navigate to, in the first match. (match[1]); // (note: non-capturing groups might be helpful in doing so). navigateToPage: T.func.isRequired, // intended to supplied by connect } static defaultProps = { navigateToPage: () => {}, onLinkClick: () => {}, }; extractValidPath($link) { const href = $link.getAttribute('href'); if (!href) { return; } // if its a relative link, use it without validation if (href.indexOf('//') === -1) { return href; } // if we have a regexp to validate and extract paths, return it const { urlRegexp }= this.props; if (urlRegexp) { const match = href.match(urlRegexp); if (match && match[1]) { return match[1]; } } } onClick = e => { // let the content node's click handler run first and allow it call // preventDefault if desired. const { children: child } = this.props; if (child && child.props.onClick) { child.props.onClick(e); if (e.defaultPrevented) { return; } } const $link = findLinkParent(e.target); if (!$link) { return; } if (isNewTabClick(e)) { return; } const path = this.extractValidPath($link); if (!path) { return; } this.props.onLinkClick(path, e, $link); if (e.defaultPrevented) { return; } e.stopPropagation(); e.preventDefault(); const url = path.split('?')[0]; const queryParams = extractQuery(path); this.props.navigateToPage(url, queryParams); } render() { const { children: child } = this.props; return React.cloneElement(React.Children.only(child), { onClick: this.onClick, }); } } export const LinkHijacker = connect(null, anchorDispatcher)(_LinkHijacker); // ****** Form const getValues = form => { if (!form || form.nodeName.toLowerCase() !== 'form') { return {}; } return Array.from(form.elements).reduce((values, el) => { if (el.name) { switch (el.type) { case 'checkbox': { if (!values[el.name]) { values[el.name] = []; } if (el.checked) { values[el.name].push(el.value); } break; } case 'select-multiple': { values[el.name] = Array.from(el.options).map(o => o.value); break; } case 'radio': { if (el.checked) { values[el.name] = el.value; } break; } default: { values[el.name] = el.value; break; } } } return values; }, {}); }; const preventFormSubmit = e => { e.preventDefault(); // iOS doesn't automatically unfocus form inputs, this leaves the keyboard // open and can create weird states where clicking anything on the page // re-opens the keyboard if (document.activeElement) { document.activeElement.blur(); } }; export class _Form extends React.Component { static propTypes = { action: T.string.isRequired, method: T.oneOf([METHODS.POST, METHODS.GET]), className: T.string, style: T.object, onSubmit: T.func, }; static defaultProps = { method: METHODS.POST, onSubmit: () => {}, }; handleSubmit = e => { preventFormSubmit(e); const form = e.target; this.props.onSubmit(this.props.action, this.props.method, getValues(form)); } render() { const { className, action, method, style, children } = this.props; return ( <form className={ className } action={ action } method={ method } style={ style } onSubmit={ this.handleSubmit } > { children } </form> ); } } const formDispatcher = dispatch => ({ onSubmit: (url, method, bodyParams) => dispatch( navigationActions.navigateToUrl(method, url, { bodyParams }) ), }); export const Form = connect(null, formDispatcher)(_Form); export class JSForm extends React.Component { static propTypes = { onSubmit: T.func.isRequired, className: T.string, style: T.object, }; handleSubmit = e => { preventFormSubmit(e); const form = e.target; this.props.onSubmit(getValues(form)); } render() { const { className, style, children } = this.props; return ( <form className={ className } style={ style } onSubmit={ this.handleSubmit }> { children } </form> ); } } // ****** UrlSync export class _UrlSync extends React.Component { static propTypes = { pageIndex: T.number.isRequired, history: T.array.isRequired, gotoPageIndex: T.func, navigateToPage: T.func, }; static defaultProps = { gotoPageIndex: () => {}, navigateToPage: () => {}, }; componentDidMount() { const handlePopstate = () => { const pathname = self.location.pathname; const currentQuery = extractQuery(self.location.search); const currentHash = {}; // TODO: address how hashes are displayed let pageIndex = -1; let hist = {}; for (let i = this.props.history.length - 1; i >= 0; i--) { hist = this.props.history[i]; if (hist.url === pathname && isEqual(hist.queryParams, currentQuery)) { pageIndex = i; break; } } if (pageIndex > -1) { const { url, queryParams, hashParams, urlParams, referrer } = hist; this.props.gotoPageIndex(pageIndex, url, { queryParams, hashParams, urlParams, referrer }); } else { // can't find the url, just navigate this.props.navigateToPage(pathname, currentQuery, currentHash); } }; self.addEventListener('popstate', handlePopstate); self.addEventListener('hashchange', handlePopstate); } componentWillUpdate(nextProps) { const currentQuery = extractQuery(self.location.search); const { pageIndex, history } = nextProps; const page = history[pageIndex]; const newUrl = page.url; const newQuery = page.queryParams; if ((self.location.pathname !== newUrl) || (!isEqual(currentQuery, newQuery))) { if (self.history && self.history.pushState) { let newHref = newUrl; if (!isEmpty(newQuery)) { newHref += createQuery(newQuery); } self.history.pushState({}, '', newHref); } else { self.location = newUrl; } } } render() { return false; } } const urlSelector = createSelector( state => state.platform.currentPageIndex, state => state.platform.history, (pageIndex, history) => ({ pageIndex, history }) ); const urlDispatcher = dispatch => ({ gotoPageIndex: (index, url, data) => dispatch(navigationActions.gotoPageIndex(index, url, data)), navigateToPage: (url, queryParams, hashParams) => dispatch( navigationActions.navigateToUrl(METHODS.GET, url, { queryParams, hashParams }) ), }); export const UrlSync = connect(urlSelector, urlDispatcher)(_UrlSync);