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
*/
import { Component, createElement } from '@wordpress/element';
import { parse } from 'qs';
import { find, last, isEqual } from 'lodash';
import { parse, stringify } from 'qs';
import { isEqual, last } from 'lodash';
import { applyFilters } from '@wordpress/hooks';
/**
* WooCommerce dependencies
*/
import { getNewPath, getPersistedQuery, getHistory, stringifyQuery } from '@woocommerce/navigation';
import { getNewPath, getPersistedQuery, getHistory } from '@woocommerce/navigation';
/**
* Internal dependencies
@ -104,19 +104,23 @@ export class Controller extends Component {
return query;
}
// @todo What should we display or do when a route/page doesn't exist?
render404() {
return null;
}
render() {
// Pass URL parameters (example :report -> params.report) and query string parameters
const { path, url, params } = this.props.match;
const query = this.getQuery( this.props.location.search );
const page = find( getPages(), { path } );
const { page, match, location } = this.props;
const { url, params } = match;
const query = this.getBaseQuery( location.search );
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.wpNavMenuClassChange( page );
return createElement( page.container, { params, path: url, pathMatch: path, query } );
window.wpNavMenuClassChange( page, url );
return createElement( page.container, { params, path: url, pathMatch: page.path, query } );
}
}
@ -125,38 +129,34 @@ export class Controller extends Component {
* as is.
*
* @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.
*/
export function updateLinkHref( item, nextQuery, excludedScreens ) {
/**
* 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 );
const isWCAdmin = /admin.php\?page=wc-admin/.test( item.href );
if ( wcAdminMatches ) {
// Get fourth grouping
const screen = wcAdminMatches[ 3 ];
if ( isWCAdmin ) {
const search = last( item.href.split( '?' ) );
const query = parse( search );
const path = query.path || 'dashboard';
const screen = path.replace( '/analytics', '' ).replace( '/', '' );
if ( ! excludedScreens.includes( screen ) ) {
const url = item.href.split( 'wc-admin' );
const hashUrl = last( url );
const base = hashUrl.split( '?' )[ 0 ];
const href = `${ url[ 0 ] }wc-admin${ '#' === base[ 0 ] ? '' : '#/' }${ base }${ nextQuery }`;
item.href = href;
}
const isExcludedScreen = excludedScreens.includes( screen );
const href =
'admin.php?' + stringify( Object.assign( query, isExcludedScreen ? {} : nextQuery ) );
// 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 ) {
const excludedScreens = applyFilters( TIME_EXCLUDED_SCREENS_FILTER, [
'devdocs',
@ -164,7 +164,7 @@ window.wpNavMenuUrlUpdate = function( page, query ) {
'settings',
'customers',
] );
const nextQuery = stringifyQuery( getPersistedQuery( query ) );
const nextQuery = getPersistedQuery( query );
Array.from( document.querySelectorAll( '#adminmenu a' ) ).forEach( item =>
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
window.wpNavMenuClassChange = function( page ) {
window.wpNavMenuClassChange = function( page, url ) {
Array.from( document.getElementsByClassName( 'current' ) ).forEach( function( item ) {
item.classList.remove( 'current' );
} );
@ -186,11 +186,14 @@ window.wpNavMenuClassChange = function( page ) {
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 =
pageHash === '#/'
? `li > a[href$="${ pageHash }"], li > a[href*="${ pageHash }?"]`
: `li > a[href*="${ pageHash }"]`;
url === '/'
? `li > a[href$="${ pageUrl }"], li > a[href*="${ pageUrl }?"]`
: `li > a[href*="${ pageUrl }"]`;
const currentItems = document.querySelectorAll( currentItemsSelector );
Array.from( currentItems ).forEach( function( item ) {

View File

@ -102,9 +102,15 @@ class _PageLayout extends Component {
<Router history={ getHistory() }>
<Switch>
{ 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>
</Router>
);

View File

@ -1,9 +1,4 @@
/** @format */
/**
* WooCommerce dependencies
*/
import { stringifyQuery } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
@ -12,25 +7,25 @@ import { updateLinkHref } from '../controller';
describe( 'updateLinkHref', () => {
const timeExcludedScreens = [ 'devdocs', 'stock', 'settings', 'customers' ];
const REPORT_URL =
'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#/?period=week&compare=previous_year';
const DASHBOARD_URL_NO_HASH = 'http://example.com/wp-admin/admin.php?page=wc-admin';
const REPORT_URL = 'http://example.com/wp-admin/admin.php?page=wc-admin&path=/analytics/orders';
const DASHBOARD_URL = 'http://example.com/wp-admin/admin.php?page=wc-admin';
const REPORT_URL_TIME_EXCLUDED =
'http://example.com/wp-admin/admin.php?page=wc-admin&path=/analytics/settings';
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 nextQuery = stringifyQuery( {
const nextQuery = {
fruit: 'apple',
dish: 'cobbler',
} );
};
it( 'should update report urls', () => {
const item = { href: REPORT_URL };
updateLinkHref( item, nextQuery, timeExcludedScreens );
const encodedPath = encodeURIComponent( '/analytics/orders' );
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 };
updateLinkHref( item, nextQuery, timeExcludedScreens );
expect( item.href ).toBe(
'http://example.com/wp-admin/admin.php?page=wc-admin#/?fruit=apple&dish=cobbler'
);
expect( item.href ).toBe( 'admin.php?page=wc-admin&fruit=apple&dish=cobbler' );
} );
it( 'should update dashboard urls with no hash', () => {
const item = { href: DASHBOARD_URL_NO_HASH };
it( 'should not add the nextQuery to a time excluded screen', () => {
const item = { href: REPORT_URL_TIME_EXCLUDED };
updateLinkHref( item, nextQuery, timeExcludedScreens );
const encodedPath = encodeURIComponent( '/analytics/settings' );
expect( item.href ).toBe(
'http://example.com/wp-admin/admin.php?page=wc-admin#/?fruit=apple&dish=cobbler'
);
expect( item.href ).toBe( `admin.php?page=wc-admin&path=${ encodedPath }` );
} );
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() ) {
if ( ! empty( $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_path = wp_parse_url( $current_url, PHP_URL_PATH );
$current_query = wp_parse_url( $current_url, PHP_URL_QUERY );
$current_fragment = wp_parse_url( $current_url, PHP_URL_FRAGMENT );
$current_query = wp_parse_url( $current_url, PHP_URL_QUERY );
parse_str( $current_query, $current_pieces );
$current_path = empty( $current_pieces['page'] ) ? '' : $current_pieces['page'];
$current_path .= empty( $current_pieces['path'] ) ? '' : '&path=' . $current_pieces['path'];
foreach ( $this->pages as $page ) {
if ( isset( $page['js_page'] ) && $page['js_page'] ) {
// 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 (
$page_path === $current_path &&
0 === strpos( $current_query, $page_query ) &&
$page_fragment === $current_fragment
$page['path'] === $current_path
) {
$this->current_page = $page;
return;
@ -408,7 +402,7 @@ class WC_Admin_Page_Controller {
$options = wp_parse_args( $options, $defaults );
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'] ) ) {

View File

@ -3,40 +3,47 @@
* External dependencies
*/
import { Component } from '@wordpress/element';
import { Link as RouterLink } from 'react-router-dom';
import PropTypes from 'prop-types';
/**
* 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
* create wp-admin links, wc-admin links, and external links.
*/
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() {
const { children, href, type, ...props } = this.props;
if ( this.context.router && 'wc-admin' === type ) {
return (
<RouterLink to={ href } { ...props }>
{ children }
</RouterLink>
);
}
let path;
if ( 'wp-admin' === type ) {
path = getAdminLink( href );
} else if ( 'external' === type ) {
path = href;
} 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 (
<a href={ path } { ...props }>
<a href={ path } { ...passProps }>
{ children }
</a>
);

View File

@ -2,15 +2,60 @@
/**
* 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
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() {
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;
}

View File

@ -99,8 +99,8 @@ export function getSearchWords( query = navUtils.getQuery() ) {
* @return {String} Updated URL merging query params into existing params.
*/
export function getNewPath( query, path = getPath(), currentQuery = getQuery() ) {
const queryString = stringifyQuery( { ...currentQuery, ...query } );
return `${ path }${ queryString }`;
const queryString = stringifyQuery( { page: 'wc-admin', path, ...currentQuery, ...query } );
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 );
$this->assertEquals( 200, $response->get_status() );
$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' );
$response = $this->server->dispatch( $request );