@@ -118,10 +119,6 @@ const renderWrappedItemInPanel = () => {
);
};
-// Attempts to find the tools panel via its CSS class.
-const getPanel = ( container ) =>
- container.querySelector( '.components-tools-panel' );
-
// Renders a default tools panel including children that are
// not to be represented within the panel's menu.
const renderPanel = () => {
@@ -139,15 +136,24 @@ const renderPanel = () => {
);
};
+/**
+ * Retrieves the panel's dropdown menu toggle button.
+ *
+ * @return {HTMLElement} The menu button.
+ */
+const getMenuButton = () => {
+ return screen.getByRole( 'button', {
+ name: /view([\w\s]+)options/i,
+ } );
+};
+
/**
* Helper to find the menu button and simulate a user click.
*
* @return {HTMLElement} The menuButton.
*/
const openDropdownMenu = () => {
- const menuButton = screen.getByRole( 'button', {
- name: /view([\w\s]+)options/i,
- } );
+ const menuButton = getMenuButton();
fireEvent.click( menuButton );
return menuButton;
};
@@ -166,17 +172,17 @@ describe( 'ToolsPanel', () => {
describe( 'basic rendering', () => {
it( 'should render panel', () => {
- const { container } = renderPanel();
-
- expect( getPanel( container ) ).toBeInTheDocument();
- } );
-
- it( 'should render non panel item child', () => {
renderPanel();
- const nonPanelItem = screen.queryByText( 'Visible' );
+ const menuButton = getMenuButton();
+ const label = screen.getByText( defaultProps.label );
+ const control = screen.getByText( 'Example control' );
+ const nonToolsPanelItem = screen.getByText( 'Visible' );
- expect( nonPanelItem ).toBeInTheDocument();
+ expect( menuButton ).toBeInTheDocument();
+ expect( label ).toBeInTheDocument();
+ expect( control ).toBeInTheDocument();
+ expect( nonToolsPanelItem ).toBeInTheDocument();
} );
it( 'should render panel item flagged as default control even without value', () => {
@@ -295,13 +301,13 @@ describe( 'ToolsPanel', () => {
expect( control ).toBeInTheDocument();
await selectMenuItem( controlProps.label );
- const resetControl = screen.queryByText( 'Default control' );
+ const resetControl = screen.getByText( 'Default control' );
expect( resetControl ).toBeInTheDocument();
} );
it( 'should render appropriate menu groups', async () => {
- const { container } = render(
+ render(
{
);
openDropdownMenu();
- const menuGroups = container.querySelectorAll(
- '.components-menu-group'
- );
+ const menuGroups = screen.getAllByRole( 'group' );
// Groups should be: default controls, optional controls & reset all.
expect( menuGroups.length ).toEqual( 3 );
} );
- it( 'should render placeholder items when panel opts into that feature', () => {
- const { container } = render(
+ it( 'should not render contents of items when in placeholder state', () => {
+ render(
{
);
const optionalItem = screen.queryByText( 'Optional control' );
- const placeholder = container.querySelector(
- '.components-tools-panel-item'
- );
// When rendered as a placeholder a ToolsPanelItem will just omit
- // all the item's children. So we should still find the container
- // element but not the text etc within.
+ // all the item's children. So the container element will still be
+ // there holding its position but the inner text etc should not be
+ // there.
expect( optionalItem ).not.toBeInTheDocument();
- expect( placeholder ).toBeInTheDocument();
} );
} );
@@ -414,26 +415,30 @@ describe( 'ToolsPanel', () => {
openDropdownMenu();
const defaultItem = screen.getByText( 'Nested Control 1' );
- const defaultMenuItem = defaultItem.parentNode;
+ const defaultMenuItem = screen.getByRole( 'menuitemcheckbox', {
+ name: 'Reset Nested Control 1',
+ checked: true,
+ } );
const altItem = screen.getByText( 'Nested Control 2' );
- const altMenuItem = altItem.parentNode;
+ const altMenuItem = screen.getByRole( 'menuitemcheckbox', {
+ name: 'Show Nested Control 2',
+ checked: false,
+ } );
expect( defaultItem ).toBeInTheDocument();
- expect( defaultMenuItem ).toHaveAttribute( 'aria-checked', 'true' );
+ expect( defaultMenuItem ).toBeInTheDocument();
expect( altItem ).toBeInTheDocument();
- expect( altMenuItem ).toHaveAttribute( 'aria-checked', 'false' );
+ expect( altMenuItem ).toBeInTheDocument();
} );
} );
describe( 'wrapped panel items within custom components', () => {
it( 'should render wrapped items correctly', () => {
- const { container } = renderWrappedItemInPanel();
+ renderWrappedItemInPanel();
- const wrappers = container.querySelectorAll(
- '.wrapped-panel-item-container'
- );
+ const wrappers = screen.getAllByText( 'Wrapper' );
const defaultItem = screen.getByText( 'Wrapped 1' );
const altItem = screen.queryByText( 'Wrapped 2' );
@@ -449,20 +454,30 @@ describe( 'ToolsPanel', () => {
openDropdownMenu();
const defaultItem = screen.getByText( 'Nested Control 1' );
- const defaultMenuItem = defaultItem.parentNode;
+ const defaultMenuItem = screen.getByRole( 'menuitemcheckbox', {
+ name: 'Reset Nested Control 1',
+ checked: true,
+ } );
const altItem = screen.getByText( 'Nested Control 2' );
- const altMenuItem = altItem.parentNode;
+ const altMenuItem = screen.getByRole( 'menuitemcheckbox', {
+ name: 'Show Nested Control 2',
+ checked: false,
+ } );
expect( defaultItem ).toBeInTheDocument();
- expect( defaultMenuItem ).toHaveAttribute( 'aria-checked', 'true' );
+ expect( defaultMenuItem ).toBeInTheDocument();
expect( altItem ).toBeInTheDocument();
- expect( altMenuItem ).toHaveAttribute( 'aria-checked', 'false' );
+ expect( altMenuItem ).toBeInTheDocument();
} );
} );
describe( 'rendering via SlotFills', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
it( 'should maintain visual order of controls when toggled on and off', async () => {
// Multiple fills are added to better simulate panel items being
// injected from different locations.
@@ -515,6 +530,64 @@ describe( 'ToolsPanel', () => {
expect( items[ 0 ] ).toHaveTextContent( 'Item 1' );
expect( items[ 1 ] ).toHaveTextContent( 'Item 2' );
} );
+
+ it( 'should not trigger callback when fill has not updated yet when panel has', () => {
+ // Fill provided controls can update independently to the panel.
+ // A `panelId` prop was added to both panels and items
+ // so it could prevent erroneous registrations and calls to
+ // `onDeselect` etc.
+ //
+ // See: https://github.com/WordPress/gutenberg/pull/35375
+ //
+ // This test simulates this issue by rendering an item within a
+ // contrived `ToolsPanelContext` to reflect the changes the panel
+ // item needs to protect against.
+
+ const noop = () => undefined;
+ const context = {
+ panelId: '1234',
+ menuItems: {
+ default: {},
+ optional: { [ altControlProps.label ]: true },
+ },
+ hasMenuItems: false,
+ isResetting: false,
+ shouldRenderPlaceholderItems: false,
+ registerPanelItem: noop,
+ deregisterPanelItem: noop,
+ flagItemCustomization: noop,
+ areAllOptionalControlsHidden: true,
+ };
+
+ // This initial render gives the tools panel item a chance to
+ // set its internal state to reflect it was previously selected.
+ // This later forms part of the condition used to determine if an
+ // item is being deselected and thus call the onDeselect callback.
+ const { rerender } = render(
+
+
+
Item
+
+
+ );
+
+ // Simulate a change in panel separate to the rendering of fills.
+ // e.g. a switch of block selection.
+ context.panelId = '4321';
+ context.menuItems.optional[ altControlProps.label ] = false;
+
+ // Rerender the panel item and ensure that it skips any check
+ // for deselection given it still belongs to a different panelId.
+ rerender(
+
+
+
Item
+
+
+ );
+
+ expect( altControlProps.onDeselect ).not.toHaveBeenCalled();
+ } );
} );
describe( 'panel header icon toggle', () => {
diff --git a/packages/components/src/tooltip/stories/index.js b/packages/components/src/tooltip/stories/index.js
index a15588c264ca2a..8685d6ecbe02eb 100644
--- a/packages/components/src/tooltip/stories/index.js
+++ b/packages/components/src/tooltip/stories/index.js
@@ -17,6 +17,9 @@ import Tooltip from '../';
export default {
title: 'Components/ToolTip',
component: Tooltip,
+ parameters: {
+ knobs: { disabled: false },
+ },
};
export const _default = () => {
diff --git a/packages/components/src/tree-select/stories/index.js b/packages/components/src/tree-select/stories/index.js
index c4f299bde2b4cf..7a1e07c0f2a981 100644
--- a/packages/components/src/tree-select/stories/index.js
+++ b/packages/components/src/tree-select/stories/index.js
@@ -16,6 +16,9 @@ import TreeSelect from '../';
export default {
title: 'Components/TreeSelect',
component: TreeSelect,
+ parameters: {
+ knobs: { disabled: false },
+ },
};
const TreeSelectWithState = ( props ) => {
diff --git a/packages/components/src/truncate/stories/index.js b/packages/components/src/truncate/stories/index.js
index 0417514d068414..55dd719bf14bc5 100644
--- a/packages/components/src/truncate/stories/index.js
+++ b/packages/components/src/truncate/stories/index.js
@@ -11,6 +11,9 @@ import { Truncate } from '..';
export default {
component: Truncate,
title: 'Components (Experimental)/Truncate',
+ parameters: {
+ knobs: { disabled: false },
+ },
};
export const _default = () => {
diff --git a/packages/components/src/unit-control/stories/index.js b/packages/components/src/unit-control/stories/index.js
index 2605bc4b4c03b9..de4bdfc52a4fee 100644
--- a/packages/components/src/unit-control/stories/index.js
+++ b/packages/components/src/unit-control/stories/index.js
@@ -18,6 +18,9 @@ import { CSS_UNITS } from '../utils';
export default {
title: 'Components/UnitControl',
component: UnitControl,
+ parameters: {
+ knobs: { disabled: false },
+ },
};
const ControlWrapperView = styled.div`
diff --git a/packages/components/src/z-stack/stories/index.js b/packages/components/src/z-stack/stories/index.js
index 49cacc95b9c131..04685fdf9dae42 100644
--- a/packages/components/src/z-stack/stories/index.js
+++ b/packages/components/src/z-stack/stories/index.js
@@ -14,6 +14,9 @@ import { ZStack } from '..';
export default {
component: ZStack,
title: 'Components (Experimental)/ZStack',
+ parameters: {
+ knobs: { disabled: false },
+ },
};
const Avatar = ( { backgroundColor } ) => {
diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js
index 92eff202ce743e..7ff03976a8b4b0 100644
--- a/packages/core-data/src/resolvers.js
+++ b/packages/core-data/src/resolvers.js
@@ -85,7 +85,7 @@ export const getEntityRecord = ( kind, name, key = '', query ) => async ( {
// for how the request is made to the REST API.
// eslint-disable-next-line @wordpress/no-unused-vars-before-return
- const path = addQueryArgs( entity.baseURL + '/' + key, {
+ const path = addQueryArgs( entity.baseURL + ( key ? '/' + key : '' ), {
...entity.baseURLParams,
...query,
} );
diff --git a/packages/edit-navigation/src/index.js b/packages/edit-navigation/src/index.js
index 68a2328f714866..b31e46980bb2d6 100644
--- a/packages/edit-navigation/src/index.js
+++ b/packages/edit-navigation/src/index.js
@@ -92,3 +92,5 @@ export function initialize( id, settings ) {
document.getElementById( id )
);
}
+
+export { createMenuPreloadingMiddleware as __unstableCreateMenuPreloadingMiddleware } from './utils';
diff --git a/packages/edit-navigation/src/utils/index.js b/packages/edit-navigation/src/utils/index.js
new file mode 100644
index 00000000000000..2229f6a1c8b329
--- /dev/null
+++ b/packages/edit-navigation/src/utils/index.js
@@ -0,0 +1,122 @@
+/**
+ * The purpose of this function is to create a middleware that is responsible for preloading menu-related data.
+ * It uses data that is returned from the /__experimental/menus endpoint for requests
+ * to the /__experimental/menu/ endpoint, because the data is the same.
+ * This way, we can avoid making additional REST API requests.
+ * This middleware can be removed if/when we implement caching at the wordpress/core-data level.
+ *
+ * @param {Object} preloadedData
+ * @return {Function} Preloading middleware.
+ */
+export function createMenuPreloadingMiddleware( preloadedData ) {
+ const cache = Object.keys( preloadedData ).reduce( ( result, path ) => {
+ result[ getStablePath( path ) ] = preloadedData[ path ];
+ return result;
+ }, /** @type {Record} */ ( {} ) );
+
+ let menusDataLoaded = false;
+ let menuDataLoaded = false;
+
+ return ( options, next ) => {
+ const { parse = true } = options;
+ if ( 'string' !== typeof options.path ) {
+ return next( options );
+ }
+
+ const method = options.method || 'GET';
+ if ( 'GET' !== method ) {
+ return next( options );
+ }
+
+ const path = getStablePath( options.path );
+ if ( ! menusDataLoaded && cache[ path ] ) {
+ menusDataLoaded = true;
+ return sendSuccessResponse( cache[ path ], parse );
+ }
+
+ if ( menuDataLoaded ) {
+ return next( options );
+ }
+
+ const matches = path.match(
+ /^\/__experimental\/menus\/(\d+)\?context=edit$/
+ );
+ if ( ! matches ) {
+ return next( options );
+ }
+
+ const key = Object.keys( cache )?.[ 0 ];
+ const menuData = cache[ key ]?.body;
+ if ( ! menuData ) {
+ return next( options );
+ }
+
+ const menuId = parseInt( matches[ 1 ] );
+ const menu = menuData.filter( ( { id } ) => id === menuId );
+
+ if ( menu.length > 0 ) {
+ menuDataLoaded = true;
+ // We don't have headers because we "emulate" this request
+ return sendSuccessResponse(
+ { body: menu[ 0 ], headers: {} },
+ parse
+ );
+ }
+
+ return next( options );
+ };
+}
+
+/**
+ * This is a helper function that sends a success response.
+ *
+ * @param {Object} responseData An object with the menu data
+ * @param {boolean} parse A boolean that controls whether to send a response or just the response data
+ * @return {Object} Resolved promise
+ */
+function sendSuccessResponse( responseData, parse ) {
+ return Promise.resolve(
+ parse
+ ? responseData.body
+ : new window.Response( JSON.stringify( responseData.body ), {
+ status: 200,
+ statusText: 'OK',
+ headers: responseData.headers,
+ } )
+ );
+}
+
+/**
+ * Given a path, returns a normalized path where equal query parameter values
+ * will be treated as identical, regardless of order they appear in the original
+ * text.
+ *
+ * @param {string} path Original path.
+ *
+ * @return {string} Normalized path.
+ */
+export function getStablePath( path ) {
+ const splitted = path.split( '?' );
+ const query = splitted[ 1 ];
+ const base = splitted[ 0 ];
+ if ( ! query ) {
+ return base;
+ }
+
+ // 'b=1&c=2&a=5'
+ return (
+ base +
+ '?' +
+ query
+ // [ 'b=1', 'c=2', 'a=5' ]
+ .split( '&' )
+ // [ [ 'b, '1' ], [ 'c', '2' ], [ 'a', '5' ] ]
+ .map( ( entry ) => entry.split( '=' ) )
+ // [ [ 'a', '5' ], [ 'b, '1' ], [ 'c', '2' ] ]
+ .sort( ( a, b ) => a[ 0 ].localeCompare( b[ 0 ] ) )
+ // [ 'a=5', 'b=1', 'c=2' ]
+ .map( ( pair ) => pair.join( '=' ) )
+ // 'a=5&b=1&c=2'
+ .join( '&' )
+ );
+}
diff --git a/packages/edit-site/src/components/navigation-sidebar/navigation-panel/constants.js b/packages/edit-site/src/components/navigation-sidebar/navigation-panel/constants.js
index ae97bb567f473c..f601df39d61397 100644
--- a/packages/edit-site/src/components/navigation-sidebar/navigation-panel/constants.js
+++ b/packages/edit-site/src/components/navigation-sidebar/navigation-panel/constants.js
@@ -10,6 +10,7 @@ import {
TEMPLATE_PART_AREA_HEADER,
TEMPLATE_PART_AREA_FOOTER,
TEMPLATE_PART_AREA_SIDEBAR,
+ TEMPLATE_PART_AREA_GENERAL,
} from '../../../store/constants';
export const TEMPLATES_PRIMARY = [
@@ -101,7 +102,7 @@ export const TEMPLATE_PARTS_SUB_MENUS = [
title: __( 'sidebars' ),
},
{
- area: 'uncategorized',
+ area: TEMPLATE_PART_AREA_GENERAL,
menu: MENU_TEMPLATE_PARTS_GENERAL,
title: __( 'general' ),
},
diff --git a/packages/edit-site/src/components/sidebar/template-card/template-areas.js b/packages/edit-site/src/components/sidebar/template-card/template-areas.js
index b7af215bc0cb87..7762812fad558f 100644
--- a/packages/edit-site/src/components/sidebar/template-card/template-areas.js
+++ b/packages/edit-site/src/components/sidebar/template-card/template-areas.js
@@ -61,7 +61,7 @@ export default function TemplateAreas() {