UNPKG

11.5 kBJavaScriptView Raw
1import React from 'react';
2import { connect } from 'react-redux';
3import { createSelector } from 'reselect';
4import { isEqual, isEmpty } from 'lodash/lang';
5
6import * as navigationActions from './actions';
7import { METHODS } from './router';
8import { extractQuery, createQuery } from './pageUtils';
9import shouldGoBack from './shouldGoBack';
10
11const T = React.PropTypes;
12
13const isNewTabClick = (e) => e.metaKey || e.ctrlKey || e.button === 1 || e.button === 4;
14
15const findLinkParent = el => {
16 if (el.tagName === 'A') {
17 return el;
18 }
19
20 if (el.parentNode) {
21 return findLinkParent(el.parentNode);
22 }
23};
24
25// ****** Anchor
26export class _Anchor extends React.Component {
27 static propTypes = {
28 href: T.string,
29 noop: T.bool,
30 className: T.string,
31 style: T.object,
32 navigateToPage: T.func,
33 onClick: T.func,
34 };
35
36 static defaultProps = {
37 href: '#',
38 noop: false,
39 navigateToPage: () => {},
40 onClick: () => {},
41 };
42
43 handleClick = e => {
44 if (isNewTabClick(e)) { return; }
45
46 this.props.onClick(e);
47 if (e.defaultPrevented) { return; }
48
49 e.stopPropagation();
50 e.preventDefault();
51
52 const url = this.props.href.split('?')[0];
53 const queryParams = extractQuery(this.props.href);
54
55 this.props.navigateToPage(url, queryParams);
56 }
57
58 render() {
59 const { href, className, style, children } = this.props;
60
61 return (
62 <a
63 href={ href }
64 className={ className }
65 style={ style }
66 onClick={ this.handleClick }
67 >
68 { children }
69 </a>
70 );
71 }
72}
73
74export class _BackAnchor extends React.Component {
75 static propTypes = {
76 href: T.string,
77 backupHref: T.string,
78 noop: T.bool,
79 className: T.string,
80 style: T.object,
81 referrer: T.string,
82 navigateToPage: T.func,
83 onClick: T.func,
84 };
85
86 static defaultProps = {
87 href: '#',
88 backupHref: '#',
89 noop: false,
90 referrer: '',
91 navigateToPage: () => {},
92 onClick: () => {},
93 };
94
95 static AUTO_ROUTE = '__backanchor-auto-route';
96
97 handleClick = e => {
98 if (isNewTabClick(e)) { return; }
99
100 this.props.onClick(e);
101 if (e.defaultPrevented) { return; }
102
103 e.stopPropagation();
104 e.preventDefault();
105
106 const { urlHistory, currentIndex, href, referrer, backupHref } = this.props;
107
108 const unParsedUrl = href === _BackAnchor.AUTO_ROUTE ? referrer || backupHref : href;
109 const url = unParsedUrl.split('?')[0];
110 const queryParams = extractQuery(unParsedUrl);
111
112 if (shouldGoBack(urlHistory, currentIndex, url, queryParams)) {
113 history.back();
114 } else {
115 this.props.navigateToPage(url, queryParams);
116 }
117 }
118
119 render() {
120 const { href, className, style, children, referrer, backupHref } = this.props;
121 const renderHref = href === _BackAnchor.AUTO_ROUTE
122 ? referrer || backupHref
123 : href;
124
125 return (
126 <a
127 href={ renderHref }
128 className={ className }
129 style={ style }
130 onClick={ this.handleClick }
131 >
132 { children }
133 </a>
134 );
135 }
136}
137
138const anchorSelector = createSelector(
139 state => state.platform.history,
140 state => state.platform.currentPageIndex,
141 state => state.platform.currentPage.referrer,
142 (urlHistory, currentIndex, referrer) => ({ urlHistory, currentIndex, referrer })
143);
144
145const anchorDispatcher = dispatch => ({
146 navigateToPage: (url, queryParams) => dispatch(
147 navigationActions.navigateToUrl(METHODS.GET, url, { queryParams })
148 ),
149});
150
151export const Anchor = connect(null, anchorDispatcher)(_Anchor);
152export const BackAnchor = connect(anchorSelector, anchorDispatcher)(_BackAnchor);
153BackAnchor.AUTO_ROUTE = _BackAnchor.AUTO_ROUTE;
154
155// ****** LinkJacker
156
157// _LinkHijacker is a component used to explicitly hijack all link clicks
158// in a given area and transform them into calls to navigationActions.navigateToUrl.
159// This is useful in situations where you have content thats created
160// outside of your app's react templates (e.g., user generated content or pages
161// stored in a wiki).
162// It purposefully doesn't render any markup and attaches its handlers to the child
163// element you pass in, allowing full control of the markup.
164
165export class _LinkHijacker extends React.Component {
166 static propTypes = {
167 children: T.element.isRequired,
168 onLinkClick: T.func, // you can use a normal onclick handler attached
169 // to your content node. Or, if you'd like to only run a click handler
170 // when a an a tag within this element tree has been clicked,
171 // you can use `onLinkClick`
172 urlRegexp: T.instanceOf(RegExp), // a regexp used to validate a url for
173 // navigating to. It is expected that the regexp will handle capturing
174 // the proper path we want to navigate to, in the first match. (match[1]);
175 // (note: non-capturing groups might be helpful in doing so).
176 navigateToPage: T.func.isRequired, // intended to supplied by connect
177 }
178
179 static defaultProps = {
180 navigateToPage: () => {},
181 onLinkClick: () => {},
182 };
183
184 extractValidPath($link) {
185 const href = $link.getAttribute('href');
186 if (!href) { return; }
187
188 // if its a relative link, use it without validation
189 if (href.indexOf('//') === -1) {
190 return href;
191 }
192
193 // if we have a regexp to validate and extract paths, return it
194 const { urlRegexp }= this.props;
195 if (urlRegexp) {
196 const match = href.match(urlRegexp);
197 if (match && match[1]) {
198 return match[1];
199 }
200 }
201 }
202
203 onClick = e => {
204 // let the content node's click handler run first and allow it call
205 // preventDefault if desired.
206 const { children: child } = this.props;
207 if (child && child.props.onClick) {
208 child.props.onClick(e);
209 if (e.defaultPrevented) { return; }
210 }
211
212 const $link = findLinkParent(e.target);
213 if (!$link) { return; }
214
215 if (isNewTabClick(e)) { return; }
216
217 const path = this.extractValidPath($link);
218 if (!path) { return; }
219
220 this.props.onLinkClick(path, e, $link);
221 if (e.defaultPrevented) { return; }
222
223 e.stopPropagation();
224 e.preventDefault();
225
226 const url = path.split('?')[0];
227 const queryParams = extractQuery(path);
228
229 this.props.navigateToPage(url, queryParams);
230 }
231
232 render() {
233 const { children: child } = this.props;
234
235 return React.cloneElement(React.Children.only(child), {
236 onClick: this.onClick,
237 });
238 }
239}
240
241export const LinkHijacker = connect(null, anchorDispatcher)(_LinkHijacker);
242
243// ****** Form
244const getValues = form => {
245 if (!form || form.nodeName.toLowerCase() !== 'form') {
246 return {};
247 }
248
249 return Array.from(form.elements).reduce((values, el) => {
250 if (el.name) {
251 switch (el.type) {
252 case 'checkbox': {
253 if (!values[el.name]) { values[el.name] = []; }
254 if (el.checked) { values[el.name].push(el.value); }
255 break;
256 }
257 case 'select-multiple': {
258 values[el.name] = Array.from(el.options).map(o => o.value);
259 break;
260 }
261 case 'radio': {
262 if (el.checked) { values[el.name] = el.value; }
263 break;
264 }
265 default: {
266 values[el.name] = el.value;
267 break;
268 }
269 }
270 }
271 return values;
272 }, {});
273};
274
275const preventFormSubmit = e => {
276 e.preventDefault();
277
278 // iOS doesn't automatically unfocus form inputs, this leaves the keyboard
279 // open and can create weird states where clicking anything on the page
280 // re-opens the keyboard
281 if (document.activeElement) {
282 document.activeElement.blur();
283 }
284};
285
286export class _Form extends React.Component {
287 static propTypes = {
288 action: T.string.isRequired,
289 method: T.oneOf([METHODS.POST, METHODS.GET]),
290 className: T.string,
291 style: T.object,
292 onSubmit: T.func,
293 };
294
295 static defaultProps = {
296 method: METHODS.POST,
297 onSubmit: () => {},
298 };
299
300 handleSubmit = e => {
301 preventFormSubmit(e);
302
303 const form = e.target;
304 this.props.onSubmit(this.props.action, this.props.method, getValues(form));
305 }
306
307 render() {
308 const { className, action, method, style, children } = this.props;
309
310 return (
311 <form
312 className={ className }
313 action={ action }
314 method={ method }
315 style={ style }
316 onSubmit={ this.handleSubmit }
317 >
318 { children }
319 </form>
320 );
321 }
322}
323
324const formDispatcher = dispatch => ({
325 onSubmit: (url, method, bodyParams) => dispatch(
326 navigationActions.navigateToUrl(method, url, { bodyParams })
327 ),
328});
329
330export const Form = connect(null, formDispatcher)(_Form);
331
332
333export class JSForm extends React.Component {
334 static propTypes = {
335 onSubmit: T.func.isRequired,
336 className: T.string,
337 style: T.object,
338 };
339
340 handleSubmit = e => {
341 preventFormSubmit(e);
342 const form = e.target;
343 this.props.onSubmit(getValues(form));
344 }
345
346 render() {
347 const { className, style, children } = this.props;
348
349 return (
350 <form className={ className } style={ style } onSubmit={ this.handleSubmit }>
351 { children }
352 </form>
353 );
354 }
355}
356
357
358// ****** UrlSync
359export class _UrlSync extends React.Component {
360 static propTypes = {
361 pageIndex: T.number.isRequired,
362 history: T.array.isRequired,
363 gotoPageIndex: T.func,
364 navigateToPage: T.func,
365 };
366
367 static defaultProps = {
368 gotoPageIndex: () => {},
369 navigateToPage: () => {},
370 };
371
372 componentDidMount() {
373 const handlePopstate = () => {
374 const pathname = self.location.pathname;
375 const currentQuery = extractQuery(self.location.search);
376 const currentHash = {}; // TODO: address how hashes are displayed
377 let pageIndex = -1;
378 let hist = {};
379
380 for (let i = this.props.history.length - 1; i >= 0; i--) {
381 hist = this.props.history[i];
382 if (hist.url === pathname && isEqual(hist.queryParams, currentQuery)) {
383 pageIndex = i;
384 break;
385 }
386 }
387
388 if (pageIndex > -1) {
389 const { url, queryParams, hashParams, urlParams, referrer } = hist;
390 this.props.gotoPageIndex(pageIndex, url, { queryParams, hashParams, urlParams, referrer });
391 } else {
392 // can't find the url, just navigate
393 this.props.navigateToPage(pathname, currentQuery, currentHash);
394 }
395 };
396
397 self.addEventListener('popstate', handlePopstate);
398 self.addEventListener('hashchange', handlePopstate);
399 }
400
401 componentWillUpdate(nextProps) {
402 const currentQuery = extractQuery(self.location.search);
403 const { pageIndex, history } = nextProps;
404 const page = history[pageIndex];
405 const newUrl = page.url;
406 const newQuery = page.queryParams;
407
408 if ((self.location.pathname !== newUrl) || (!isEqual(currentQuery, newQuery))) {
409 if (self.history && self.history.pushState) {
410 let newHref = newUrl;
411 if (!isEmpty(newQuery)) { newHref += createQuery(newQuery); }
412 self.history.pushState({}, '', newHref);
413 } else {
414 self.location = newUrl;
415 }
416 }
417 }
418
419 render() {
420 return false;
421 }
422}
423
424const urlSelector = createSelector(
425 state => state.platform.currentPageIndex,
426 state => state.platform.history,
427 (pageIndex, history) => ({ pageIndex, history })
428);
429
430const urlDispatcher = dispatch => ({
431 gotoPageIndex: (index, url, data) => dispatch(navigationActions.gotoPageIndex(index, url, data)),
432 navigateToPage: (url, queryParams, hashParams) => dispatch(
433 navigationActions.navigateToUrl(METHODS.GET, url, { queryParams, hashParams })
434 ),
435});
436
437export const UrlSync = connect(urlSelector, urlDispatcher)(_UrlSync);