From 1a04ac94d807521f6fb496ce00860ea02a2f4441 Mon Sep 17 00:00:00 2001 From: James Koster Date: Fri, 29 May 2026 09:23:10 +0100 Subject: [PATCH 01/60] Dashboard: Use Howdy greeting for page title (#78740) Co-authored-by: jameskoster Co-authored-by: simison Co-authored-by: retrofox --- routes/dashboard/stage.tsx | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/routes/dashboard/stage.tsx b/routes/dashboard/stage.tsx index e08834a556e1fb..d18272d6105e45 100644 --- a/routes/dashboard/stage.tsx +++ b/routes/dashboard/stage.tsx @@ -2,9 +2,10 @@ * WordPress dependencies */ import { Page } from '@wordpress/admin-ui'; +import { store as coreStore } from '@wordpress/core-data'; import { useDispatch, useSelect } from '@wordpress/data'; import { useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import { store as viewportStore } from '@wordpress/viewport'; @@ -34,8 +35,26 @@ function Dashboard() { [] ); - const customizeDashboardLabel = __( 'Customize Dashboard' ); - const dashboardLabel = __( 'Dashboard' ); + const greetingName = useSelect( ( select ) => { + const user = select( coreStore ).getCurrentUser(); + if ( ! user ) { + return undefined; + } + + const displayName = user.name?.trim(); + if ( displayName ) { + return displayName; + } + + if ( 'username' in user && typeof user.username === 'string' ) { + const username = user.username.trim(); + if ( username ) { + return username; + } + } + + return user.slug; + }, [] ); const { createSuccessNotice } = useDispatch( noticesStore ); @@ -46,7 +65,16 @@ function Dashboard() { } ); }; - const pageTitle = editMode ? customizeDashboardLabel : dashboardLabel; + let pageTitle: string = __( 'Dashboard' ); + if ( editMode ) { + pageTitle = __( 'Customize Dashboard' ); + } else if ( greetingName ) { + pageTitle = sprintf( + /* translators: %s: current user's display name. */ + __( 'Howdy, %s' ), + greetingName + ); + } return ( Date: Fri, 29 May 2026 10:42:37 +0200 Subject: [PATCH 02/60] Block Editor: Refactor Inserter to a function component (#78766) Co-authored-by: Mamaduka Co-authored-by: andrewserong Co-authored-by: jsnajdr --- .../src/components/inserter/index.js | 506 ++++++++---------- 1 file changed, 224 insertions(+), 282 deletions(-) diff --git a/packages/block-editor/src/components/inserter/index.js b/packages/block-editor/src/components/inserter/index.js index ab1b18cdaf2513..64ad560ae834b9 100644 --- a/packages/block-editor/src/components/inserter/index.js +++ b/packages/block-editor/src/components/inserter/index.js @@ -9,9 +9,7 @@ import clsx from 'clsx'; import { speak } from '@wordpress/a11y'; import { __, _x, sprintf } from '@wordpress/i18n'; import { Dropdown, Button } from '@wordpress/components'; -import { Component } from '@wordpress/element'; -import { withDispatch, withSelect } from '@wordpress/data'; -import { compose, ifCondition } from '@wordpress/compose'; +import { useDispatch, useRegistry, useSelect } from '@wordpress/data'; import { createBlock, store as blocksStore } from '@wordpress/blocks'; import { plus } from '@wordpress/icons'; @@ -23,7 +21,7 @@ import QuickInserter from './quick-inserter'; import { store as blockEditorStore } from '../../store'; import { getAppenderLabel } from './get-appender-label'; -const defaultRenderToggle = ( { +function InserterToggle( { onToggle, disabled, isOpen, @@ -31,7 +29,8 @@ const defaultRenderToggle = ( { hasSingleBlockType, appenderLabel, toggleProps = {}, -} ) => { + ref, +} ) { const { as: Wrapper = Button, label: labelProp, @@ -65,6 +64,7 @@ const defaultRenderToggle = ( { return ( ); -}; +} + +function Inserter( { + clientId, + rootClientId, + disabled, + isAppender, + position, + selectBlockOnInsert, + shouldDirectInsert = true, + showInserterHelpPanel, + // This prop is experimental to give some time for the quick inserter to mature + // Feel free to make them stable after a few releases. + __experimentalIsQuick: isQuick, + onSelectOrClose, + onToggle, + renderToggle: renderToggleProp, + toggleProps, + ref, +} ) { + const { + hasItems, + hasSingleBlockType, + blockTitle, + allowedBlockType, + blockToInsert, + appenderLabel, + targetRootClientId, + } = useSelect( + ( select ) => { + const { + getBlockRootClientId, + hasInserterItems, + getAllowedBlocks, + getDirectInsertBlock, + getBlockListSettings, + } = select( blockEditorStore ); + const { getBlockVariations, getBlockType } = select( blocksStore ); + + const _targetRootClientId = + rootClientId || getBlockRootClientId( clientId ) || undefined; + + const allowedBlocks = getAllowedBlocks( _targetRootClientId ); + const directInsertBlock = + shouldDirectInsert && + getDirectInsertBlock( _targetRootClientId ); + const { defaultBlock } = + getBlockListSettings( _targetRootClientId ) ?? {}; + + const _hasSingleBlockType = + allowedBlocks?.length === 1 && + getBlockVariations( allowedBlocks[ 0 ].name, 'inserter' ) + ?.length === 0; + const _allowedBlockType = _hasSingleBlockType + ? allowedBlocks[ 0 ] + : null; + + // Single-block-type parents get adjacent-attribute copying + // without needing to set `directInsert: true`. + let _blockToInsert = directInsertBlock || null; + if ( + ! _blockToInsert && + _hasSingleBlockType && + defaultBlock?.name === _allowedBlockType.name + ) { + _blockToInsert = defaultBlock; + } + + const defaultBlockType = directInsertBlock + ? getBlockType( directInsertBlock.name ) + : null; + + return { + hasItems: hasInserterItems( _targetRootClientId ), + hasSingleBlockType: _hasSingleBlockType, + blockTitle: _allowedBlockType ? _allowedBlockType.title : '', + allowedBlockType: _allowedBlockType, + blockToInsert: _blockToInsert, + appenderLabel: getAppenderLabel( + directInsertBlock, + defaultBlockType + ), + targetRootClientId: _targetRootClientId, + }; + }, + [ rootClientId, clientId, shouldDirectInsert ] + ); -class Inserter extends Component { - constructor() { - super( ...arguments ); + const registry = useRegistry(); + const { insertBlock } = useDispatch( blockEditorStore ); - this.onToggle = this.onToggle.bind( this ); - this.renderToggle = this.renderToggle.bind( this ); - this.renderContent = this.renderContent.bind( this ); + // The global inserter (no isAppender, no rootClientId, no clientId) should + // always render, even with no items. + if ( ! hasItems && ( isAppender || targetRootClientId || clientId ) ) { + return null; } - onToggle( isOpen ) { - const { onToggle } = this.props; + function insertOnlyAllowedBlock() { + const blockName = blockToInsert?.name ?? allowedBlockType.name; - // Surface toggle callback to parent component. - if ( onToggle ) { - onToggle( isOpen ); + function getAdjacentBlockAttributes( attributesToCopy ) { + if ( ! attributesToCopy?.length ) { + return {}; + } + + const { getBlock, getPreviousBlockClientId } = + registry.select( blockEditorStore ); + + // Find the adjacent block of the same type whose attributes + // should be copied: previous sibling when inserting next to + // an existing block, otherwise the last child of the root. + let adjacentAttributes; + if ( clientId ) { + const currentBlock = getBlock( clientId ); + const previousBlock = getBlock( + getPreviousBlockClientId( clientId ) + ); + if ( currentBlock?.name === previousBlock?.name ) { + adjacentAttributes = previousBlock?.attributes; + } + } else if ( targetRootClientId ) { + const lastInnerBlock = + getBlock( targetRootClientId )?.innerBlocks?.at( -1 ); + if ( lastInnerBlock?.name === blockName ) { + adjacentAttributes = lastInnerBlock.attributes; + } + } + + if ( ! adjacentAttributes ) { + return {}; + } + + return Object.fromEntries( + attributesToCopy + .filter( ( attr ) => attr in adjacentAttributes ) + .map( ( attr ) => [ attr, adjacentAttributes[ attr ] ] ) + ); } - } - /** - * Render callback to display Dropdown toggle element. - * - * @param {Object} options - * @param {Function} options.onToggle Callback to invoke when toggle is - * pressed. - * @param {boolean} options.isOpen Whether dropdown is currently open. - * - * @return {Element} Dropdown toggle element. - */ - renderToggle( { onToggle, isOpen } ) { - const { - disabled, - blockTitle, - hasSingleBlockType, - appenderLabel, - toggleProps, - hasItems, - renderToggle = defaultRenderToggle, - } = this.props; + function getInsertionIndex() { + const { + getBlockIndex, + getBlockSelectionEnd, + getBlockOrder, + getBlockRootClientId, + } = registry.select( blockEditorStore ); + + // If the clientId is defined, we insert at the position of the block. + if ( clientId ) { + return getBlockIndex( clientId ); + } + + // If there a selected block, we insert after the selected block. + const end = getBlockSelectionEnd(); + if ( + ! isAppender && + end && + getBlockRootClientId( end ) === targetRootClientId + ) { + return getBlockIndex( end ) + 1; + } + + // Otherwise, we insert at the end of the current rootClientId. + return getBlockOrder( targetRootClientId ).length; + } + + // Attempt to augment the inserted block with attributes from an adjacent block. + // This ensures styling from nearby blocks is preserved in the newly inserted block. + // See: https://github.com/WordPress/gutenberg/issues/37904 + const newAttributes = getAdjacentBlockAttributes( + blockToInsert?.attributesToCopy + ); + + const newBlock = createBlock( blockName, { + ...blockToInsert?.attributes, + ...newAttributes, + } ); + + insertBlock( + newBlock, + getInsertionIndex(), + targetRootClientId, + selectBlockOnInsert + ); + + onSelectOrClose?.( newBlock ); - return renderToggle( { - onToggle, + const message = sprintf( + // translators: %s: the name of the block that has been added + __( '%s block added' ), + allowedBlockType.title + ); + speak( message ); + } + + function renderToggle( { onToggle: dropdownOnToggle, isOpen } ) { + const toggleArgs = { + onToggle: dropdownOnToggle, isOpen, disabled: disabled || ! hasItems, blockTitle, hasSingleBlockType, appenderLabel, toggleProps, - } ); - } + }; + + if ( renderToggleProp ) { + return renderToggleProp( toggleArgs ); + } - /** - * Render callback to display Dropdown content element. - * - * @param {Object} options - * @param {Function} options.onClose Callback to invoke when dropdown is - * closed. - * - * @return {Element} Dropdown content element. - */ - renderContent( { onClose } ) { - const { - rootClientId, - clientId, - isAppender, - showInserterHelpPanel, - // This prop is experimental to give some time for the quick inserter to mature - // Feel free to make them stable after a few releases. - __experimentalIsQuick: isQuick, - onSelectOrClose, - selectBlockOnInsert, - } = this.props; + return ; + } + function renderContent( { onClose } ) { if ( isQuick ) { return ( - ); + if ( hasSingleBlockType || blockToInsert ) { + return renderToggle( { onToggle: insertOnlyAllowedBlock } ); } -} - -export default compose( [ - withSelect( - ( select, { clientId, rootClientId, shouldDirectInsert = true } ) => { - const { - getBlockRootClientId, - hasInserterItems, - getAllowedBlocks, - getDirectInsertBlock, - getBlockListSettings, - } = select( blockEditorStore ); - const { getBlockVariations, getBlockType } = select( blocksStore ); - - rootClientId = - rootClientId || getBlockRootClientId( clientId ) || undefined; - - const allowedBlocks = getAllowedBlocks( rootClientId ); - const directInsertBlock = - shouldDirectInsert && getDirectInsertBlock( rootClientId ); - const { defaultBlock } = getBlockListSettings( rootClientId ) ?? {}; - - const hasSingleBlockType = - allowedBlocks?.length === 1 && - getBlockVariations( allowedBlocks[ 0 ].name, 'inserter' ) - ?.length === 0; - const allowedBlockType = hasSingleBlockType - ? allowedBlocks[ 0 ] - : null; - // Single-block-type parents get adjacent-attribute copying - // without needing to set `directInsert: true`. - let blockToInsert = directInsertBlock || null; - if ( - ! blockToInsert && - hasSingleBlockType && - defaultBlock?.name === allowedBlockType.name - ) { - blockToInsert = defaultBlock; - } - - const defaultBlockType = directInsertBlock - ? getBlockType( directInsertBlock.name ) - : null; - const appenderLabel = getAppenderLabel( - directInsertBlock, - defaultBlockType - ); - - return { - hasItems: hasInserterItems( rootClientId ), - hasSingleBlockType, - blockTitle: allowedBlockType ? allowedBlockType.title : '', - allowedBlockType, - blockToInsert, - appenderLabel, - rootClientId, - }; - } - ), - withDispatch( ( dispatch, ownProps, { select } ) => { - return { - insertOnlyAllowedBlock() { - const { - rootClientId, - clientId, - isAppender, - hasSingleBlockType, - allowedBlockType, - blockToInsert, - onSelectOrClose, - selectBlockOnInsert, - } = ownProps; - - if ( ! hasSingleBlockType && ! blockToInsert ) { - return; - } - - const blockName = blockToInsert?.name ?? allowedBlockType.name; - - function getAdjacentBlockAttributes( attributesToCopy ) { - if ( ! attributesToCopy?.length ) { - return {}; - } - - const { getBlock, getPreviousBlockClientId } = - select( blockEditorStore ); - - // Find the adjacent block of the same type whose attributes - // should be copied: previous sibling when inserting next to - // an existing block, otherwise the last child of the root. - let adjacentAttributes; - if ( clientId ) { - const currentBlock = getBlock( clientId ); - const previousBlock = getBlock( - getPreviousBlockClientId( clientId ) - ); - if ( currentBlock?.name === previousBlock?.name ) { - adjacentAttributes = previousBlock?.attributes; - } - } else if ( rootClientId ) { - const lastInnerBlock = - getBlock( rootClientId )?.innerBlocks?.at( -1 ); - if ( lastInnerBlock?.name === blockName ) { - adjacentAttributes = lastInnerBlock.attributes; - } - } - - if ( ! adjacentAttributes ) { - return {}; - } - - return Object.fromEntries( - attributesToCopy - .filter( ( attr ) => attr in adjacentAttributes ) - .map( ( attr ) => [ - attr, - adjacentAttributes[ attr ], - ] ) - ); - } - - function getInsertionIndex() { - const { - getBlockIndex, - getBlockSelectionEnd, - getBlockOrder, - getBlockRootClientId, - } = select( blockEditorStore ); - - // If the clientId is defined, we insert at the position of the block. - if ( clientId ) { - return getBlockIndex( clientId ); - } - - // If there a selected block, we insert after the selected block. - const end = getBlockSelectionEnd(); - if ( - ! isAppender && - end && - getBlockRootClientId( end ) === rootClientId - ) { - return getBlockIndex( end ) + 1; - } - - // Otherwise, we insert at the end of the current rootClientId. - return getBlockOrder( rootClientId ).length; - } - - const { insertBlock } = dispatch( blockEditorStore ); - - // Attempt to augment the inserted block with attributes from an adjacent block. - // This ensures styling from nearby blocks is preserved in the newly inserted block. - // See: https://github.com/WordPress/gutenberg/issues/37904 - const newAttributes = getAdjacentBlockAttributes( - blockToInsert?.attributesToCopy - ); - - const newBlock = createBlock( blockName, { - ...( blockToInsert?.attributes || {} ), - ...newAttributes, - } ); - - insertBlock( - newBlock, - getInsertionIndex(), - rootClientId, - selectBlockOnInsert - ); - - if ( onSelectOrClose ) { - onSelectOrClose( newBlock ); - } + return ( + + ); +} - const message = sprintf( - // translators: %s: the name of the block that has been added - __( '%s block added' ), - allowedBlockType.title - ); - speak( message ); - }, - }; - } ), - // The global inserter should always be visible, we are using ( ! isAppender && ! rootClientId && ! clientId ) as - // a way to detect the global Inserter. - ifCondition( - ( { hasItems, isAppender, rootClientId, clientId } ) => - hasItems || ( ! isAppender && ! rootClientId && ! clientId ) - ), -] )( Inserter ); +export default Inserter; From 1dae94dbf5ed1baca3084fc2359765bc3adef1a4 Mon Sep 17 00:00:00 2001 From: James Koster Date: Fri, 29 May 2026 12:32:07 +0100 Subject: [PATCH 03/60] Dashboard: Move layout settings to customize toolbar (#78738) Co-authored-by: jameskoster Co-authored-by: simison Co-authored-by: retrofox --- routes/dashboard/widget-dashboard/README.md | 2 +- .../components/actions/actions.tsx | 50 +++++++----- .../layout-settings/layout-settings.tsx | 24 +++--- .../context/dashboard-context.tsx | 34 ++++---- .../widget-dashboard/context/ui-context.tsx | 5 +- .../widget-dashboard/test/actions.test.tsx | 80 ++++++++++++++++++- .../widget-dashboard/test/staging.test.tsx | 44 +++++++++- routes/dashboard/widget-dashboard/types.ts | 2 +- 8 files changed, 183 insertions(+), 58 deletions(-) diff --git a/routes/dashboard/widget-dashboard/README.md b/routes/dashboard/widget-dashboard/README.md index 0de985219a6e9d..8ef714a3cf143c 100644 --- a/routes/dashboard/widget-dashboard/README.md +++ b/routes/dashboard/widget-dashboard/README.md @@ -88,7 +88,7 @@ Renders its children only when `layout` is empty. Pair it with `` -Edit-mode toggle: a "Customize" button while `editMode` is off, and "Add widget", "Cancel", "Done" while it is on. Clicking "Customize" or "Done" fires `onEditChange` with the toggled value. Clicking "Add widget" opens the inserter (see below). Returns `null` when the dashboard is mounted without `onEditChange`, so hosts that don't expose edit mode can keep `Actions` in their tree unconditionally. +Edit-mode toggle: a "Customize" button while `editMode` is off, and "Add widget", "Layout settings" (when `onGridSettingsChange` is provided), "Cancel", "Done" while it is on. Layout settings is only available in customize mode. Clicking "Customize" or "Done" fires `onEditChange` with the toggled value. Clicking "Add widget" opens the inserter (see below). Returns `null` when the dashboard is mounted without `onEditChange`, so surfaces that don't expose edit mode can keep `Actions` in their tree unconditionally. `` from `@wordpress/admin-ui` exposes an `actions` slot used across admin screens (DataViews, WidgetDashboard, …). Plug `Actions` straight into it: diff --git a/routes/dashboard/widget-dashboard/components/actions/actions.tsx b/routes/dashboard/widget-dashboard/components/actions/actions.tsx index f8f1acc5a0df6c..5a6df5c9fecf7f 100644 --- a/routes/dashboard/widget-dashboard/components/actions/actions.tsx +++ b/routes/dashboard/widget-dashboard/components/actions/actions.tsx @@ -4,7 +4,7 @@ import { useSelect } from '@wordpress/data'; import { useCallback, useEffect, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { plus } from '@wordpress/icons'; +import { layout as layoutIcon, plus } from '@wordpress/icons'; import { store as viewportStore } from '@wordpress/viewport'; // eslint-disable-next-line @wordpress/use-recommended-components import { AlertDialog, Button, Stack } from '@wordpress/ui'; @@ -20,18 +20,13 @@ import { MoreActionsDropdown } from '../more-actions-dropdown'; import type { MoreActionsDropdownItem } from '../more-actions-dropdown'; /** - * Header chrome for the dashboard. Two independent flows are exposed: - * - * - **Customize** (layout edits): toggles edit mode, surfaces the Add - * widgets / Cancel / Done toolbar. Commits the layout staging buffer - * on Done. - * - **Layout settings** (more-actions dropdown entry): opens a side - * drawer with model, column behavior, and row height. Commits the - * settings staging buffer on Save inside the drawer. - * - * The two flows are mutually exclusive: the Layout settings entry is - * disabled while edit mode is on so the settings drawer cannot - * accumulate changes on top of pending layout edits, and vice versa. + * Header chrome for the dashboard. Customize mode surfaces an edit + * toolbar with Add widget, Layout settings (when grid settings are + * editable), Cancel, and Done. Layout settings opens a side drawer + * for model, column behavior, and row height; Save inside the drawer + * commits the settings staging buffer without leaving customize mode. + * Widget layout edits and grid settings share the same staging layer + * while customize mode is active. * * Returns `null` when the dashboard is mounted without `onEditChange` * so hosts that don't expose edit mode can keep `Actions` in their @@ -108,6 +103,12 @@ export function Actions(): React.ReactNode { setLayoutSettingsOpen( true ); }, [ setLayoutSettingsOpen ] ); + useEffect( () => { + if ( ! editMode && layoutSettingsOpen ) { + setLayoutSettingsOpen( false ); + } + }, [ editMode, layoutSettingsOpen, setLayoutSettingsOpen ] ); + const moreActionsItems: MoreActionsDropdownItem[] = [ { label: __( 'Reset to default' ), @@ -116,15 +117,6 @@ export function Actions(): React.ReactNode { }, ]; - if ( canEditGridSettings ) { - moreActionsItems.unshift( { - label: __( 'Layout settings' ), - onClick: openLayoutSettings, - disabled: editMode, - disabledTooltip: __( 'Disabled while editing widgets' ), - } ); - } - if ( ! onEditChange ) { return null; } @@ -151,6 +143,20 @@ export function Actions(): React.ReactNode { { __( 'Add widget' ) } + { canEditGridSettings && ( + + ) } +