From a8757648a133725bea61244cb4c674fed9597692 Mon Sep 17 00:00:00 2001 From: Joshua T Flowers Date: Mon, 22 Feb 2021 13:54:27 -0500 Subject: [PATCH] Refactor menu item mapping and sorting (https://github.com/woocommerce/woocommerce-admin/pull/6382) * Separate primary and secondary menu components * Simplify category mapping * Map categories and items simultaneously to improve performance * Sort added menu items * Pre-sort menu items * Create single mapped sorting on server-side * Fix incorrect menu ID references * Update tests * Move methods to utils file * Add in permissions check in client nav * Fix server-side capability check * Fix duplicate expectation and test name * Add testing instructions --- .../woocommerce-admin/TESTING-INSTRUCTIONS.md | 10 + .../navigation/components/container/index.js | 231 +++-------------- .../components/container/primary-menu.js | 67 +++++ .../components/container/secondary-menu.js | 39 +++ .../components/container/test/index.js | 204 --------------- .../client/navigation/test/utils.js | 245 ++++++++++++++++++ .../client/navigation/utils.js | 87 +++++++ .../src/Features/Navigation/CoreMenu.php | 89 ++++--- .../src/Features/Navigation/Menu.php | 63 +++-- 9 files changed, 572 insertions(+), 463 deletions(-) create mode 100644 plugins/woocommerce-admin/client/navigation/components/container/primary-menu.js create mode 100644 plugins/woocommerce-admin/client/navigation/components/container/secondary-menu.js delete mode 100644 plugins/woocommerce-admin/client/navigation/components/container/test/index.js diff --git a/plugins/woocommerce-admin/TESTING-INSTRUCTIONS.md b/plugins/woocommerce-admin/TESTING-INSTRUCTIONS.md index 92ea43ca0e9..9b4a67afe9d 100644 --- a/plugins/woocommerce-admin/TESTING-INSTRUCTIONS.md +++ b/plugins/woocommerce-admin/TESTING-INSTRUCTIONS.md @@ -43,6 +43,16 @@ wp db query 'SELECT status FROM wp_wc_admin_notes WHERE name = "wc-admin-add-fir - Run the cron again. - The note's status should continue being `unactioned`. +### Refactor menu item mapping and sorting #6382 + +1. Enable the new navigation under WooCommerce -> Settings -> Advanced -> Features. +2. Navigate to a WooCommerce page. +3. Make sure all items and categories continue to work as expected. +4. Activate multiple extensions that register WooCommerce extension categories. (e.g., WooCommerce Bookings and WooCommerce Payments). +5. Favorite and unfavorite menu items. +6. Make sure the menu item order is correct after unfavoriting. +7. Create a user with permissions to see some but not all registered WooCommerce pages. +8. Check that a user without permission to access a menu item cannot see said menu item. ### Remove CES actions for adding and editing a product and editing an order #6355 1. Add a product. The customer effort score survey should not appear. diff --git a/plugins/woocommerce-admin/client/navigation/components/container/index.js b/plugins/woocommerce-admin/client/navigation/components/container/index.js index af929c59ec3..47d4c12f48e 100644 --- a/plugins/woocommerce-admin/client/navigation/components/container/index.js +++ b/plugins/woocommerce-admin/client/navigation/components/container/index.js @@ -1,90 +1,25 @@ /** * External dependencies */ -import { __ } from '@wordpress/i18n'; import { useEffect, useMemo, useState, useRef } from '@wordpress/element'; import classnames from 'classnames'; import { compose } from '@wordpress/compose'; -import { - Navigation, - NavigationMenu, - NavigationGroup, -} from '@woocommerce/experimental'; -import { NAVIGATION_STORE_NAME } from '@woocommerce/data'; +import { Navigation } from '@woocommerce/experimental'; +import { NAVIGATION_STORE_NAME, useUser } from '@woocommerce/data'; import { recordEvent } from '@woocommerce/tracks'; import { withSelect } from '@wordpress/data'; /** * Internal dependencies */ -import { addHistoryListener, getMatchingItem } from '../../utils'; -import CategoryTitle from '../category-title'; +import { + addHistoryListener, + getMappedItemsCategories, + getMatchingItem, +} from '../../utils'; import Header from '../header'; -import Item from '../../components/Item'; - -/** - * Get a map of all categories, including the topmost WooCommerce parentCategory - * - * @param {Array} menuItems Array of menuItems - * @return {Object} Map of categories by id - */ -export const getCategoriesMap = ( menuItems ) => { - return menuItems.reduce( - ( acc, item ) => { - if ( item.isCategory ) { - return { ...acc, [ item.id ]: item }; - } - return acc; - }, - { - woocommerce: { - capability: 'manage_woocommerce', - id: 'woocommerce', - isCategory: true, - menuId: 'primary', - migrate: true, - order: 10, - parent: '', - title: 'WooCommerce', - }, - } - ); -}; - -/** - * Get a flat tree structure of all Categories and thier children grouped by menuId - * - * @param {Object} categoriesMap Map of categories by id - * @param {Array} menuItems Array of menuItems - * @return {Object} a;dslkfj - */ -export const getMenuItemsByCategory = ( categoriesMap, menuItems ) => { - return menuItems.reduce( ( acc, item ) => { - // Set up the category if it doesn't yet exist. - if ( ! acc[ item.parent ] ) { - acc[ item.parent ] = {}; - } - - // Check if parent category is in the same menu. - if ( - item.parent !== 'woocommerce' && - categoriesMap[ item.parent ] && - categoriesMap[ item.parent ].menuId !== item.menuId && - // Allow favorites to exist under any menu. - categoriesMap[ item.parent ].menuId !== 'favorites' - ) { - return acc; - } - - // Create the menu object if it doesn't exist in this category. - if ( ! acc[ item.parent ][ item.menuId ] ) { - acc[ item.parent ][ item.menuId ] = []; - } - - acc[ item.parent ][ item.menuId ].push( item ); - return acc; - }, {} ); -}; +import { PrimaryMenu } from './primary-menu'; +import { SecondaryMenu } from './secondary-menu'; const Container = ( { menuItems } ) => { useEffect( () => { @@ -100,11 +35,6 @@ const Container = ( { menuItems } ) => { adminMenu.classList.add( 'folded' ); }, [] ); - const { rootBackLabel, rootBackUrl } = window.wcNavigation; - - const categoriesMap = getCategoriesMap( menuItems ); - const categories = Object.values( categoriesMap ); - const [ activeItem, setActiveItem ] = useState( 'woocommerce-home' ); const [ activeLevel, setActiveLevel ] = useState( 'woocommerce' ); @@ -127,21 +57,22 @@ const Container = ( { menuItems } ) => { return removeListener; }, [ menuItems ] ); - const categorizedItems = useMemo( - () => getMenuItemsByCategory( categoriesMap, menuItems ), - [ categoriesMap, menuItems ] + const { currentUserCan } = useUser(); + + const { categories, items } = useMemo( + () => getMappedItemsCategories( menuItems, currentUserCan ), + [ menuItems, currentUserCan ] ); const navDomRef = useRef( null ); - const trackBackClick = ( id ) => { + const onBackClick = ( id ) => { recordEvent( 'navigation_back_click', { category: id, } ); }; const isRoot = activeLevel === 'woocommerce'; - const isRootBackVisible = isRoot && rootBackUrl; const classes = classnames( 'woocommerce-navigation', { 'is-root': isRoot, @@ -162,121 +93,29 @@ const Container = ( { menuItems } ) => { setActiveLevel( ...args ); } } > - { categories.map( ( category ) => { - const { - primary: primaryItems, - favorites: favoriteItems, - secondary: secondaryItems, - plugins: pluginItems, - } = categorizedItems[ category.id ] || {}; + { Object.values( categories ).map( ( category ) => { + const categoryItems = items[ category.id ]; - const primaryAndFavoriteItems = [ - ...( primaryItems || [] ), - ...( favoriteItems || [] ), - ]; - - return [ - ( !! primaryAndFavoriteItems.length || - !! pluginItems ) && ( - - } - menu={ category.id } - parentMenu={ category.parent } - backButtonLabel={ - isRootBackVisible - ? rootBackLabel - : category.backButtonLabel || null - } - onBackButtonClick={ - isRootBackVisible - ? () => { - trackBackClick( - 'woocommerce' - ); - window.location = rootBackUrl; - } - : () => - trackBackClick( - category.id - ) - } - > - { !! primaryAndFavoriteItems.length && ( - - { primaryAndFavoriteItems.map( - ( item ) => ( - - ) - ) } - - ) } - { !! pluginItems && ( - - { pluginItems.map( ( item ) => ( - - ) ) } - - ) } - - ), - !! secondaryItems && ( - , + - ) - } - menu={ category.id } - parentMenu={ category.parent } - backButtonLabel={ - category.backButtonLabel || null - } - onBackButtonClick={ - isRootBackVisible - ? null - : () => - trackBackClick( - category.id - ) - } - > - - trackBackClick( category.id ) - } - > - { secondaryItems.map( ( item ) => ( - - ) ) } - - - ), - ]; + category={ category } + onBackClick={ onBackClick } + items={ categoryItems.secondary } + />, + ] + ); } ) } diff --git a/plugins/woocommerce-admin/client/navigation/components/container/primary-menu.js b/plugins/woocommerce-admin/client/navigation/components/container/primary-menu.js new file mode 100644 index 00000000000..e21ac3c9e73 --- /dev/null +++ b/plugins/woocommerce-admin/client/navigation/components/container/primary-menu.js @@ -0,0 +1,67 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { NavigationMenu, NavigationGroup } from '@woocommerce/experimental'; + +/** + * Internal dependencies + */ +import CategoryTitle from '../category-title'; +import Item from '../../components/Item'; + +export const PrimaryMenu = ( { + category, + onBackClick, + pluginItems, + primaryItems, +} ) => { + if ( ! primaryItems.length && ! pluginItems.length ) { + return null; + } + + const { rootBackLabel, rootBackUrl } = window.wcNavigation; + const isRootBackVisible = category.id === 'woocommerce' && rootBackUrl; + + return ( + } + menu={ category.id } + parentMenu={ category.parent } + backButtonLabel={ + isRootBackVisible + ? rootBackLabel + : category.backButtonLabel || null + } + onBackButtonClick={ + isRootBackVisible + ? () => { + onBackClick( 'woocommerce' ); + window.location = rootBackUrl; + } + : () => onBackClick( category.id ) + } + > + { !! primaryItems.length && ( + + { primaryItems.map( ( item ) => ( + + ) ) } + + ) } + { !! pluginItems.length && ( + + { pluginItems.map( ( item ) => ( + + ) ) } + + ) } + + ); +}; diff --git a/plugins/woocommerce-admin/client/navigation/components/container/secondary-menu.js b/plugins/woocommerce-admin/client/navigation/components/container/secondary-menu.js new file mode 100644 index 00000000000..aa159e373f8 --- /dev/null +++ b/plugins/woocommerce-admin/client/navigation/components/container/secondary-menu.js @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +import { NavigationMenu, NavigationGroup } from '@woocommerce/experimental'; + +/** + * Internal dependencies + */ +import CategoryTitle from '../category-title'; +import Item from '../../components/Item'; + +export const SecondaryMenu = ( { category, items, onBackClick } ) => { + if ( ! items.length ) { + return null; + } + + const isRoot = category.id === 'woocommerce'; + + return ( + } + menu={ category.id } + parentMenu={ category.parent } + backButtonLabel={ category.backButtonLabel || null } + onBackButtonClick={ + isRoot ? null : () => onBackClick( category.id ) + } + > + onBackClick( category.id ) } + > + { items.map( ( item ) => ( + + ) ) } + + + ); +}; diff --git a/plugins/woocommerce-admin/client/navigation/components/container/test/index.js b/plugins/woocommerce-admin/client/navigation/components/container/test/index.js deleted file mode 100644 index 12a0bf23ab0..00000000000 --- a/plugins/woocommerce-admin/client/navigation/components/container/test/index.js +++ /dev/null @@ -1,204 +0,0 @@ -/** - * Internal dependencies - */ -import { getCategoriesMap, getMenuItemsByCategory } from '../'; - -describe( 'getCategoriesMap', () => { - const menuItems = [ - { id: 'zero', title: 'zero', isCategory: true }, - { id: 'one', title: 'one', isCategory: true }, - { id: 'two', title: 'two', isCategory: true }, - { id: 'three', title: 'three', isCategory: false }, - { id: 'four', title: 'four', isCategory: false }, - ]; - - it( 'should get a map of all categories', () => { - const categoriesMap = getCategoriesMap( menuItems ); - - expect( categoriesMap.zero ).toMatchObject( menuItems[ 0 ] ); - expect( categoriesMap.one ).toMatchObject( menuItems[ 1 ] ); - expect( categoriesMap.two ).toMatchObject( menuItems[ 2 ] ); - expect( categoriesMap.three ).toBeUndefined(); - expect( categoriesMap.four ).toBeUndefined(); - } ); - - it( 'should include the topmost WooCommerce parent category', () => { - const categoriesMap = getCategoriesMap( menuItems ); - - expect( categoriesMap.woocommerce ).toBeDefined(); - } ); - - it( 'should have the correct number of values', () => { - const categoriesMap = getCategoriesMap( menuItems ); - - expect( Object.keys( categoriesMap ).length ).toBe( 4 ); - } ); -} ); - -describe( 'getMenuItemsByCategory', () => { - it( 'should get a map of all categories and child elements', () => { - const menuItems = [ - { - id: 'child-one', - title: 'child-one', - isCategory: false, - parent: 'parent', - menuId: 'plugins', - }, - { - id: 'child-two', - title: 'child-two', - isCategory: false, - parent: 'parent', - menuId: 'plugins', - }, - { - id: 'parent', - title: 'parent', - isCategory: true, - parent: 'woocommerce', - menuId: 'plugins', - }, - ]; - const categoriesMap = getCategoriesMap( menuItems ); - const categorizedItems = getMenuItemsByCategory( - categoriesMap, - menuItems - ); - - expect( categorizedItems.woocommerce ).toBeDefined(); - expect( categorizedItems.woocommerce.plugins ).toBeDefined(); - expect( categorizedItems.woocommerce.plugins.length ).toBe( 1 ); - - expect( categorizedItems.parent ).toBeDefined(); - expect( categorizedItems.parent.plugins ).toBeDefined(); - expect( categorizedItems.parent.plugins.length ).toBe( 2 ); - } ); - - it( 'should handle multiple depths', () => { - const menuItems = [ - { - id: 'grand-child', - title: 'grand-child', - isCategory: false, - parent: 'child', - menuId: 'plugins', - }, - { - id: 'child', - title: 'child', - isCategory: true, - parent: 'grand-parent', - menuId: 'plugins', - }, - { - id: 'grand-parent', - title: 'grand-parent', - isCategory: true, - parent: 'woocommerce', - menuId: 'plugins', - }, - ]; - const categoriesMap = getCategoriesMap( menuItems ); - const categorizedItems = getMenuItemsByCategory( - categoriesMap, - menuItems - ); - - expect( categorizedItems[ 'grand-parent' ] ).toBeDefined(); - expect( categorizedItems[ 'grand-parent' ] ).toBeDefined(); - expect( categorizedItems[ 'grand-parent' ].plugins.length ).toBe( 1 ); - - expect( categorizedItems.child ).toBeDefined(); - expect( categorizedItems.child ).toBeDefined(); - expect( categorizedItems.child.plugins.length ).toBe( 1 ); - - expect( categorizedItems[ 'grand-child' ] ).not.toBeDefined(); - } ); - - it( 'should group by menuId', () => { - const menuItems = [ - { - id: 'parent', - title: 'parent', - isCategory: true, - parent: 'woocommerce', - menuId: 'primary', - }, - { - id: 'primary-one', - title: 'primary-one', - isCategory: false, - parent: 'parent', - menuId: 'primary', - }, - { - id: 'primary-two', - title: 'primary-two', - isCategory: false, - parent: 'parent', - menuId: 'primary', - }, - ]; - const categoriesMap = getCategoriesMap( menuItems ); - const categorizedItems = getMenuItemsByCategory( - categoriesMap, - menuItems - ); - - expect( categorizedItems.parent ).toBeDefined(); - expect( categorizedItems.parent.primary ).toBeDefined(); - expect( categorizedItems.parent.primary.length ).toBe( 2 ); - } ); - - it( 'should group children only if their menuId matches parent', () => { - const menuItems = [ - { - id: 'plugin-one', - title: 'plugin-one', - isCategory: false, - parent: 'parent', - menuId: 'plugins', - }, - { - id: 'plugin-two', - title: 'plugin-two', - isCategory: false, - parent: 'parent', - menuId: 'plugins', - }, - { - id: 'parent', - title: 'parent', - isCategory: true, - parent: 'woocommerce', - menuId: 'plugins', - }, - { - id: 'primary-one', - title: 'primary-one', - isCategory: false, - parent: 'parent', - menuId: 'primary', - }, - { - id: 'primary-two', - title: 'primary-two', - isCategory: false, - parent: 'parent', - menuId: 'primary', - }, - ]; - const categoriesMap = getCategoriesMap( menuItems ); - const categorizedItems = getMenuItemsByCategory( - categoriesMap, - menuItems - ); - - expect( categorizedItems.parent ).toBeDefined(); - expect( categorizedItems.parent.plugins ).toBeDefined(); - expect( categorizedItems.parent.plugins.length ).toBe( 2 ); - - expect( categorizedItems.primary ).not.toBeDefined(); - } ); -} ); diff --git a/plugins/woocommerce-admin/client/navigation/test/utils.js b/plugins/woocommerce-admin/client/navigation/test/utils.js index 91562a5f041..e7e53926188 100644 --- a/plugins/woocommerce-admin/client/navigation/test/utils.js +++ b/plugins/woocommerce-admin/client/navigation/test/utils.js @@ -10,8 +10,10 @@ import { addHistoryListener, getDefaultMatchExpression, getFullUrl, + getMappedItemsCategories, getMatchingItem, getMatchScore, + sortMenuItems, } from '../utils'; const originalLocation = window.location; @@ -309,3 +311,246 @@ describe( 'addHistoryListener', () => { expect( mockCallback.mock.calls.length ).toBe( 2 ); } ); } ); + +describe( 'sortMenuItems', () => { + it( 'should return an array of items sorted by the order property', () => { + const menuItems = [ + { id: 'second', title: 'second', order: 2 }, + { id: 'first', title: 'three', order: 1 }, + { id: 'third', title: 'four', order: 3 }, + ]; + + const sortedItems = sortMenuItems( menuItems ); + + expect( sortedItems[ 0 ].id ).toBe( 'first' ); + expect( sortedItems[ 1 ].id ).toBe( 'second' ); + expect( sortedItems[ 2 ].id ).toBe( 'third' ); + } ); + + it( 'should sort items alphabetically if order is the same', () => { + const menuItems = [ + { id: 'third', title: 'z', order: 2 }, + { id: 'first', title: 'first', order: 1 }, + { id: 'second', title: 'a', order: 2 }, + ]; + + const sortedItems = sortMenuItems( menuItems ); + + expect( sortedItems[ 0 ].id ).toBe( 'first' ); + expect( sortedItems[ 1 ].id ).toBe( 'second' ); + expect( sortedItems[ 2 ].id ).toBe( 'third' ); + } ); +} ); + +describe( 'getMappedItemsCategories', () => { + it( 'should get the default category when none are provided', () => { + const menuItems = [ + { + id: 'child-one', + title: 'child-one', + isCategory: false, + parent: 'woocommerce', + menuId: 'plugins', + }, + ]; + const { categories, items } = getMappedItemsCategories( menuItems ); + + expect( items.woocommerce ).toBeDefined(); + expect( items.woocommerce.plugins ).toBeDefined(); + expect( items.woocommerce.plugins.length ).toBe( 1 ); + + expect( Object.keys( categories ).length ).toBe( 1 ); + expect( categories.woocommerce ).toBeDefined(); + } ); + + it( 'should get a map of all items and categories', () => { + const menuItems = [ + { + id: 'child-one', + title: 'child-one', + isCategory: false, + parent: 'parent', + menuId: 'plugins', + }, + { + id: 'child-two', + title: 'child-two', + isCategory: false, + parent: 'parent', + menuId: 'plugins', + }, + { + id: 'parent', + title: 'parent', + isCategory: true, + parent: 'woocommerce', + menuId: 'plugins', + }, + ]; + const { categories, items } = getMappedItemsCategories( menuItems ); + + expect( items.woocommerce ).toBeDefined(); + expect( items.woocommerce.plugins ).toBeDefined(); + expect( items.woocommerce.plugins.length ).toBe( 1 ); + + expect( items.parent ).toBeDefined(); + expect( items.parent.plugins ).toBeDefined(); + expect( items.parent.plugins.length ).toBe( 2 ); + + expect( Object.keys( categories ).length ).toBe( 2 ); + expect( categories.parent ).toBeDefined(); + expect( categories.woocommerce ).toBeDefined(); + } ); + + it( 'should handle multiple depths', () => { + const menuItems = [ + { + id: 'grand-child', + title: 'grand-child', + isCategory: false, + parent: 'child', + menuId: 'plugins', + }, + { + id: 'child', + title: 'child', + isCategory: true, + parent: 'grand-parent', + menuId: 'plugins', + }, + { + id: 'grand-parent', + title: 'grand-parent', + isCategory: true, + parent: 'woocommerce', + menuId: 'plugins', + }, + ]; + const { categories, items } = getMappedItemsCategories( menuItems ); + + expect( items[ 'grand-parent' ] ).toBeDefined(); + expect( items[ 'grand-parent' ] ).toBeDefined(); + expect( items[ 'grand-parent' ].plugins.length ).toBe( 1 ); + + expect( items.child ).toBeDefined(); + expect( items.child.plugins.length ).toBe( 1 ); + + expect( items[ 'grand-child' ] ).not.toBeDefined(); + + expect( Object.keys( categories ).length ).toBe( 3 ); + } ); + + it( 'should group by menuId', () => { + const menuItems = [ + { + id: 'parent', + title: 'parent', + isCategory: true, + parent: 'woocommerce', + menuId: 'primary', + }, + { + id: 'primary-one', + title: 'primary-one', + isCategory: false, + parent: 'parent', + menuId: 'primary', + }, + { + id: 'primary-two', + title: 'primary-two', + isCategory: false, + parent: 'parent', + menuId: 'primary', + }, + ]; + const { items } = getMappedItemsCategories( menuItems ); + + expect( items.parent ).toBeDefined(); + expect( items.parent.primary ).toBeDefined(); + expect( items.parent.primary.length ).toBe( 2 ); + } ); + + it( 'should group children only if their menuId matches parent', () => { + const menuItems = [ + { + id: 'plugin-one', + title: 'plugin-one', + isCategory: false, + parent: 'parent', + menuId: 'plugins', + }, + { + id: 'plugin-two', + title: 'plugin-two', + isCategory: false, + parent: 'parent', + menuId: 'plugins', + }, + { + id: 'parent', + title: 'parent', + isCategory: true, + parent: 'woocommerce', + menuId: 'plugins', + }, + { + id: 'primary-one', + title: 'primary-one', + isCategory: false, + parent: 'parent', + menuId: 'primary', + }, + { + id: 'primary-two', + title: 'primary-two', + isCategory: false, + parent: 'parent', + menuId: 'primary', + }, + ]; + const { items } = getMappedItemsCategories( menuItems ); + + expect( items.parent ).toBeDefined(); + expect( items.parent.plugins ).toBeDefined(); + expect( items.parent.plugins.length ).toBe( 2 ); + + expect( items.primary ).not.toBeDefined(); + } ); + + it( 'should ignore bad menu IDs', () => { + const menuItems = [ + { + id: 'parent', + title: 'parent', + isCategory: false, + parent: 'woocommerce', + menuId: 'badId', + }, + { + id: 'primary-one', + title: 'primary-one', + isCategory: false, + parent: 'woocommerce', + menuId: 'primary', + }, + { + id: 'primary-two', + title: 'primary-two', + isCategory: false, + parent: 'woocommerce', + menuId: 'primary', + }, + ]; + const { categories, items } = getMappedItemsCategories( menuItems ); + + expect( items.woocommerce ).toBeDefined(); + expect( items.woocommerce.primary ).toBeDefined(); + expect( items.woocommerce.primary.length ).toBe( 2 ); + + expect( items.woocommerce ).toBeDefined(); + expect( items.woocommerce.badId ).not.toBeDefined(); + + expect( Object.keys( categories ).length ).toBe( 1 ); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/navigation/utils.js b/plugins/woocommerce-admin/client/navigation/utils.js index 998d2eaf64d..8ebe3280bd7 100644 --- a/plugins/woocommerce-admin/client/navigation/utils.js +++ b/plugins/woocommerce-admin/client/navigation/utils.js @@ -132,3 +132,90 @@ export const getMatchingItem = ( items ) => { return matchedItem || null; }; + +/** + * Available menu IDs. + */ +export const menuIds = [ 'primary', 'favorites', 'plugins', 'secondary' ]; + +/** + * Default categories for the menu. + */ +export const defaultCategories = { + woocommerce: { + id: 'woocommerce', + isCategory: true, + menuId: 'primary', + migrate: true, + order: 10, + parent: '', + title: 'WooCommerce', + }, +}; + +/** + * Sort an array of menu items by their order property. + * + * @param {Array} menuItems Array of menu items. + * @return {Array} Sorted menu items. + */ +export const sortMenuItems = ( menuItems ) => { + return menuItems.sort( ( a, b ) => { + if ( a.order === b.order ) { + return a.title.localeCompare( b.title ); + } + + return a.order - b.order; + } ); +}; + +/** + * Get a flat tree structure of all Categories and thier children grouped by menuId + * + * @param {Array} menuItems Array of menu items. + * @param {Function} currentUserCan Callback method passed the capability to determine if a menu item is visible. + * @return {Object} Mapped menu items and categories. + */ +export const getMappedItemsCategories = ( + menuItems, + currentUserCan = null +) => { + const categories = { ...defaultCategories }; + + const items = sortMenuItems( menuItems ).reduce( ( acc, item ) => { + // Set up the category if it doesn't yet exist. + if ( ! acc[ item.parent ] ) { + acc[ item.parent ] = {}; + menuIds.forEach( ( menuId ) => { + acc[ item.parent ][ menuId ] = []; + } ); + } + + // Incorrect menu ID. + if ( ! acc[ item.parent ][ item.menuId ] ) { + return acc; + } + + // User does not have permission to view this item. + if ( + currentUserCan && + item.capability && + ! currentUserCan( item.capability ) + ) { + return acc; + } + + // Add categories. + if ( item.isCategory ) { + categories[ item.id ] = item; + } + + acc[ item.parent ][ item.menuId ].push( item ); + return acc; + }, {} ); + + return { + items, + categories, + }; +}; diff --git a/plugins/woocommerce-admin/src/Features/Navigation/CoreMenu.php b/plugins/woocommerce-admin/src/Features/Navigation/CoreMenu.php index 9aca261b66e..1ed3e4f08f5 100644 --- a/plugins/woocommerce-admin/src/Features/Navigation/CoreMenu.php +++ b/plugins/woocommerce-admin/src/Features/Navigation/CoreMenu.php @@ -282,56 +282,63 @@ class CoreMenu { */ public function add_dashboard_menu_items() { global $submenu, $menu; - $top_level_items = Menu::get_category_items( 'woocommerce' ); + $mapped_items = Menu::get_mapped_menu_items(); + $top_level = $mapped_items['woocommerce']; // phpcs:disable - if ( ! isset( $submenu['woocommerce'] ) ) { + if ( ! isset( $submenu['woocommerce'] ) || empty( $top_level ) ) { return; } - foreach( $top_level_items as $item ) { - // Skip extensions. - if ( ! isset( $item['menuId'] ) || $item['menuId'] === 'plugins' ) { - continue; - } + $menuIds = array( + 'primary', + 'secondary', + 'favorites', + ); - // Skip specific categories. - if ( - in_array( - $item['id'], - array( - 'woocommerce-tools', - ), - true - ) - ) { - continue; - } - - // Use the link from the first item if it's a category. - if ( ! isset( $item['url'] ) ) { - $category_items = Menu::get_category_items( $item['id'] ); - if ( ! empty( $category_items ) ) { - $first_item = $category_items[0]; - - $submenu['woocommerce'][] = array( - $item['title'], - $first_item['capability'], - isset( $first_item['url'] ) ? $first_item['url'] : null, - $item['title'], - ); + foreach ( $menuIds as $menuId ) { + foreach( $top_level[ $menuId ] as $item ) { + // Skip specific categories. + if ( + in_array( + $item['id'], + array( + 'woocommerce-tools', + ), + true + ) + ) { + continue; } + + // Use the link from the first item if it's a category. + if ( ! isset( $item['url'] ) ) { + $categoryMenuId = $menuId === 'favorites' ? 'plugins' : $menuId; + $category_items = $mapped_items[ $item['id'] ][ $categoryMenuId ]; - continue; + if ( ! empty( $category_items ) ) { + $first_item = $category_items[0]; + + + $submenu['woocommerce'][] = array( + $item['title'], + $first_item['capability'], + isset( $first_item['url'] ) ? $first_item['url'] : null, + $item['title'], + ); + } + + continue; + } + + // Show top-level items. + $submenu['woocommerce'][] = array( + $item['title'], + $item['capability'], + isset( $item['url'] ) ? $item['url'] : null, + $item['title'], + ); } - - // Show top-level items. - $submenu['woocommerce'][] = array( - $item['title'], - $item['capability'], - isset( $item['url'] ) ? $item['url'] : null, - $item['title'], - ); } // phpcs:enable } diff --git a/plugins/woocommerce-admin/src/Features/Navigation/Menu.php b/plugins/woocommerce-admin/src/Features/Navigation/Menu.php index 30d986f7a4f..975ef8ade79 100644 --- a/plugins/woocommerce-admin/src/Features/Navigation/Menu.php +++ b/plugins/woocommerce-admin/src/Features/Navigation/Menu.php @@ -50,6 +50,16 @@ class Menu { */ const CSS_CLASSES = 4; + /** + * Array of usable menu IDs. + */ + const MENU_IDS = array( + 'primary', + 'favorites', + 'plugins', + 'secondary', + ); + /** * Store menu items. * @@ -675,8 +685,7 @@ class Menu { * @return array */ public static function get_items() { - $menu_items = self::get_prepared_menu_item_data( self::$menu_items ); - return apply_filters( 'woocommerce_navigation_menu_items', $menu_items ); + return apply_filters( 'woocommerce_navigation_menu_items', self::$menu_items ); } /** @@ -691,7 +700,6 @@ class Menu { $menu_item_ids = self::$categories[ $category ]; - $category_menu_items = array(); foreach ( $menu_item_ids as $id ) { if ( isset( self::$menu_items[ $id ] ) ) { @@ -699,7 +707,6 @@ class Menu { } } - $category_menu_items = self::get_prepared_menu_item_data( $category_menu_items ); return apply_filters( 'woocommerce_navigation_menu_category_items', $category_menu_items ); } @@ -713,31 +720,43 @@ class Menu { } /** - * Gets the menu item data for use in the client. + * Gets the menu item data mapped by category and menu ID. * - * @param array $menu_items Menu items to prepare. * @return array */ - public static function get_prepared_menu_item_data( $menu_items ) { - foreach ( $menu_items as $index => $menu_item ) { - if ( isset( $menu_item[ 'capability' ] ) && ! current_user_can( $menu_item[ 'capability' ] ) ) { - unset( $menu_items[ $index ] ); - } - } + public static function get_mapped_menu_items() { + $menu_items = self::get_items(); + $mapped_items = array(); - // Sort the menu items. - $menuOrder = array( - 'primary' => 0, - 'secondary' => 1, - 'plugins' => 2, - 'favorites' => 3, - ); - $menu = array_map( function( $item ) use( $menuOrder ) { return $menuOrder[ $item['menuId'] ]; }, $menu_items ); + // Sort the items by order and title. $order = array_column( $menu_items, 'order' ); $title = array_column( $menu_items, 'title' ); - array_multisort( $menu, SORT_ASC, $order, SORT_ASC, $title, SORT_ASC, $menu_items ); + array_multisort( $order, SORT_ASC, $title, SORT_ASC, $menu_items ); - return $menu_items; + foreach ( $menu_items as $id => $menu_item ) { + $category_id = $menu_item[ 'parent' ]; + $menu_id = $menu_item[ 'menuId' ]; + if ( ! isset( $mapped_items[ $category_id ] ) ) { + $mapped_items[ $category_id ] = array(); + foreach ( self::MENU_IDS as $available_menu_id ) { + $mapped_items[ $category_id ][ $available_menu_id ] = array(); + } + } + + // Incorrect menu ID. + if ( ! isset( $mapped_items[ $category_id ][ $menu_id ] ) ) { + continue; + } + + // Remove the item if the user cannot access it. + if ( isset( $menu_item[ 'capability' ] ) && ! current_user_can( $menu_item[ 'capability' ] ) ) { + continue; + } + + $mapped_items[ $category_id ][ $menu_id ][] = $menu_item; + } + + return $mapped_items; } /**