From aed4ea63b48234588c2f6683f4502d268bad2d6d Mon Sep 17 00:00:00 2001 From: Justin Shreve Date: Thu, 13 Jun 2019 15:58:21 -0400 Subject: [PATCH] Server side route handling --- .../client/layout/controller.js | 83 ++++++++++--------- .../woocommerce-admin/client/layout/index.js | 10 ++- .../client/layout/test/index.js | 34 +++----- .../includes/core-functions.php | 4 +- .../class-wc-admin-page-controller.php | 18 ++-- .../packages/components/src/link/index.js | 33 +++++--- .../packages/navigation/src/history.js | 49 ++++++++++- .../packages/navigation/src/index.js | 4 +- .../tests/api/leaderboards.php | 2 +- 9 files changed, 142 insertions(+), 95 deletions(-) diff --git a/plugins/woocommerce-admin/client/layout/controller.js b/plugins/woocommerce-admin/client/layout/controller.js index b7f15e09e59..5ecef5b46f1 100644 --- a/plugins/woocommerce-admin/client/layout/controller.js +++ b/plugins/woocommerce-admin/client/layout/controller.js @@ -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 ) { diff --git a/plugins/woocommerce-admin/client/layout/index.js b/plugins/woocommerce-admin/client/layout/index.js index c48b34d1614..7a2a0282fd6 100644 --- a/plugins/woocommerce-admin/client/layout/index.js +++ b/plugins/woocommerce-admin/client/layout/index.js @@ -102,9 +102,15 @@ class _PageLayout extends Component { { getPages().map( page => { - return ; + return ( + } + /> + ); } ) } - ); diff --git a/plugins/woocommerce-admin/client/layout/test/index.js b/plugins/woocommerce-admin/client/layout/test/index.js index 2fb3498a054..ac1219e0705 100644 --- a/plugins/woocommerce-admin/client/layout/test/index.js +++ b/plugins/woocommerce-admin/client/layout/test/index.js @@ -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', () => { diff --git a/plugins/woocommerce-admin/includes/core-functions.php b/plugins/woocommerce-admin/includes/core-functions.php index c794bce7880..9e319d9ac0d 100644 --- a/plugins/woocommerce-admin/includes/core-functions.php +++ b/plugins/woocommerce-admin/includes/core-functions.php @@ -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__ ) ); } diff --git a/plugins/woocommerce-admin/includes/page-controller/class-wc-admin-page-controller.php b/plugins/woocommerce-admin/includes/page-controller/class-wc-admin-page-controller.php index 896224a0bb4..539de9d72dc 100644 --- a/plugins/woocommerce-admin/includes/page-controller/class-wc-admin-page-controller.php +++ b/plugins/woocommerce-admin/includes/page-controller/class-wc-admin-page-controller.php @@ -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'] ) ) { diff --git a/plugins/woocommerce-admin/packages/components/src/link/index.js b/plugins/woocommerce-admin/packages/components/src/link/index.js index 463a0dcb861..b3dd56ad5c1 100644 --- a/plugins/woocommerce-admin/packages/components/src/link/index.js +++ b/plugins/woocommerce-admin/packages/components/src/link/index.js @@ -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 directly. + // With React Router 5+, cannot be used outside of the main 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 ( - - { children } - - ); - } 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 ( - + { children } ); diff --git a/plugins/woocommerce-admin/packages/navigation/src/history.js b/plugins/woocommerce-admin/packages/navigation/src/history.js index ef6754820e4..fa6871ab572 100644 --- a/plugins/woocommerce-admin/packages/navigation/src/history.js +++ b/plugins/woocommerce-admin/packages/navigation/src/history.js @@ -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. + * + * `` and `` 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; } diff --git a/plugins/woocommerce-admin/packages/navigation/src/index.js b/plugins/woocommerce-admin/packages/navigation/src/index.js index 19202a8a4c4..4c836e5b8ca 100644 --- a/plugins/woocommerce-admin/packages/navigation/src/index.js +++ b/plugins/woocommerce-admin/packages/navigation/src/index.js @@ -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 }`; } /** diff --git a/plugins/woocommerce-admin/tests/api/leaderboards.php b/plugins/woocommerce-admin/tests/api/leaderboards.php index e2e0a0ac696..1f0b8fd1dc0 100644 --- a/plugins/woocommerce-admin/tests/api/leaderboards.php +++ b/plugins/woocommerce-admin/tests/api/leaderboards.php @@ -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 );