From 0792221dd597290fe512b109a1469f2057f0dda4 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Fri, 1 Sep 2023 13:12:05 +0100 Subject: [PATCH] Rename Group blocks in the Editor via Modal (#53735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Enable metadata support for Group block * Enable Rename option in block dropdown * Add rename functionality via modal * Use pretty block name as default * Ensure modal cannot be shown if block does not support naming * Use aria-disabled * Use form submit * Allow flow to reset custom name and remove metadata * Ensure input has label * Scaffold initial e2e test * Test ability to reset value * Leave options menu in place on modal open Ensures focus transfer * Augment tests with assertions on focus management * Use provided setAttributes * Extract single component * Remove redundant comments * Remove redundant class * Utilise built in Playwright enabled/disabled * Use built in assertions * Use aria- attributes on menu item * Autofocus input * Add block rename to block inspector controls * Move information on saving block data to parent * Make Rename component simpler * Use canonical block name as placeholder * Add aria-described by to Modal As per https://github.com/WordPress/gutenberg/pull/53735#discussion_r1298860765 * Use custom name in List View aria label Addresses https://github.com/WordPress/gutenberg/pull/53735#pullrequestreview-1585215094 * Add announcement when updating the block name * Default value should be empty unless custom name is set * Fix tests * Enable submitting empty to reset * Combine e2e tests into single test to save resources * Rename inspector control to `Block name` * Remove required and allow submitting true empty values * Update code to treat empty value as trigger to “reset” * Centralise assignment of blockTitle * Add test coverage for List View reflecting custom name * Update modal title to match menu option --- packages/base-styles/_z-index.scss | 1 + .../src/components/list-view/block.js | 4 +- .../use-block-display-information/index.js | 3 + .../block-editor/src/hooks/block-rename-ui.js | 230 ++++++++++++++++++ .../src/hooks/block-rename-ui.scss | 3 + packages/block-editor/src/hooks/index.js | 1 + packages/block-editor/src/style.scss | 1 + packages/block-library/src/group/block.json | 1 + .../editor/various/block-renaming.spec.js | 221 +++++++++++++++++ 9 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 packages/block-editor/src/hooks/block-rename-ui.js create mode 100644 packages/block-editor/src/hooks/block-rename-ui.scss create mode 100644 test/e2e/specs/editor/various/block-renaming.spec.js diff --git a/packages/base-styles/_z-index.scss b/packages/base-styles/_z-index.scss index cabd7788715911..8df96260c80664 100644 --- a/packages/base-styles/_z-index.scss +++ b/packages/base-styles/_z-index.scss @@ -125,6 +125,7 @@ $z-layers: ( ".edit-site-create-template-part-modal": 1000001, ".block-editor-block-lock-modal": 1000001, ".block-editor-template-part__selection-modal": 1000001, + ".block-editor-block-rename-modal": 1000001, // Note: The ConfirmDialog component's z-index is being set to 1000001 in packages/components/src/confirm-dialog/styles.ts // because it uses emotion and not sass. We need it to render on top its parent popover. diff --git a/packages/block-editor/src/components/list-view/block.js b/packages/block-editor/src/components/list-view/block.js index d4845dc769c7fa..cf32d17fc60e14 100644 --- a/packages/block-editor/src/components/list-view/block.js +++ b/packages/block-editor/src/components/list-view/block.js @@ -72,7 +72,9 @@ function ListViewBlock( { const { toggleBlockHighlight } = useDispatch( blockEditorStore ); const blockInformation = useBlockDisplayInformation( clientId ); - const blockTitle = blockInformation?.title || __( 'Untitled' ); + const blockTitle = + blockInformation?.name || blockInformation?.title || __( 'Untitled' ); + const block = useSelect( ( select ) => select( blockEditorStore ).getBlock( clientId ), [ clientId ] diff --git a/packages/block-editor/src/components/use-block-display-information/index.js b/packages/block-editor/src/components/use-block-display-information/index.js index 1cff9da4bc04a9..68e9abf8936741 100644 --- a/packages/block-editor/src/components/use-block-display-information/index.js +++ b/packages/block-editor/src/components/use-block-display-information/index.js @@ -26,6 +26,7 @@ import { store as blockEditorStore } from '../../store'; * @property {WPIcon} icon Block type icon. * @property {string} description A detailed block type description. * @property {string} anchor HTML anchor. + * @property {name} name A custom, human readable name for the block. */ /** @@ -94,6 +95,7 @@ export default function useBlockDisplayInformation( clientId ) { anchor: attributes?.anchor, positionLabel, positionType: attributes?.style?.position?.type, + name: attributes?.metadata?.name, }; if ( ! match ) return blockTypeInfo; @@ -105,6 +107,7 @@ export default function useBlockDisplayInformation( clientId ) { anchor: attributes?.anchor, positionLabel, positionType: attributes?.style?.position?.type, + name: attributes?.metadata?.name, }; }, [ clientId ] diff --git a/packages/block-editor/src/hooks/block-rename-ui.js b/packages/block-editor/src/hooks/block-rename-ui.js new file mode 100644 index 00000000000000..189090668cbb45 --- /dev/null +++ b/packages/block-editor/src/hooks/block-rename-ui.js @@ -0,0 +1,230 @@ +/** + * WordPress dependencies + */ +import { createHigherOrderComponent, useInstanceId } from '@wordpress/compose'; +import { addFilter } from '@wordpress/hooks'; +import { __, sprintf } from '@wordpress/i18n'; +import { getBlockSupport } from '@wordpress/blocks'; +import { + MenuItem, + __experimentalHStack as HStack, + __experimentalVStack as VStack, + Button, + TextControl, + Modal, +} from '@wordpress/components'; +import { useState } from '@wordpress/element'; +import { speak } from '@wordpress/a11y'; + +/** + * Internal dependencies + */ +import { + BlockSettingsMenuControls, + useBlockDisplayInformation, + InspectorControls, +} from '../components'; + +const emptyString = ( testString ) => testString?.trim()?.length === 0; + +function RenameModal( { blockName, originalBlockName, onClose, onSave } ) { + const [ editedBlockName, setEditedBlockName ] = useState( blockName ); + + const nameHasChanged = editedBlockName !== blockName; + const nameIsOriginal = editedBlockName === originalBlockName; + const nameIsEmpty = emptyString( editedBlockName ); + + const isNameValid = nameHasChanged || nameIsOriginal; + + const autoSelectInputText = ( event ) => event.target.select(); + + const dialogDescription = useInstanceId( + RenameModal, + `block-editor-rename-modal__description` + ); + + const handleSubmit = () => { + // Must be assertive to immediately announce change. + speak( + sprintf( + /* translators: %1$s: type of update (either reset of changed). %2$s: new name/label for the block */ + __( 'Block name %1$s to: "%2$s".' ), + nameIsOriginal || nameIsEmpty ? __( 'reset' ) : __( 'changed' ), + editedBlockName + ), + 'assertive' + ); + + onSave( editedBlockName ); + + // Immediate close avoids ability to hit save multiple times. + onClose(); + }; + + return ( + +

+ { __( 'Choose a custom name for this block.' ) } +

+
{ + e.preventDefault(); + + if ( ! isNameValid ) { + return; + } + + handleSubmit(); + } } + > + + + + + + + + +
+
+ ); +} + +function BlockRenameControl( props ) { + const [ renamingBlock, setRenamingBlock ] = useState( false ); + + const { clientId, customName, onChange } = props; + + const blockInformation = useBlockDisplayInformation( clientId ); + + return ( + <> + + + + + { ( { selectedClientIds } ) => { + // Only enabled for single selections. + const canRename = + selectedClientIds.length === 1 && + clientId === selectedClientIds[ 0 ]; + + // This check ensures the `BlockSettingsMenuControls` fill + // doesn't render multiple times and also that it renders for + // the block from which the menu was triggered. + if ( ! canRename ) { + return null; + } + + return ( + { + setRenamingBlock( true ); + } } + aria-expanded={ renamingBlock } + aria-haspopup="dialog" + > + { __( 'Rename' ) } + + ); + } } + + + { renamingBlock && ( + setRenamingBlock( false ) } + onSave={ ( newName ) => { + // If the new value is the block's original name (e.g. `Group`) + // or it is an empty string then assume the intent is to reset + // the value. Therefore reset the metadata. + if ( + newName === blockInformation?.title || + emptyString( newName ) + ) { + newName = undefined; + } + + onChange( newName ); + } } + /> + ) } + + ); +} + +export const withBlockRenameControl = createHigherOrderComponent( + ( BlockEdit ) => ( props ) => { + const { clientId, name, attributes, setAttributes } = props; + + const metaDataSupport = getBlockSupport( + name, + '__experimentalMetadata', + false + ); + + const supportsBlockNaming = !! ( + true === metaDataSupport || metaDataSupport?.name + ); + + return ( + <> + { supportsBlockNaming && ( + <> + { + setAttributes( { + metadata: { + ...( attributes?.metadata && + attributes?.metadata ), + name: newName, + }, + } ); + } } + /> + + ) } + + + + ); + }, + 'withToolbarControls' +); + +addFilter( + 'editor.BlockEdit', + 'core/block-rename-ui/with-block-rename-control', + withBlockRenameControl +); diff --git a/packages/block-editor/src/hooks/block-rename-ui.scss b/packages/block-editor/src/hooks/block-rename-ui.scss new file mode 100644 index 00000000000000..2b08e82662bc6f --- /dev/null +++ b/packages/block-editor/src/hooks/block-rename-ui.scss @@ -0,0 +1,3 @@ +.block-editor-block-rename-modal { + z-index: z-index(".block-editor-block-rename-modal"); +} diff --git a/packages/block-editor/src/hooks/index.js b/packages/block-editor/src/hooks/index.js index 5e18c6a309d693..9907fbed89c521 100644 --- a/packages/block-editor/src/hooks/index.js +++ b/packages/block-editor/src/hooks/index.js @@ -23,6 +23,7 @@ import './metadata-name'; import './behaviors'; import './custom-fields'; import './auto-inserting-blocks'; +import './block-rename-ui'; export { useCustomSides } from './dimensions'; export { useLayoutClasses, useLayoutStyles } from './layout'; diff --git a/packages/block-editor/src/style.scss b/packages/block-editor/src/style.scss index 5eafc0766ae220..67d5001a697a4c 100644 --- a/packages/block-editor/src/style.scss +++ b/packages/block-editor/src/style.scss @@ -53,6 +53,7 @@ @import "./hooks/padding.scss"; @import "./hooks/position.scss"; @import "./hooks/typography.scss"; +@import "./hooks/block-rename-ui.scss"; @import "./components/block-toolbar/style.scss"; @import "./components/inserter/style.scss"; diff --git a/packages/block-library/src/group/block.json b/packages/block-library/src/group/block.json index 9c2d012620c3c1..66d5a99e8bed48 100644 --- a/packages/block-library/src/group/block.json +++ b/packages/block-library/src/group/block.json @@ -24,6 +24,7 @@ "__experimentalOnEnter": true, "__experimentalOnMerge": true, "__experimentalSettings": true, + "__experimentalMetadata": true, "align": [ "wide", "full" ], "anchor": true, "ariaLabel": true, diff --git a/test/e2e/specs/editor/various/block-renaming.spec.js b/test/e2e/specs/editor/various/block-renaming.spec.js new file mode 100644 index 00000000000000..8568258aaa4fda --- /dev/null +++ b/test/e2e/specs/editor/various/block-renaming.spec.js @@ -0,0 +1,221 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Block Renaming', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test.describe( 'Dialog renaming', () => { + test( 'allows renaming of blocks that support the feature via dialog-based UI', async ( { + editor, + page, + pageUtils, + } ) => { + // Turn on block list view by default. + await page.evaluate( () => { + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-site', 'showListViewByDefault', true ); + } ); + + const listView = page.getByRole( 'treegrid', { + name: 'Block navigation structure', + } ); + + // Create a two blocks on the page. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'First Paragraph' }, + } ); + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Second Paragraph' }, + } ); + + // Multiselect via keyboard. + await pageUtils.pressKeys( 'primary+a' ); + await pageUtils.pressKeys( 'primary+a' ); + + // Convert to a Group block which supports renaming. + await editor.clickBlockOptionsMenuItem( 'Group' ); + + await editor.clickBlockOptionsMenuItem( 'Rename' ); + + const renameMenuItem = page.getByRole( 'menuitem', { + name: 'Rename', + includeHidden: true, // the option is hidden behind modal but assertion is still valid. + } ); + + await expect( renameMenuItem ).toHaveAttribute( + 'aria-expanded', + 'true' + ); + + const renameModal = page.getByRole( 'dialog', { + name: 'Rename', + } ); + + // Check focus is transferred into modal. + await expect( renameModal ).toBeFocused(); + + // Check the Modal is perceivable. + await expect( renameModal ).toBeVisible(); + + const saveButton = renameModal.getByRole( 'button', { + name: 'Save', + type: 'submit', + } ); + + await expect( saveButton ).toBeDisabled(); + + const nameInput = renameModal.getByLabel( 'Block name' ); + + await expect( nameInput ).toHaveAttribute( 'placeholder', 'Group' ); + + await nameInput.fill( 'My new name' ); + + await expect( saveButton ).toBeEnabled(); + + await saveButton.click(); + + await expect( renameModal ).toBeHidden(); + + // Check that focus is transferred back to original "Rename" menu item. + await expect( renameMenuItem ).toBeFocused(); + + await expect( renameMenuItem ).toHaveAttribute( + 'aria-expanded', + 'false' + ); + + // Check custom name reflected in List View. + listView.getByRole( 'link', { + name: 'My new name', + } ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/group', + attributes: { + metadata: { + name: 'My new name', + }, + }, + }, + ] ); + + // Re-trigger the rename dialog. + await renameMenuItem.click(); + + // Expect modal input to contain the custom name. + await expect( nameInput ).toHaveValue( 'My new name' ); + + // Clear the input of text content. + await nameInput.focus(); + await pageUtils.pressKeys( 'primary+a' ); + await page.keyboard.press( 'Delete' ); + + // Check placeholder for input is the original block name. + await expect( nameInput ).toHaveAttribute( 'placeholder', 'Group' ); + + // It should be possible to submit empty. + await expect( saveButton ).toBeEnabled(); + + await saveButton.click(); + + // Check the original block name to reflected in List View. + listView.getByRole( 'link', { + name: 'Group', + } ); + + // Expect block to have no custom name (i.e. it should be reset to the original block name). + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/group', + attributes: { + metadata: { + name: undefined, + }, + }, + }, + ] ); + } ); + } ); + + test.describe( 'Block inspector renaming', () => { + test( 'allows renaming of blocks that support the feature via "Advanced" section of block inspector tools', async ( { + editor, + page, + pageUtils, + } ) => { + // Create a two blocks on the page. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'First Paragraph' }, + } ); + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Second Paragraph' }, + } ); + + // Multiselect via keyboard. + await pageUtils.pressKeys( 'primary+a' ); + await pageUtils.pressKeys( 'primary+a' ); + + // Convert to a Group block which supports renaming. + await editor.clickBlockOptionsMenuItem( 'Group' ); + + await editor.openDocumentSettingsSidebar(); + + const advancedPanelToggle = page + .getByRole( 'region', { + name: 'Editor settings', + } ) + .getByRole( 'button', { + name: 'Advanced', + expanded: false, + } ); + + await advancedPanelToggle.click(); + + const nameInput = page.getByRole( 'textbox', { + name: 'Block name', + } ); + + await expect( nameInput ).toBeEmpty(); + + await nameInput.fill( 'My new name' ); + + await expect( nameInput ).toHaveValue( 'My new name' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/group', + attributes: { + metadata: { + name: 'My new name', + }, + }, + }, + ] ); + + await nameInput.focus(); + await pageUtils.pressKeys( 'primary+a' ); + await page.keyboard.press( 'Delete' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/group', + attributes: { + metadata: { + name: '', + }, + }, + }, + ] ); + } ); + } ); +} );