Server side route handling

This commit is contained in:
Justin Shreve 2019-06-13 15:58:21 -04:00 committed by Paul Sealock
parent 123af97b1c
commit aed4ea63b4
9 changed files with 142 additions and 95 deletions

View File

@ -3,14 +3,14 @@
* External dependencies * External dependencies
*/ */
import { Component, createElement } from '@wordpress/element'; import { Component, createElement } from '@wordpress/element';
import { parse } from 'qs'; import { parse, stringify } from 'qs';
import { find, last, isEqual } from 'lodash'; import { isEqual, last } from 'lodash';
import { applyFilters } from '@wordpress/hooks'; import { applyFilters } from '@wordpress/hooks';
/** /**
* WooCommerce dependencies * WooCommerce dependencies
*/ */
import { getNewPath, getPersistedQuery, getHistory, stringifyQuery } from '@woocommerce/navigation'; import { getNewPath, getPersistedQuery, getHistory } from '@woocommerce/navigation';
/** /**
* Internal dependencies * Internal dependencies
@ -104,19 +104,23 @@ export class Controller extends Component {
return query; return query;
} }
// @todo What should we display or do when a route/page doesn't exist?
render404() {
return null;
}
render() { render() {
// Pass URL parameters (example :report -> params.report) and query string parameters const { page, match, location } = this.props;
const { path, url, params } = this.props.match; const { url, params } = match;
const query = this.getQuery( this.props.location.search ); const query = this.getBaseQuery( location.search );
const page = find( getPages(), { path } );
if ( ! page ) { if ( ! page ) {
return null; // @todo What should we display or do when a route/page doesn't exist? return this.render404();
} }
window.wpNavMenuUrlUpdate( page, query ); window.wpNavMenuUrlUpdate( page, query );
window.wpNavMenuClassChange( page ); window.wpNavMenuClassChange( page, url );
return createElement( page.container, { params, path: url, pathMatch: path, query } ); return createElement( page.container, { params, path: url, pathMatch: page.path, query } );
} }
} }
@ -125,38 +129,34 @@ export class Controller extends Component {
* as is. * as is.
* *
* @param {HTMLElement} item - Sidebar anchor link. * @param {HTMLElement} item - Sidebar anchor link.
* @param {string} nextQuery - A query string to be added to updated hrefs. * @param {object} nextQuery - A query object to be added to updated hrefs.
* @param {Array} excludedScreens - wc-admin screens to avoid updating. * @param {Array} excludedScreens - wc-admin screens to avoid updating.
*/ */
export function updateLinkHref( item, nextQuery, excludedScreens ) { export function updateLinkHref( item, nextQuery, excludedScreens ) {
/** const isWCAdmin = /admin.php\?page=wc-admin/.test( item.href );
* Regular expression for finding any WooCommerce Admin screen.
* The groupings are as follows:
*
* 0 - Full match
* 1 - "#/" (optional)
* 2 - "analytics/" (optional)
* 3 - Any string, eg "orders"
* 4 - "?" or end of line
*/
const _exp = /page=wc-admin(#\/)?(analytics\/)?(.*?)(\?|$)/;
const wcAdminMatches = item.href.match( _exp );
if ( wcAdminMatches ) { if ( isWCAdmin ) {
// Get fourth grouping const search = last( item.href.split( '?' ) );
const screen = wcAdminMatches[ 3 ]; const query = parse( search );
const path = query.path || 'dashboard';
const screen = path.replace( '/analytics', '' ).replace( '/', '' );
if ( ! excludedScreens.includes( screen ) ) { const isExcludedScreen = excludedScreens.includes( screen );
const url = item.href.split( 'wc-admin' );
const hashUrl = last( url ); const href =
const base = hashUrl.split( '?' )[ 0 ]; 'admin.php?' + stringify( Object.assign( query, isExcludedScreen ? {} : nextQuery ) );
const href = `${ url[ 0 ] }wc-admin${ '#' === base[ 0 ] ? '' : '#/' }${ base }${ nextQuery }`;
item.href = href; // Replace the href so you can see the url on hover.
} item.href = href;
item.onclick = e => {
e.preventDefault();
getHistory().push( href );
};
} }
} }
// Update links in wp-admin menu to persist time related queries // Update's wc-admin links in wp-admin menu
window.wpNavMenuUrlUpdate = function( page, query ) { window.wpNavMenuUrlUpdate = function( page, query ) {
const excludedScreens = applyFilters( TIME_EXCLUDED_SCREENS_FILTER, [ const excludedScreens = applyFilters( TIME_EXCLUDED_SCREENS_FILTER, [
'devdocs', 'devdocs',
@ -164,7 +164,7 @@ window.wpNavMenuUrlUpdate = function( page, query ) {
'settings', 'settings',
'customers', 'customers',
] ); ] );
const nextQuery = stringifyQuery( getPersistedQuery( query ) ); const nextQuery = getPersistedQuery( query );
Array.from( document.querySelectorAll( '#adminmenu a' ) ).forEach( item => Array.from( document.querySelectorAll( '#adminmenu a' ) ).forEach( item =>
updateLinkHref( item, nextQuery, excludedScreens ) updateLinkHref( item, nextQuery, excludedScreens )
@ -172,7 +172,7 @@ window.wpNavMenuUrlUpdate = function( page, query ) {
}; };
// When the route changes, we need to update wp-admin's menu with the correct section & current link // When the route changes, we need to update wp-admin's menu with the correct section & current link
window.wpNavMenuClassChange = function( page ) { window.wpNavMenuClassChange = function( page, url ) {
Array.from( document.getElementsByClassName( 'current' ) ).forEach( function( item ) { Array.from( document.getElementsByClassName( 'current' ) ).forEach( function( item ) {
item.classList.remove( 'current' ); item.classList.remove( 'current' );
} ); } );
@ -186,11 +186,14 @@ window.wpNavMenuClassChange = function( page ) {
element.classList.add( 'menu-top' ); element.classList.add( 'menu-top' );
} ); } );
const pageHash = window.location.hash.split( '?' )[ 0 ]; const pageUrl =
'/' === url
? 'admin.php?page=wc-admin'
: 'admin.php?page=wc-admin&path=' + encodeURIComponent( url );
const currentItemsSelector = const currentItemsSelector =
pageHash === '#/' url === '/'
? `li > a[href$="${ pageHash }"], li > a[href*="${ pageHash }?"]` ? `li > a[href$="${ pageUrl }"], li > a[href*="${ pageUrl }?"]`
: `li > a[href*="${ pageHash }"]`; : `li > a[href*="${ pageUrl }"]`;
const currentItems = document.querySelectorAll( currentItemsSelector ); const currentItems = document.querySelectorAll( currentItemsSelector );
Array.from( currentItems ).forEach( function( item ) { Array.from( currentItems ).forEach( function( item ) {

View File

@ -102,9 +102,15 @@ class _PageLayout extends Component {
<Router history={ getHistory() }> <Router history={ getHistory() }>
<Switch> <Switch>
{ getPages().map( page => { { getPages().map( page => {
return <Route key={ page.path } path={ page.path } exact component={ Layout } />; return (
<Route
key={ page.path }
path={ page.path }
exact
render={ props => <Layout page={ page } { ...props } /> }
/>
);
} ) } } ) }
<Route component={ Layout } />
</Switch> </Switch>
</Router> </Router>
); );

View File

@ -1,9 +1,4 @@
/** @format */ /** @format */
/**
* WooCommerce dependencies
*/
import { stringifyQuery } from '@woocommerce/navigation';
/** /**
* Internal dependencies * Internal dependencies
*/ */
@ -12,25 +7,25 @@ import { updateLinkHref } from '../controller';
describe( 'updateLinkHref', () => { describe( 'updateLinkHref', () => {
const timeExcludedScreens = [ 'devdocs', 'stock', 'settings', 'customers' ]; const timeExcludedScreens = [ 'devdocs', 'stock', 'settings', 'customers' ];
const REPORT_URL = const REPORT_URL = 'http://example.com/wp-admin/admin.php?page=wc-admin&path=/analytics/orders';
'http://example.com/wp-admin/admin.php?page=wc-admin#/analytics/orders?period=today&compare=previous_year'; const DASHBOARD_URL = 'http://example.com/wp-admin/admin.php?page=wc-admin';
const DASHBOARD_URL = const REPORT_URL_TIME_EXCLUDED =
'http://example.com/wp-admin/admin.php?page=wc-admin#/?period=week&compare=previous_year'; 'http://example.com/wp-admin/admin.php?page=wc-admin&path=/analytics/settings';
const DASHBOARD_URL_NO_HASH = 'http://example.com/wp-admin/admin.php?page=wc-admin';
const WOO_URL = 'http://example.com/wp-admin/edit.php?post_type=shop_coupon'; const WOO_URL = 'http://example.com/wp-admin/edit.php?post_type=shop_coupon';
const WP_ADMIN_URL = 'http://example.com/wp-admin/edit-comments.php'; const WP_ADMIN_URL = 'http://example.com/wp-admin/edit-comments.php';
const nextQuery = stringifyQuery( { const nextQuery = {
fruit: 'apple', fruit: 'apple',
dish: 'cobbler', dish: 'cobbler',
} ); };
it( 'should update report urls', () => { it( 'should update report urls', () => {
const item = { href: REPORT_URL }; const item = { href: REPORT_URL };
updateLinkHref( item, nextQuery, timeExcludedScreens ); updateLinkHref( item, nextQuery, timeExcludedScreens );
const encodedPath = encodeURIComponent( '/analytics/orders' );
expect( item.href ).toBe( expect( item.href ).toBe(
'http://example.com/wp-admin/admin.php?page=wc-admin#/analytics/orders?fruit=apple&dish=cobbler' `admin.php?page=wc-admin&path=${ encodedPath }&fruit=apple&dish=cobbler`
); );
} ); } );
@ -38,18 +33,15 @@ describe( 'updateLinkHref', () => {
const item = { href: DASHBOARD_URL }; const item = { href: DASHBOARD_URL };
updateLinkHref( item, nextQuery, timeExcludedScreens ); updateLinkHref( item, nextQuery, timeExcludedScreens );
expect( item.href ).toBe( expect( item.href ).toBe( 'admin.php?page=wc-admin&fruit=apple&dish=cobbler' );
'http://example.com/wp-admin/admin.php?page=wc-admin#/?fruit=apple&dish=cobbler'
);
} ); } );
it( 'should update dashboard urls with no hash', () => { it( 'should not add the nextQuery to a time excluded screen', () => {
const item = { href: DASHBOARD_URL_NO_HASH }; const item = { href: REPORT_URL_TIME_EXCLUDED };
updateLinkHref( item, nextQuery, timeExcludedScreens ); updateLinkHref( item, nextQuery, timeExcludedScreens );
const encodedPath = encodeURIComponent( '/analytics/settings' );
expect( item.href ).toBe( expect( item.href ).toBe( `admin.php?page=wc-admin&path=${ encodedPath }` );
'http://example.com/wp-admin/admin.php?page=wc-admin#/?fruit=apple&dish=cobbler'
);
} ); } );
it( 'should not update WooCommerce urls', () => { it( 'should not update WooCommerce urls', () => {

View File

@ -37,8 +37,8 @@ function wc_admin_number_format( $number ) {
function wc_admin_url( $path, $query = array() ) { function wc_admin_url( $path, $query = array() ) {
if ( ! empty( $query ) ) { if ( ! empty( $query ) ) {
$query_string = http_build_query( $query ); $query_string = http_build_query( $query );
$path = $path . '?' . $query_string; $path = $path . '&' . $query_string;
} }
return admin_url( 'admin.php?page=wc-admin#' . $path, dirname( __FILE__ ) ); return admin_url( 'admin.php?page=wc-admin&path=' . $path, dirname( __FILE__ ) );
} }

View File

@ -102,22 +102,16 @@ class WC_Admin_Page_Controller {
$current_url = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ); $current_url = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
} }
$current_path = wp_parse_url( $current_url, PHP_URL_PATH ); $current_query = wp_parse_url( $current_url, PHP_URL_QUERY );
$current_query = wp_parse_url( $current_url, PHP_URL_QUERY ); parse_str( $current_query, $current_pieces );
$current_fragment = wp_parse_url( $current_url, PHP_URL_FRAGMENT ); $current_path = empty( $current_pieces['page'] ) ? '' : $current_pieces['page'];
$current_path .= empty( $current_pieces['path'] ) ? '' : '&path=' . $current_pieces['path'];
foreach ( $this->pages as $page ) { foreach ( $this->pages as $page ) {
if ( isset( $page['js_page'] ) && $page['js_page'] ) { if ( isset( $page['js_page'] ) && $page['js_page'] ) {
// Check registered admin pages. // Check registered admin pages.
$full_page_path = add_query_arg( 'page', $page['path'], admin_url( 'admin.php' ) );
$page_path = wp_parse_url( $full_page_path, PHP_URL_PATH );
$page_query = wp_parse_url( $full_page_path, PHP_URL_QUERY );
$page_fragment = wp_parse_url( $full_page_path, PHP_URL_FRAGMENT );
if ( if (
$page_path === $current_path && $page['path'] === $current_path
0 === strpos( $current_query, $page_query ) &&
$page_fragment === $current_fragment
) { ) {
$this->current_page = $page; $this->current_page = $page;
return; return;
@ -408,7 +402,7 @@ class WC_Admin_Page_Controller {
$options = wp_parse_args( $options, $defaults ); $options = wp_parse_args( $options, $defaults );
if ( 0 !== strpos( $options['path'], self::PAGE_ROOT ) ) { if ( 0 !== strpos( $options['path'], self::PAGE_ROOT ) ) {
$options['path'] = self::PAGE_ROOT . '#' . $options['path']; $options['path'] = self::PAGE_ROOT . '&path=' . $options['path'];
} }
if ( is_null( $options['parent'] ) ) { if ( is_null( $options['parent'] ) ) {

View File

@ -3,40 +3,47 @@
* External dependencies * External dependencies
*/ */
import { Component } from '@wordpress/element'; import { Component } from '@wordpress/element';
import { Link as RouterLink } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
/** /**
* WooCommerce dependencies * WooCommerce dependencies
*/ */
import { getAdminLink } from '@woocommerce/navigation'; import { getAdminLink, getHistory } from '@woocommerce/navigation';
/** /**
* Use `Link` to create a link to another resource. It accepts a type to automatically * Use `Link` to create a link to another resource. It accepts a type to automatically
* create wp-admin links, wc-admin links, and external links. * create wp-admin links, wc-admin links, and external links.
*/ */
class Link extends Component { class Link extends Component {
// @todo Investigate further if we can use <Link /> directly.
// With React Router 5+, <RouterLink /> cannot be used outside of the main <Router /> elements,
// which seems to include components imported from @woocommerce/components. For now, we can use the history object directly.
wcAdminLinkHandler( e ) {
e.preventDefault();
getHistory().push( e.target.closest( 'a' ).getAttribute( 'href' ) );
}
render() { render() {
const { children, href, type, ...props } = this.props; const { children, href, type, ...props } = this.props;
if ( this.context.router && 'wc-admin' === type ) {
return (
<RouterLink to={ href } { ...props }>
{ children }
</RouterLink>
);
}
let path; let path;
if ( 'wp-admin' === type ) { if ( 'wp-admin' === type ) {
path = getAdminLink( href ); path = getAdminLink( href );
} else if ( 'external' === type ) {
path = href;
} else { } else {
path = getAdminLink( 'admin.php?page=wc-admin#' + href ); path = href;
}
const passProps = {
...props,
'data-link-type': type,
};
if ( 'wc-admin' === type ) {
passProps.onClick = this.wcAdminLinkHandler;
} }
return ( return (
<a href={ path } { ...props }> <a href={ path } { ...passProps }>
{ children } { children }
</a> </a>
); );

View File

@ -2,15 +2,60 @@
/** /**
* External dependencies * External dependencies
*/ */
import { createHashHistory } from 'history'; import { createBrowserHistory } from 'history';
import { parse } from 'qs';
// See https://github.com/ReactTraining/react-router/blob/master/FAQ.md#how-do-i-access-the-history-object-outside-of-components // See https://github.com/ReactTraining/react-router/blob/master/FAQ.md#how-do-i-access-the-history-object-outside-of-components
let _history; let _history;
/**
* Recreate `history` to coerce React Router into accepting path arguments found in query
* parameter `path`, allowing a url hash to be avoided. Since hash portions of the url are
* not sent server side, full route information can be detected by the server.
*
* `<Router />` and `<Switch />` components use `history.location()` to match a url with a route.
* Since they don't parse query arguments, recreate `get location` to return a `pathname` with the
* query path argument's value.
*
* @returns {object} React-router history object with `get location` modified.
*/
function getHistory() { function getHistory() {
if ( ! _history ) { if ( ! _history ) {
_history = createHashHistory(); const path = document.location.pathname;
const browserHistory = createBrowserHistory( {
basename: path.substring( 0, path.lastIndexOf( '/' ) ),
} );
_history = {
get length() {
return browserHistory.length;
},
get action() {
return browserHistory.action;
},
get location() {
const { location } = browserHistory;
const query = parse( location.search.substring( 1 ) );
const pathname = query.path || '/';
return {
...location,
pathname,
};
},
createHref: ( ...args ) => browserHistory.createHref.apply( browserHistory, args ),
push: ( ...args ) => browserHistory.push.apply( browserHistory, args ),
replace: ( ...args ) => browserHistory.replace.apply( browserHistory, args ),
go: ( ...args ) => browserHistory.go.apply( browserHistory, args ),
goBack: ( ...args ) => browserHistory.goBack.apply( browserHistory, args ),
goForward: ( ...args ) => browserHistory.goForward.apply( browserHistory, args ),
block: ( ...args ) => browserHistory.block.apply( browserHistory, args ),
listen: function( listener ) {
return browserHistory.listen( () => {
listener( this.location, this.action );
} );
},
};
} }
return _history; return _history;
} }

View File

@ -99,8 +99,8 @@ export function getSearchWords( query = navUtils.getQuery() ) {
* @return {String} Updated URL merging query params into existing params. * @return {String} Updated URL merging query params into existing params.
*/ */
export function getNewPath( query, path = getPath(), currentQuery = getQuery() ) { export function getNewPath( query, path = getPath(), currentQuery = getQuery() ) {
const queryString = stringifyQuery( { ...currentQuery, ...query } ); const queryString = stringifyQuery( { page: 'wc-admin', path, ...currentQuery, ...query } );
return `${ path }${ queryString }`; return `admin.php${ queryString }`;
} }
/** /**

View File

@ -141,7 +141,7 @@ class WC_Tests_API_Leaderboards extends WC_REST_Unit_Test_Case {
$widgets_leaderboard = end( $data ); $widgets_leaderboard = end( $data );
$this->assertEquals( 200, $response->get_status() ); $this->assertEquals( 200, $response->get_status() );
$this->assertEquals( 'top_widgets', $widgets_leaderboard['id'] ); $this->assertEquals( 'top_widgets', $widgets_leaderboard['id'] );
$this->assertEquals( admin_url( 'admin.php?page=wc-admin#test/path?persisted_param=1' ), $widgets_leaderboard['rows'][0]['display'] ); $this->assertEquals( admin_url( 'admin.php?page=wc-admin&path=test/path&persisted_param=1' ), $widgets_leaderboard['rows'][0]['display'] );
$request = new WP_REST_Request( 'GET', $this->endpoint . '/allowed' ); $request = new WP_REST_Request( 'GET', $this->endpoint . '/allowed' );
$response = $this->server->dispatch( $request ); $response = $this->server->dispatch( $request );