diff --git a/lib/compat/wordpress-6.3/class-gutenberg-navigation-fallback.php b/lib/compat/wordpress-6.3/class-gutenberg-navigation-fallback.php index 91417971e22c73..fcf6e13b0954d7 100644 --- a/lib/compat/wordpress-6.3/class-gutenberg-navigation-fallback.php +++ b/lib/compat/wordpress-6.3/class-gutenberg-navigation-fallback.php @@ -23,9 +23,18 @@ class Gutenberg_Navigation_Fallback { */ public static function get_fallback() { + /** + * Filters whether or not a fallback should be created. + * + * @since 6.3.0 + * + * @param bool Whether or not to create a fallback. + */ + $should_create_fallback = apply_filters( 'gutenberg_navigation_should_create_fallback', true ); + $fallback = static::get_most_recently_published_navigation(); - if ( $fallback ) { + if ( $fallback || ! $should_create_fallback ) { return $fallback; } diff --git a/lib/compat/wordpress-6.3/theme-previews.php b/lib/compat/wordpress-6.3/theme-previews.php index eab05c5824b1ff..31d684c17cf2a3 100644 --- a/lib/compat/wordpress-6.3/theme-previews.php +++ b/lib/compat/wordpress-6.3/theme-previews.php @@ -88,7 +88,7 @@ function addLivePreviewButton() { livePreviewButton.setAttribute('class', 'button button-primary'); livePreviewButton.setAttribute( 'href', - `/wp-admin/site-editor.php?wp_theme_preview=${themePath}&return=themes.php` + `?wp_theme_preview=${themePath}&return=themes.php` ); livePreviewButton.innerHTML = ''; themeInfo.querySelector('.theme-actions').appendChild(livePreviewButton); diff --git a/packages/block-editor/src/components/block-draggable/test/index.native.js b/packages/block-editor/src/components/block-draggable/test/index.native.js index 5b670970406f51..47b3bc829345fb 100644 --- a/packages/block-editor/src/components/block-draggable/test/index.native.js +++ b/packages/block-editor/src/components/block-draggable/test/index.native.js @@ -133,16 +133,6 @@ describe( 'BlockDraggable', () => { // "firePanGesture" finishes the dragging gesture firePanGesture( blockDraggableWrapper ); expect( getDraggableChip( screen ) ).not.toBeDefined(); - - // Start dragging from block's mobile toolbar - fireLongPress( - paragraphBlock, - 'draggable-trigger-mobile-toolbar' - ); - expect( getDraggableChip( screen ) ).toBeVisible(); - // "firePanGesture" finishes the dragging gesture - firePanGesture( blockDraggableWrapper ); - expect( getDraggableChip( screen ) ).not.toBeDefined(); } ) ); it( 'does not enable drag mode when selected and editing text', async () => @@ -243,16 +233,6 @@ describe( 'BlockDraggable', () => { // "firePanGesture" finishes the dragging gesture firePanGesture( blockDraggableWrapper ); expect( getDraggableChip( screen ) ).not.toBeDefined(); - - // Start dragging from block's mobile toolbar - fireLongPress( - imageBlock, - 'draggable-trigger-mobile-toolbar' - ); - expect( getDraggableChip( screen ) ).toBeVisible(); - // "firePanGesture" finishes the dragging gesture - firePanGesture( blockDraggableWrapper ); - expect( getDraggableChip( screen ) ).not.toBeDefined(); } ) ); } ); @@ -301,16 +281,6 @@ describe( 'BlockDraggable', () => { // "firePanGesture" finishes the dragging gesture firePanGesture( blockDraggableWrapper ); expect( getDraggableChip( screen ) ).not.toBeDefined(); - - // Start dragging from block's mobile toolbar - fireLongPress( - galleryBlock, - 'draggable-trigger-mobile-toolbar' - ); - expect( getDraggableChip( screen ) ).toBeVisible(); - // "firePanGesture" finishes the dragging gesture - firePanGesture( blockDraggableWrapper ); - expect( getDraggableChip( screen ) ).not.toBeDefined(); } ) ); it( 'enables drag mode when nested block is selected', async () => @@ -336,20 +306,6 @@ describe( 'BlockDraggable', () => { // "firePanGesture" finishes the dragging gesture firePanGesture( blockDraggableWrapper ); expect( getDraggableChip( screen ) ).not.toBeDefined(); - - // After dropping the block, the gallery item gets automatically selected. - // Hence, we have to select the gallery item again. - fireEvent.press( galleryItem ); - - // Start dragging from nested block's mobile toolbar - fireLongPress( - galleryItem, - 'draggable-trigger-mobile-toolbar' - ); - expect( getDraggableChip( screen ) ).toBeVisible(); - // "firePanGesture" finishes the dragging gesture - firePanGesture( blockDraggableWrapper ); - expect( getDraggableChip( screen ) ).not.toBeDefined(); } ) ); } ); @@ -390,16 +346,6 @@ describe( 'BlockDraggable', () => { // "firePanGesture" finishes the dragging gesture firePanGesture( blockDraggableWrapper ); expect( getDraggableChip( screen ) ).not.toBeDefined(); - - // Start dragging from block's mobile toolbar - fireLongPress( - spacerBlock, - 'draggable-trigger-mobile-toolbar' - ); - expect( getDraggableChip( screen ) ).toBeVisible(); - // "firePanGesture" finishes the dragging gesture - firePanGesture( blockDraggableWrapper ); - expect( getDraggableChip( screen ) ).not.toBeDefined(); } ) ); } ); } ); diff --git a/packages/block-editor/src/components/block-edit/edit.js b/packages/block-editor/src/components/block-edit/edit.js index 1154b99efebab5..31344b54337935 100644 --- a/packages/block-editor/src/components/block-edit/edit.js +++ b/packages/block-editor/src/components/block-edit/edit.js @@ -29,7 +29,25 @@ import BlockContext from '../block-context'; */ const DEFAULT_BLOCK_CONTEXT = {}; -export const Edit = ( props ) => { +const Edit = ( props ) => { + const { name } = props; + const blockType = getBlockType( name ); + + if ( ! blockType ) { + return null; + } + + // `edit` and `save` are functions or components describing the markup + // with which a block is displayed. If `blockType` is valid, assign + // them preferentially as the render value for the block. + const Component = blockType.edit || blockType.save; + + return ; +}; + +const EditWithFilters = withFilters( 'editor.BlockEdit' )( Edit ); + +const EditWithGeneratedProps = ( props ) => { const { attributes = {}, name } = props; const blockType = getBlockType( name ); const blockContext = useContext( BlockContext ); @@ -49,13 +67,8 @@ export const Edit = ( props ) => { return null; } - // `edit` and `save` are functions or components describing the markup - // with which a block is displayed. If `blockType` is valid, assign - // them preferentially as the render value for the block. - const Component = blockType.edit || blockType.save; - if ( blockType.apiVersion > 1 ) { - return ; + return ; } // Generate a class name for the block's editable form. @@ -69,8 +82,12 @@ export const Edit = ( props ) => { ); return ( - + ); }; -export default withFilters( 'editor.BlockEdit' )( Edit ); +export default EditWithGeneratedProps; diff --git a/packages/block-editor/src/components/block-edit/test/edit.js b/packages/block-editor/src/components/block-edit/test/edit.js index 0eb4c72cbbfc9f..76afbcb852ac19 100644 --- a/packages/block-editor/src/components/block-edit/test/edit.js +++ b/packages/block-editor/src/components/block-edit/test/edit.js @@ -15,7 +15,7 @@ import { /** * Internal dependencies */ -import { Edit } from '../edit'; +import Edit from '../edit'; import { BlockContextProvider } from '../../block-context'; const noop = () => {}; diff --git a/packages/block-editor/src/components/block-list/block.native.js b/packages/block-editor/src/components/block-list/block.native.js index bb537f22d0fb06..89f30572fe3315 100644 --- a/packages/block-editor/src/components/block-list/block.native.js +++ b/packages/block-editor/src/components/block-list/block.native.js @@ -6,12 +6,11 @@ import { Pressable, useWindowDimensions, View } from 'react-native'; /** * WordPress dependencies */ -import { useCallback, useMemo, useRef, useState } from '@wordpress/element'; +import { useCallback, useMemo, useState } from '@wordpress/element'; import { GlobalStylesContext, getMergedGlobalStyles, useMobileGlobalStylesColors, - alignmentHelpers, useGlobalStyles, } from '@wordpress/components'; import { @@ -36,9 +35,7 @@ import { compose, ifCondition, pure } from '@wordpress/compose'; import BlockEdit from '../block-edit'; import BlockDraggable from '../block-draggable'; import BlockInvalidWarning from './block-invalid-warning'; -import BlockMobileToolbar from '../block-mobile-toolbar'; import BlockOutline from './block-outline'; -import styles from './block.scss'; import { store as blockEditorStore } from '../../store'; import { useLayout } from './layout'; import useSetting from '../use-setting'; @@ -63,8 +60,6 @@ function getWrapperProps( value, getWrapperPropsFunction ) { function BlockWrapper( { accessibilityLabel, - align, - blockWidth, children, clientId, draggingClientId, @@ -72,18 +67,12 @@ function BlockWrapper( { isDescendentBlockSelected, isParentSelected, isSelected, - isStackedHorizontally, isTouchable, marginHorizontal, marginVertical, - onDeleteBlock, onFocus, } ) { const { width: screenWidth } = useWindowDimensions(); - const anchorNodeRef = useRef(); - const { isFullWidth } = alignmentHelpers; - const isScreenWidthEqual = blockWidth === screenWidth; - const isFullWidthToolbar = isFullWidth( align ) || isScreenWidthEqual; const blockWrapperStyles = { flex: 1 }; const blockWrapperStyle = [ blockWrapperStyles, @@ -116,19 +105,6 @@ function BlockWrapper( { > { children } - - { isSelected && ( - - ) } - ); } @@ -295,7 +271,6 @@ function BlockListBlock( { ), ] ); - const { align } = attributes; const isFocused = isSelected || isDescendentBlockSelected; const isTouchable = isSelected || @@ -312,8 +287,6 @@ function BlockListBlock( { return ( { () => diff --git a/packages/block-editor/src/components/block-list/block.native.scss b/packages/block-editor/src/components/block-list/block.native.scss index 78905de5bd733f..0576f97f5a3a98 100644 --- a/packages/block-editor/src/components/block-list/block.native.scss +++ b/packages/block-editor/src/components/block-list/block.native.scss @@ -51,15 +51,10 @@ min-height: 50px; } -.neutralToolbar { - margin-left: -$block-edge-to-content; - margin-right: -$block-edge-to-content; -} - .solidBorder { position: absolute; top: -$solid-border-space; - bottom: 0; + bottom: -$solid-border-space; left: -$solid-border-space; right: -$solid-border-space; border-width: $block-selected-border-width; diff --git a/packages/block-editor/src/components/block-mobile-toolbar/index.native.js b/packages/block-editor/src/components/block-mobile-toolbar/index.native.js deleted file mode 100644 index d4dd69551715f4..00000000000000 --- a/packages/block-editor/src/components/block-mobile-toolbar/index.native.js +++ /dev/null @@ -1,127 +0,0 @@ -/** - * External dependencies - */ -import { View } from 'react-native'; - -/** - * WordPress dependencies - */ -import { withDispatch, withSelect } from '@wordpress/data'; -import { compose } from '@wordpress/compose'; -import { useState, useEffect } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import styles from './style.scss'; -import BlockMover from '../block-mover'; -import BlockDraggable from '../block-draggable'; -import BlockActionsMenu from './block-actions-menu'; -import { BlockSettingsButton } from '../block-settings'; -import { store as blockEditorStore } from '../../store'; - -// Defined breakpoints are used to get a point when -// `settings` and `mover` controls should be wrapped into `BlockActionsMenu` -// and accessed through `BottomSheet`(Android)/`ActionSheet`(iOS). -const BREAKPOINTS = { - wrapSettings: 65, - wrapMover: 150, -}; -const BlockMobileToolbar = ( { - clientId, - onDelete, - isStackedHorizontally, - blockWidth, - anchorNodeRef, - isFullWidth, - draggingClientId, -} ) => { - const [ fillsLength, setFillsLength ] = useState( null ); - const [ appenderWidth, setAppenderWidth ] = useState( 0 ); - const spacingValue = styles.toolbar.marginLeft * 2; - - function onLayout( { nativeEvent } ) { - const { layout } = nativeEvent; - const layoutWidth = Math.floor( layout.width ); - if ( layoutWidth !== appenderWidth ) { - setAppenderWidth( nativeEvent.layout.width ); - } - } - - const wrapBlockSettings = - blockWidth < BREAKPOINTS.wrapSettings || - appenderWidth - spacingValue < BREAKPOINTS.wrapSettings; - const wrapBlockMover = - blockWidth <= BREAKPOINTS.wrapMover || - appenderWidth - spacingValue <= BREAKPOINTS.wrapMover; - - const BlockSettingsButtonFill = ( fillProps ) => { - useEffect( - () => fillProps.onChangeFillsLength( fillProps.fillsLength ), - [ fillProps.fillsLength ] - ); - return fillProps.children ?? null; - }; - - return ( - - { ! wrapBlockMover && ( - - ) } - - - { () => } - - - - { /* Render only one settings icon even if we have more than one fill - need for hooks with controls. */ } - { ( fills = [ null ] ) => ( - // The purpose of BlockSettingsButtonFill component is only to provide a way - // to pass data upstream from the slot rendering. - - { wrapBlockSettings ? null : fills[ 0 ] } - - ) } - - - - - ); -}; - -export default compose( - withSelect( ( select, { clientId } ) => { - const { getBlockIndex } = select( blockEditorStore ); - - return { - order: getBlockIndex( clientId ), - }; - } ), - withDispatch( ( dispatch, { clientId, rootClientId, onDelete } ) => { - const { removeBlock } = dispatch( blockEditorStore ); - return { - onDelete: - onDelete || ( () => removeBlock( clientId, rootClientId ) ), - }; - } ) -)( BlockMobileToolbar ); diff --git a/packages/block-editor/src/components/block-mobile-toolbar/style.native.scss b/packages/block-editor/src/components/block-mobile-toolbar/style.native.scss deleted file mode 100644 index 9a951770486602..00000000000000 --- a/packages/block-editor/src/components/block-mobile-toolbar/style.native.scss +++ /dev/null @@ -1,16 +0,0 @@ -.toolbar { - flex-direction: row; - height: $mobile-block-toolbar-height; - align-items: flex-start; - margin-left: $block-selected-margin; - margin-right: $block-selected-margin; -} - -.toolbarFullWidth { - padding-left: $grid-unit-15; - padding-right: $grid-unit-15; -} - -.spacer { - flex-grow: 1; -} diff --git a/packages/block-editor/src/components/block-mover/index.native.js b/packages/block-editor/src/components/block-mover/index.native.js index 852515a3438212..06a1a881d14f8e 100644 --- a/packages/block-editor/src/components/block-mover/index.native.js +++ b/packages/block-editor/src/components/block-mover/index.native.js @@ -7,7 +7,7 @@ import { Platform } from 'react-native'; * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { Picker, ToolbarButton } from '@wordpress/components'; +import { Picker, ToolbarButton, ToolbarGroup } from '@wordpress/components'; import { withInstanceId, compose } from '@wordpress/compose'; import { withSelect, withDispatch } from '@wordpress/data'; import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; @@ -107,7 +107,7 @@ export const BlockMover = ( { } return ( - <> + - + ); }; diff --git a/packages/block-editor/src/components/block-mover/test/__snapshots__/index.native.js.snap b/packages/block-editor/src/components/block-mover/test/__snapshots__/index.native.js.snap index ea30939ef9d520..db322ec2af8406 100644 --- a/packages/block-editor/src/components/block-mover/test/__snapshots__/index.native.js.snap +++ b/packages/block-editor/src/components/block-mover/test/__snapshots__/index.native.js.snap @@ -43,7 +43,16 @@ exports[`Block Mover Picker moving blocks moves blocks up and down 1`] = ` `; exports[`Block Mover Picker should render without crashing and match snapshot 1`] = ` -[ + - , + - , -] + + `; diff --git a/packages/block-editor/src/components/block-settings/button.native.js b/packages/block-editor/src/components/block-settings/button.native.js index 9fa1a037494294..9db4c279a05d9d 100644 --- a/packages/block-editor/src/components/block-settings/button.native.js +++ b/packages/block-editor/src/components/block-settings/button.native.js @@ -1,35 +1,20 @@ /** * WordPress dependencies */ -import { createSlotFill, ToolbarButton } from '@wordpress/components'; +import { ToolbarGroup, ToolbarButton } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { withDispatch } from '@wordpress/data'; import { cog } from '@wordpress/icons'; -const { Fill, Slot } = createSlotFill( 'SettingsToolbarButton' ); +const SettingsButton = ( { onOpenBlockSettings } ) => { + return ( + + + + ); +}; -const SettingsButton = ( { openGeneralSidebar } ) => ( - -); - -const SettingsButtonFill = ( props ) => ( - - - -); - -const SettingsToolbarButton = withDispatch( ( dispatch ) => { - const { openGeneralSidebar } = dispatch( 'core/edit-post' ); - - return { - openGeneralSidebar: () => openGeneralSidebar( 'edit-post/block' ), - }; -} )( SettingsButtonFill ); - -SettingsToolbarButton.Slot = Slot; - -export default SettingsToolbarButton; +export default SettingsButton; diff --git a/packages/block-editor/src/components/block-mobile-toolbar/block-actions-menu.native.js b/packages/block-editor/src/components/block-toolbar/block-toolbar-menu.native.js similarity index 98% rename from packages/block-editor/src/components/block-mobile-toolbar/block-actions-menu.native.js rename to packages/block-editor/src/components/block-toolbar/block-toolbar-menu.native.js index 48be5cb8a9c00b..bff035173b2909 100644 --- a/packages/block-editor/src/components/block-mobile-toolbar/block-actions-menu.native.js +++ b/packages/block-editor/src/components/block-toolbar/block-toolbar-menu.native.js @@ -9,6 +9,7 @@ import { Platform, findNodeHandle } from 'react-native'; import { getClipboard, setClipboard, + ToolbarGroup, ToolbarButton, Picker, } from '@wordpress/components'; @@ -254,11 +255,13 @@ const BlockActionsMenu = ( { // End early if there are no options to show. if ( ! options.length ) { return ( - + + + ); } @@ -294,7 +297,7 @@ const BlockActionsMenu = ( { anchorNodeRef ? findNodeHandle( anchorNodeRef ) : undefined; return ( - <> + - + ); }; diff --git a/packages/block-editor/src/components/block-toolbar/index.native.js b/packages/block-editor/src/components/block-toolbar/index.native.js index a1726441031b14..32233fa54a1c15 100644 --- a/packages/block-editor/src/components/block-toolbar/index.native.js +++ b/packages/block-editor/src/components/block-toolbar/index.native.js @@ -1,23 +1,60 @@ /** * WordPress dependencies */ -import { useSelect } from '@wordpress/data'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { useCallback } from '@wordpress/element'; /** * Internal dependencies */ +import BlockActionsMenu from './block-toolbar-menu'; import BlockControls from '../block-controls'; +import BlockMover from '../block-mover'; import UngroupButton from '../ungroup-button'; +import { BlockSettingsButton } from '../block-settings'; import { store as blockEditorStore } from '../../store'; -export default function BlockToolbar() { - const { isSelected, isValidAndVisual } = useSelect( ( select ) => { - const { getBlockMode, getSelectedBlockClientIds, isBlockValid } = - select( blockEditorStore ); +const REMOVE_EMPY_PARENT_BLOCKS = [ + 'core/buttons', + 'core/columns', + 'core/social-links', +]; + +export default function BlockToolbar( { anchorNodeRef, onOpenBlockSettings } ) { + const { + rootClientId, + blockClientId, + isSelected, + isValidAndVisual, + isStackedHorizontally, + parentBlockName, + parentNumberOfInnerBlocks, + } = useSelect( ( select ) => { + const { + getBlockListSettings, + getBlockMode, + getBlockName, + getBlockCount, + getBlockRootClientId, + getSelectedBlockClientIds, + isBlockValid, + } = select( blockEditorStore ); const selectedBlockClientIds = getSelectedBlockClientIds(); + const selectedBlockClientId = selectedBlockClientIds[ 0 ]; + const blockRootClientId = getBlockRootClientId( selectedBlockClientId ); + const blockListSettings = getBlockListSettings( blockRootClientId ); + const orientation = blockListSettings?.orientation; + const isBlockStackedHorizontally = orientation === 'horizontal'; + const parentName = getBlockName( blockRootClientId ); + const numberOfInnerBlocks = getBlockCount( blockRootClientId ); return { + rootClientId: blockRootClientId, + blockClientId: selectedBlockClientId, isSelected: selectedBlockClientIds.length > 0, + isStackedHorizontally: isBlockStackedHorizontally, + parentBlockName: parentName, + parentNumberOfInnerBlocks: numberOfInnerBlocks, isValidAndVisual: selectedBlockClientIds.length === 1 ? isBlockValid( selectedBlockClientIds[ 0 ] ) && @@ -26,6 +63,28 @@ export default function BlockToolbar() { }; }, [] ); + const { removeBlock } = useDispatch( blockEditorStore ); + const onRemove = useCallback( () => { + // Temp: remove parent block for specific cases where they don't + // have inner blocks, ideally we should match the behavior as in + // the Web editor and show a placeholder instead of removing the parent. + if ( + REMOVE_EMPY_PARENT_BLOCKS.includes( parentBlockName ) && + parentNumberOfInnerBlocks === 1 + ) { + removeBlock( rootClientId ); + return; + } + + removeBlock( blockClientId ); + }, [ + blockClientId, + parentBlockName, + parentNumberOfInnerBlocks, + removeBlock, + rootClientId, + ] ); + if ( ! isSelected ) { return null; } @@ -34,11 +93,27 @@ export default function BlockToolbar() { <> { isValidAndVisual && ( <> - + + + + + + + ) } diff --git a/packages/block-editor/src/components/block-mobile-toolbar/test/__snapshots__/block-actions-menu.native.js.snap b/packages/block-editor/src/components/block-toolbar/test/__snapshots__/block-toolbar-menu.native.js.snap similarity index 100% rename from packages/block-editor/src/components/block-mobile-toolbar/test/__snapshots__/block-actions-menu.native.js.snap rename to packages/block-editor/src/components/block-toolbar/test/__snapshots__/block-toolbar-menu.native.js.snap diff --git a/packages/block-editor/src/components/block-mobile-toolbar/test/block-actions-menu.native.js b/packages/block-editor/src/components/block-toolbar/test/block-toolbar-menu.native.js similarity index 81% rename from packages/block-editor/src/components/block-mobile-toolbar/test/block-actions-menu.native.js rename to packages/block-editor/src/components/block-toolbar/test/block-toolbar-menu.native.js index 940d95d2749423..6e26b906d68c9a 100644 --- a/packages/block-editor/src/components/block-mobile-toolbar/test/block-actions-menu.native.js +++ b/packages/block-editor/src/components/block-toolbar/test/block-toolbar-menu.native.js @@ -9,6 +9,7 @@ import { within, getEditorHtml, typeInRichText, + openBlockActionsMenu, } from 'test/helpers'; /** @@ -36,15 +37,14 @@ describe( 'Block Actions Menu', () => {

`, } ); - const { getByLabelText, getByRole } = screen; + const { getByRole } = screen; // Get block const paragraphBlock = await getBlock( screen, 'Paragraph' ); fireEvent.press( paragraphBlock ); // Open block actions menu - const blockActionsButton = getByLabelText( /Open Block Actions Menu/ ); - fireEvent.press( blockActionsButton ); + await openBlockActionsMenu( screen ); // Get Picker title const pickerHeader = getByRole( 'header' ); @@ -56,10 +56,8 @@ describe( 'Block Actions Menu', () => { describe( 'moving blocks', () => { it( 'moves blocks up and down', async () => { - const screen = await initializeEditor( { - screenWidth: 100, // To collapse the up/arrow buttons bellow blocks - } ); - const { getByLabelText, getByTestId } = screen; + const screen = await initializeEditor(); + const { getByLabelText } = screen; // Add Paragraph block await addBlock( screen, 'Paragraph' ); @@ -85,16 +83,8 @@ describe( 'Block Actions Menu', () => { } ); fireEvent.press( spacerBlock ); - // Open block actions menu - fireEvent.press( getByLabelText( /Open Block Actions Menu/ ) ); - - // Get block actions modal - let blockActionsMenu = await getByTestId( 'block-actions-menu' ); - // Tap on the Move Down button - fireEvent.press( - within( blockActionsMenu ).getByLabelText( 'Move block down' ) - ); + fireEvent.press( getByLabelText( /Move block down/ ) ); // Get Heading block const headingBlock = await getBlock( screen, 'Heading', { @@ -102,25 +92,15 @@ describe( 'Block Actions Menu', () => { } ); fireEvent.press( headingBlock ); - // Open block actions menu - fireEvent.press( getByLabelText( /Open Block Actions Menu/ ) ); - - // Get block actions modal - blockActionsMenu = await getByTestId( 'block-actions-menu' ); - // Tap on the Move Up button - fireEvent.press( - within( blockActionsMenu ).getByLabelText( 'Move block up' ) - ); + fireEvent.press( getByLabelText( /Move block up/ ) ); expect( getEditorHtml() ).toMatchSnapshot(); } ); it( 'disables the Move Up button for the first block', async () => { - const screen = await initializeEditor( { - screenWidth: 100, // To collapse the up/arrow buttons bellow blocks - } ); - const { getByLabelText, getByTestId } = screen; + const screen = await initializeEditor(); + const { getByLabelText } = screen; // Add Paragraph block await addBlock( screen, 'Paragraph' ); @@ -144,15 +124,8 @@ describe( 'Block Actions Menu', () => { paragraphBlock = await getBlock( screen, 'Paragraph' ); fireEvent.press( paragraphBlock ); - // Open block actions menu - fireEvent.press( getByLabelText( /Open Block Actions Menu/ ) ); - - // Get block actions modal - const blockActionsMenu = await getByTestId( 'block-actions-menu' ); - // Get the Move Up button - const upButton = - within( blockActionsMenu ).getByLabelText( 'Move block up' ); + const upButton = getByLabelText( /Move block up/ ); const isUpButtonDisabled = upButton.props.accessibilityState?.disabled; expect( isUpButtonDisabled ).toBe( true ); @@ -167,7 +140,7 @@ describe( 'Block Actions Menu', () => { const screen = await initializeEditor( { screenWidth: 100, } ); - const { getByLabelText, getByTestId } = screen; + const { getByLabelText } = screen; // Add Paragraph block await addBlock( screen, 'Paragraph' ); @@ -193,15 +166,8 @@ describe( 'Block Actions Menu', () => { } ); fireEvent.press( headingBlock ); - // Open block actions menu - fireEvent.press( getByLabelText( /Open Block Actions Menu/ ) ); - - // Get block actions modal - const blockActionsMenu = await getByTestId( 'block-actions-menu' ); - // Get the Move Down button - const downButton = - within( blockActionsMenu ).getByLabelText( 'Move block down' ); + const downButton = getByLabelText( /Move block down/ ); const isDownButtonDisabled = downButton.props.accessibilityState?.disabled; expect( isDownButtonDisabled ).toBe( true ); @@ -243,7 +209,7 @@ describe( 'Block Actions Menu', () => { fireEvent.press( headingBlock ); // Open block actions menu - fireEvent.press( getByLabelText( /Open Block Actions Menu/ ) ); + await openBlockActionsMenu( screen ); // Tap on the Copy button fireEvent.press( getByLabelText( /Copy/ ) ); @@ -253,7 +219,7 @@ describe( 'Block Actions Menu', () => { fireEvent.press( paragraphBlock ); // Open block actions menu - fireEvent.press( getByLabelText( /Open Block Actions Menu/ ) ); + await openBlockActionsMenu( screen ); // Tap on the Paste block after button fireEvent.press( getByLabelText( /Paste block after/ ) ); @@ -290,7 +256,7 @@ describe( 'Block Actions Menu', () => { fireEvent.press( headingBlock ); // Open block actions menu - fireEvent.press( getByLabelText( /Open Block Actions Menu/ ) ); + await openBlockActionsMenu( screen ); // Tap on the Copy button fireEvent.press( getByLabelText( /Copy/ ) ); @@ -300,7 +266,7 @@ describe( 'Block Actions Menu', () => { fireEvent.press( paragraphBlock ); // Open block actions menu - fireEvent.press( getByLabelText( /Open Block Actions Menu/ ) ); + await openBlockActionsMenu( screen ); // Tap on the Past block after button fireEvent.press( getByLabelText( /Paste block after/ ) ); @@ -335,7 +301,7 @@ describe( 'Block Actions Menu', () => { fireEvent.press( paragraphBlock ); // Open block actions menu - fireEvent.press( getByLabelText( /Open Block Actions Menu/ ) ); + await openBlockActionsMenu( screen ); // Tap on the Cut button fireEvent.press( getByLabelText( /Cut block/ ) ); @@ -346,7 +312,7 @@ describe( 'Block Actions Menu', () => { fireEvent.press( headingBlock ); // Open block actions menu - fireEvent.press( getByLabelText( /Open Block Actions Menu/ ) ); + await openBlockActionsMenu( screen ); // Tap on the Cut button fireEvent.press( getByLabelText( /Paste block after/ ) ); @@ -383,7 +349,7 @@ describe( 'Block Actions Menu', () => { fireEvent.press( spacerBlock ); // Open block actions menu - fireEvent.press( getByLabelText( /Open Block Actions Menu/ ) ); + await openBlockActionsMenu( screen ); // Tap on the Duplicate button fireEvent.press( getByLabelText( /Duplicate block/ ) ); @@ -418,7 +384,7 @@ describe( 'Block Actions Menu', () => { fireEvent.press( paragraphBlock ); // Open block actions menu - fireEvent.press( getByLabelText( /Open Block Actions Menu/ ) ); + await openBlockActionsMenu( screen ); // Tap on the Transform block button fireEvent.press( getByLabelText( /Transform block…/ ) ); diff --git a/packages/block-editor/src/components/inspector-controls/fill.native.js b/packages/block-editor/src/components/inspector-controls/fill.native.js index d38d865cd15cc0..f4cd288c6913cf 100644 --- a/packages/block-editor/src/components/inspector-controls/fill.native.js +++ b/packages/block-editor/src/components/inspector-controls/fill.native.js @@ -6,7 +6,6 @@ import { View } from 'react-native'; /** * WordPress dependencies */ -import { Children } from '@wordpress/element'; import { BottomSheetConsumer } from '@wordpress/components'; import warning from '@wordpress/warning'; import deprecated from '@wordpress/deprecated'; @@ -16,7 +15,6 @@ import deprecated from '@wordpress/deprecated'; */ import groups from './groups'; import useDisplayBlockControls from '../use-display-block-controls'; -import { BlockSettingsButton } from '../block-settings'; export default function InspectorControlsFill( { children, @@ -55,7 +53,6 @@ export default function InspectorControlsFill( { } - { Children.count( children ) > 0 && } ); } diff --git a/packages/block-editor/src/components/list-view/use-list-view-client-ids.js b/packages/block-editor/src/components/list-view/use-list-view-client-ids.js index d51412fdf2c3db..8a1ccfcede4c12 100644 --- a/packages/block-editor/src/components/list-view/use-list-view-client-ids.js +++ b/packages/block-editor/src/components/list-view/use-list-view-client-ids.js @@ -16,14 +16,14 @@ export default function useListViewClientIds( { blocks, rootClientId } ) { const { getDraggedBlockClientIds, getSelectedBlockClientIds, - getListViewClientIdsTree, + getEnabledClientIdsTree, } = unlock( select( blockEditorStore ) ); return { selectedClientIds: getSelectedBlockClientIds(), draggedClientIds: getDraggedBlockClientIds(), clientIdsTree: - blocks ?? getListViewClientIdsTree( rootClientId ), + blocks ?? getEnabledClientIdsTree( rootClientId ), }; }, [ blocks, rootClientId ] diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js index 3abc1b0b3bdfd3..e6fb17c5f6e3c7 100644 --- a/packages/block-editor/src/store/private-selectors.js +++ b/packages/block-editor/src/store/private-selectors.js @@ -136,21 +136,18 @@ export const isBlockSubtreeDisabled = createSelector( * * @return {Object[]} Tree of block objects with only clientID and innerBlocks set. */ -export const getListViewClientIdsTree = createSelector( +export const getEnabledClientIdsTree = createSelector( ( state, rootClientId = '' ) => { return getBlockOrder( state, rootClientId ).flatMap( ( clientId ) => { if ( getBlockEditingMode( state, clientId ) !== 'disabled' ) { return [ { clientId, - innerBlocks: getListViewClientIdsTree( - state, - clientId - ), + innerBlocks: getEnabledClientIdsTree( state, clientId ), }, ]; } - return getListViewClientIdsTree( state, clientId ); + return getEnabledClientIdsTree( state, clientId ); } ); }, ( state ) => [ diff --git a/packages/block-editor/src/store/test/private-selectors.js b/packages/block-editor/src/store/test/private-selectors.js index 30cf702c605263..e826db4a62bb9d 100644 --- a/packages/block-editor/src/store/test/private-selectors.js +++ b/packages/block-editor/src/store/test/private-selectors.js @@ -11,7 +11,7 @@ import { getLastInsertedBlocksClientIds, getBlockEditingMode, isBlockSubtreeDisabled, - getListViewClientIdsTree, + getEnabledClientIdsTree, getEnabledBlockParents, } from '../private-selectors'; @@ -391,7 +391,7 @@ describe( 'private selectors', () => { } ); } ); - describe( 'getListViewClientIdsTree', () => { + describe( 'getEnabledClientIdsTree', () => { const baseState = { settings: {}, blocks: { @@ -462,7 +462,7 @@ describe( 'private selectors', () => { ...baseState, blockEditingModes: new Map( [] ), }; - expect( getListViewClientIdsTree( state ) ).toEqual( [ + expect( getEnabledClientIdsTree( state ) ).toEqual( [ { clientId: '6cf70164-9097-4460-bcbf-200560546988', innerBlocks: [], @@ -500,7 +500,7 @@ describe( 'private selectors', () => { blockEditingModes: new Map( [] ), }; expect( - getListViewClientIdsTree( + getEnabledClientIdsTree( state, 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337' ) @@ -534,7 +534,7 @@ describe( 'private selectors', () => { [ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', 'contentOnly' ], ] ), }; - expect( getListViewClientIdsTree( state ) ).toEqual( [ + expect( getEnabledClientIdsTree( state ) ).toEqual( [ { clientId: 'b26fc763-417d-4f01-b81c-2ec61e14a972', innerBlocks: [], diff --git a/packages/block-editor/src/utils/object.js b/packages/block-editor/src/utils/object.js index a543c1e4f1d301..a144f1b1bf53a9 100644 --- a/packages/block-editor/src/utils/object.js +++ b/packages/block-editor/src/utils/object.js @@ -56,12 +56,17 @@ export function kebabCase( str ) { /** * Clones an object. + * Arrays are also cloned as arrays. * Non-object values are returned unchanged. * * @param {*} object Object to clone. * @return {*} Cloned object, or original literal non-object value. */ function cloneObject( object ) { + if ( Array.isArray( object ) ) { + return object.map( cloneObject ); + } + if ( object && typeof object === 'object' ) { return { ...Object.fromEntries( @@ -79,7 +84,7 @@ function cloneObject( object ) { /** * Immutably sets a value inside an object. Like `lodash#set`, but returning a * new object. Treats nullish initial values as empty objects. Clones any - * nested objects. + * nested objects. Supports arrays, too. * * @param {Object} object Object to set a value in. * @param {number|string|Array} path Path in the object to modify. @@ -92,7 +97,11 @@ export function setImmutably( object, path, value ) { normalizedPath.reduce( ( acc, key, i ) => { if ( acc[ key ] === undefined ) { - acc[ key ] = {}; + if ( Number.isInteger( path[ i + 1 ] ) ) { + acc[ key ] = []; + } else { + acc[ key ] = {}; + } } if ( i === normalizedPath.length - 1 ) { acc[ key ] = value; diff --git a/packages/block-editor/src/utils/test/object.js b/packages/block-editor/src/utils/test/object.js index def7e5e9c8f057..87f01375df311d 100644 --- a/packages/block-editor/src/utils/test/object.js +++ b/packages/block-editor/src/utils/test/object.js @@ -150,6 +150,22 @@ describe( 'setImmutably', () => { expect( result ).toEqual( { test: 2 } ); } ); + it( 'handles first level arrays properly', () => { + const result = setImmutably( [ 5 ], 0, 6 ); + + expect( result ).toEqual( [ 6 ] ); + } ); + + it( 'handles nested arrays properly', () => { + const result = setImmutably( + [ [ 'foo', [ 'bar' ] ] ], + [ 0, 1, 0 ], + 'baz' + ); + + expect( result ).toEqual( [ [ 'foo', [ 'baz' ] ] ] ); + } ); + describe( 'with array notation access', () => { it( 'assigns values at deeper levels', () => { const result = setImmutably( {}, [ 'foo', 'bar', 'baz' ], 5 ); @@ -236,5 +252,25 @@ describe( 'setImmutably', () => { expect( result.foo.bar ).not.toBe( input.foo.bar ); expect( result.foo.bar.baz ).not.toBe( input.foo.bar.baz ); } ); + + it( 'clones arrays at the first level', () => { + const input = []; + const result = setImmutably( input, 0, 1 ); + + expect( result ).not.toBe( input ); + } ); + + it( 'clones arrays at deeper levels', () => { + const input = [ [ [ [ 'foo', [ 'bar' ] ] ] ] ]; + const result = setImmutably( input, [ 0, 0, 0, 1, 0 ], 'baz' ); + + expect( result ).not.toBe( input ); + expect( result[ 0 ] ).not.toBe( input[ 0 ] ); + expect( result[ 0 ][ 0 ] ).not.toBe( input[ 0 ][ 0 ] ); + expect( result[ 0 ][ 0 ][ 0 ] ).not.toBe( input[ 0 ][ 0 ][ 0 ] ); + expect( result[ 0 ][ 0 ][ 0 ][ 1 ] ).not.toBe( + input[ 0 ][ 0 ][ 0 ][ 1 ] + ); + } ); } ); } ); diff --git a/packages/block-library/src/comment-template/index.php b/packages/block-library/src/comment-template/index.php index 3a553e802de0e7..bb1cfa474e4c36 100644 --- a/packages/block-library/src/comment-template/index.php +++ b/packages/block-library/src/comment-template/index.php @@ -35,8 +35,11 @@ function block_core_comment_template_render_comments( $comments, $block ) { * We set commentId context through the `render_block_context` filter so * that dynamically inserted blocks (at `render_block` filter stage) * will also receive that context. + * + * Use an early priority to so that other 'render_block_context' filters + * have access to the values. */ - add_filter( 'render_block_context', $filter_block_context ); + add_filter( 'render_block_context', $filter_block_context, 1 ); /* * We construct a new WP_Block instance from the parsed block so that @@ -44,7 +47,7 @@ function block_core_comment_template_render_comments( $comments, $block ) { */ $block_content = ( new WP_Block( $block->parsed_block ) )->render( array( 'dynamic' => false ) ); - remove_filter( 'render_block_context', $filter_block_context ); + remove_filter( 'render_block_context', $filter_block_context, 1 ); $children = $comment->get_children(); diff --git a/packages/block-library/src/gallery/test/index.native.js b/packages/block-library/src/gallery/test/index.native.js index a64b8bf4032811..ef4f445db337bc 100644 --- a/packages/block-library/src/gallery/test/index.native.js +++ b/packages/block-library/src/gallery/test/index.native.js @@ -161,7 +161,7 @@ describe( 'Gallery block', () => { // This case is disabled until the issue (https://github.com/WordPress/gutenberg/issues/38444) // is addressed. - it.skip( 'block remains selected after dimissing the media options picker', async () => { + it.skip( 'block remains selected after dismissing the media options picker', async () => { // Initialize with an empty gallery const { getByLabelText, getByText, getByTestId } = await initializeEditor( { @@ -175,7 +175,7 @@ describe( 'Gallery block', () => { expect( getByText( 'Choose images' ) ).toBeVisible(); expect( getByText( 'WordPress Media Library' ) ).toBeVisible(); - // Dimiss the picker + // Dismiss the picker if ( Platform.isIOS ) { fireEvent.press( getByText( 'Cancel' ) ); } else { @@ -511,10 +511,11 @@ describe( 'Gallery block', () => { // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc010 it( 'rearranges gallery items', async () => { // Initialize with a gallery that contains three items - const { galleryBlock } = await initializeWithGalleryBlock( { - numberOfItems: 3, - media, - } ); + const { getByLabelText, galleryBlock } = + await initializeWithGalleryBlock( { + numberOfItems: 3, + media, + } ); // Rearrange items (final disposition will be: Image 3 - Image 1 - Image 2) const galleryItem1 = getGalleryItem( galleryBlock, 1 ); @@ -523,7 +524,7 @@ describe( 'Gallery block', () => { fireEvent.press( galleryItem3 ); await act( () => fireEvent.press( - within( galleryItem3 ).getByLabelText( + getByLabelText( /Move block left from position 3 to position 2/ ) ) @@ -532,7 +533,7 @@ describe( 'Gallery block', () => { fireEvent.press( galleryItem1 ); await act( () => fireEvent.press( - within( galleryItem1 ).getByLabelText( + getByLabelText( /Move block right from position 1 to position 2/ ) ) diff --git a/packages/block-library/src/post-template/index.php b/packages/block-library/src/post-template/index.php index 3c023c80ed263d..b1499d845f39a6 100644 --- a/packages/block-library/src/post-template/index.php +++ b/packages/block-library/src/post-template/index.php @@ -97,11 +97,13 @@ function render_block_core_post_template( $attributes, $content, $block ) { $context['postId'] = $post_id; return $context; }; - add_filter( 'render_block_context', $filter_block_context ); + + // Use an early priority to so that other 'render_block_context' filters have access to the values. + add_filter( 'render_block_context', $filter_block_context, 1 ); // Render the inner blocks of the Post Template block with `dynamic` set to `false` to prevent calling // `render_callback` and ensure that no wrapper markup is included. $block_content = ( new WP_Block( $block_instance ) )->render( array( 'dynamic' => false ) ); - remove_filter( 'render_block_context', $filter_block_context ); + remove_filter( 'render_block_context', $filter_block_context, 1 ); // Wrap the render inner blocks in a `li` element with the appropriate post classes. $post_classes = implode( ' ', get_post_class( 'wp-block-post' ) ); diff --git a/packages/block-library/src/post-title/index.php b/packages/block-library/src/post-title/index.php index e1d4b255c57733..1769b199cebf17 100644 --- a/packages/block-library/src/post-title/index.php +++ b/packages/block-library/src/post-title/index.php @@ -19,8 +19,11 @@ function render_block_core_post_title( $attributes, $content, $block ) { return ''; } - $post = get_post( $block->context['postId'] ); - $title = get_the_title( $post ); + /** + * The `$post` argument is intentionally omitted so that changes are reflected when previewing a post. + * See: https://github.com/WordPress/gutenberg/pull/37622#issuecomment-1000932816. + */ + $title = get_the_title(); if ( ! $title ) { return ''; @@ -33,7 +36,7 @@ function render_block_core_post_title( $attributes, $content, $block ) { if ( isset( $attributes['isLink'] ) && $attributes['isLink'] ) { $rel = ! empty( $attributes['rel'] ) ? 'rel="' . esc_attr( $attributes['rel'] ) . '"' : ''; - $title = sprintf( '%4$s', get_the_permalink( $post ), esc_attr( $attributes['linkTarget'] ), $rel, $title ); + $title = sprintf( '%4$s', get_the_permalink( $block->context['postId'] ), esc_attr( $attributes['linkTarget'] ), $rel, $title ); } $classes = array(); diff --git a/packages/core-data/src/utils/set-nested-value.js b/packages/core-data/src/utils/set-nested-value.js index cb7db8a04b4b07..e90bf23e4dad8e 100644 --- a/packages/core-data/src/utils/set-nested-value.js +++ b/packages/core-data/src/utils/set-nested-value.js @@ -10,6 +10,8 @@ * * @see https://lodash.com/docs/4.17.15#set * + * @todo Needs to be deduplicated with its copy in `@wordpress/edit-site`. + * * @param {Object} object Object to modify * @param {Array} path Path of the property to set. * @param {*} value Value to set. diff --git a/packages/e2e-test-utils/src/create-reusable-block.js b/packages/e2e-test-utils/src/create-reusable-block.js index 4559d3b435fd15..97146381544037 100644 --- a/packages/e2e-test-utils/src/create-reusable-block.js +++ b/packages/e2e-test-utils/src/create-reusable-block.js @@ -38,7 +38,7 @@ export const createReusableBlock = async ( content, title ) => { // Wait for creation to finish await page.waitForXPath( - '//*[contains(@class, "components-snackbar")]/*[text()="Synced Pattern created."]' + '//*[contains(@class, "components-snackbar")]/*[contains(text(),"Pattern created:")]' ); // Check that we have a reusable block on the page diff --git a/packages/e2e-tests/specs/editor/various/reusable-blocks.test.js b/packages/e2e-tests/specs/editor/various/reusable-blocks.test.js index ec2fc8e535550c..0a18c75528930c 100644 --- a/packages/e2e-tests/specs/editor/various/reusable-blocks.test.js +++ b/packages/e2e-tests/specs/editor/various/reusable-blocks.test.js @@ -212,7 +212,7 @@ describe( 'Reusable blocks', () => { // Wait for creation to finish. await page.waitForXPath( - '//*[contains(@class, "components-snackbar")]/*[text()="Synced Pattern created."]' + '//*[contains(@class, "components-snackbar")]/*[contains(text(),"Pattern created:")]' ); await clearAllBlocks(); diff --git a/packages/e2e-tests/specs/site-editor/multi-entity-saving.test.js b/packages/e2e-tests/specs/site-editor/multi-entity-saving.test.js index d54f9c78dd8b89..c2bd3cb6d75076 100644 --- a/packages/e2e-tests/specs/site-editor/multi-entity-saving.test.js +++ b/packages/e2e-tests/specs/site-editor/multi-entity-saving.test.js @@ -12,8 +12,6 @@ import { activateTheme, clickButton, createReusableBlock, - visitSiteEditor, - enterEditMode, deleteAllTemplates, canvas, } from '@wordpress/e2e-test-utils'; @@ -239,104 +237,4 @@ describe( 'Multi-entity save flow', () => { expect( checkboxInputs ).toHaveLength( 1 ); } ); } ); - - describe( 'Site Editor', () => { - // Selectors - Site editor specific. - const saveSiteSelector = '.edit-site-save-button__button'; - const activeSaveSiteSelector = `${ saveSiteSelector }[aria-disabled=false]`; - const disabledSaveSiteSelector = `${ saveSiteSelector }[aria-disabled=true]`; - const saveA11ySelector = '.edit-site-editor__toggle-save-panel-button'; - - const saveAllChanges = async () => { - // Clicking button should open panel with boxes checked. - await page.click( activeSaveSiteSelector ); - await page.waitForSelector( savePanelSelector ); - await assertAllBoxesChecked(); - - // Save a11y button should not be present with save panel open. - await assertExistence( saveA11ySelector, false ); - - // Saving should result in items being saved. - await page.click( entitiesSaveSelector ); - }; - - it( 'Save flow should work as expected', async () => { - // Navigate to site editor. - await visitSiteEditor( { - postId: 'emptytheme//index', - postType: 'wp_template', - } ); - - await enterEditMode(); - - // Select the header template part via list view. - await page.click( '.edit-site-header-edit-mode__list-view-toggle' ); - const headerTemplatePartListViewButton = await page.waitForXPath( - '//a[contains(@class, "block-editor-list-view-block-select-button")][contains(., "header")]' - ); - headerTemplatePartListViewButton.click(); - await page.click( 'button[aria-label="Close"]' ); - - // Insert something to dirty the editor. - await insertBlock( 'Paragraph' ); - - const enabledButton = await page.waitForSelector( - activeSaveSiteSelector - ); - - // Should be enabled after edits. - expect( enabledButton ).not.toBeNull(); - - // Save a11y button should be present. - await assertExistence( saveA11ySelector, true ); - - // Save all changes. - await saveAllChanges(); - - const disabledButton = await page.waitForSelector( - disabledSaveSiteSelector - ); - expect( disabledButton ).not.toBeNull(); - } ); - - it( 'Save flow should allow re-saving after changing the same block attribute', async () => { - // Navigate to site editor. - await visitSiteEditor( { - postId: 'emptytheme//index', - postType: 'wp_template', - } ); - - await enterEditMode(); - - // Insert a paragraph at the bottom. - await insertBlock( 'Paragraph' ); - - // Open the block settings. - await page.click( 'button[aria-label="Settings"]' ); - - // Wait for the font size picker controls. - await page.waitForSelector( - '.components-font-size-picker__controls' - ); - - // Change the font size. - await page.click( - '.components-font-size-picker__controls button[aria-label="Small"]' - ); - - // Save all changes. - await saveAllChanges(); - - // Change the font size. - await page.click( - '.components-font-size-picker__controls button[aria-label="Medium"]' - ); - - // Assert that the save button has been re-enabled. - const saveButton = await page.waitForSelector( - activeSaveSiteSelector - ); - expect( saveButton ).not.toBeNull(); - } ); - } ); } ); diff --git a/packages/edit-post/src/components/header/header-toolbar/index.native.js b/packages/edit-post/src/components/header/header-toolbar/index.native.js index b9b91c8d4f5585..6c5ae1b932e85a 100644 --- a/packages/edit-post/src/components/header/header-toolbar/index.native.js +++ b/packages/edit-post/src/components/header/header-toolbar/index.native.js @@ -19,10 +19,15 @@ import { import { ToolbarGroup, ToolbarButton } from '@wordpress/components'; import { keyboardClose, + audio as audioIcon, + media as imageIcon, + video as videoIcon, + gallery as galleryIcon, undo as undoIcon, redo as redoIcon, } from '@wordpress/icons'; import { store as editorStore } from '@wordpress/editor'; +import { createBlock } from '@wordpress/blocks'; /** * Internal dependencies @@ -38,10 +43,13 @@ function HeaderToolbar( { showInserter, showKeyboardHideButton, getStylesFromColorScheme, + insertBlock, onHideKeyboard, + onOpenBlockSettings, isRTL, noContentSelected, } ) { + const anchorNodeRef = useRef(); const wasNoContentSelected = useRef( noContentSelected ); const [ isInserterOpen, setIsInserterOpen ] = useState( false ); @@ -55,6 +63,7 @@ function HeaderToolbar( { scrollViewRef.current.scrollTo( { x: 0 } ); } }; + const renderHistoryButtons = () => { const buttons = [ /* TODO: replace with EditorHistoryRedo and EditorHistoryUndo. */ @@ -83,6 +92,60 @@ function HeaderToolbar( { return isRTL ? buttons.reverse() : buttons; }; + const onInsertBlock = useCallback( + ( blockType ) => () => { + insertBlock( createBlock( blockType ), undefined, undefined, true, { + source: 'inserter_menu', + } ); + }, + [ insertBlock ] + ); + + const renderMediaButtons = ( + + + + + + + ); + const onToggleInserter = useCallback( ( isOpen ) => { if ( isOpen ) { @@ -104,6 +167,7 @@ function HeaderToolbar( { return ( + + { noContentSelected && renderMediaButtons } { renderHistoryButtons() } - + { showKeyboardHideButton && ( { - const { clearSelectedBlock } = dispatch( blockEditorStore ); + const { clearSelectedBlock, insertBlock } = + dispatch( blockEditorStore ); + const { openGeneralSidebar } = dispatch( editPostStore ); const { togglePostTitleSelection } = dispatch( editorStore ); return { @@ -191,6 +262,10 @@ export default compose( [ clearSelectedBlock(); togglePostTitleSelection( false ); }, + insertBlock, + onOpenBlockSettings() { + openGeneralSidebar( 'edit-post/block' ); + }, }; } ), withViewportMatch( { isLargeViewport: 'medium' } ), diff --git a/packages/edit-post/src/components/visual-editor/test/__snapshots__/index.native.js.snap b/packages/edit-post/src/components/visual-editor/test/__snapshots__/index.native.js.snap new file mode 100644 index 00000000000000..de7091fa947357 --- /dev/null +++ b/packages/edit-post/src/components/visual-editor/test/__snapshots__/index.native.js.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`when nothing is selected media buttons and picker display correctly 1`] = ` +" +

First example paragraph.

+ + + +

Second example paragraph.

+ + + + +" +`; diff --git a/packages/edit-post/src/components/visual-editor/test/index.native.js b/packages/edit-post/src/components/visual-editor/test/index.native.js index af07e4309ab691..8c6e041880a830 100644 --- a/packages/edit-post/src/components/visual-editor/test/index.native.js +++ b/packages/edit-post/src/components/visual-editor/test/index.native.js @@ -1,11 +1,12 @@ /** * External dependencies */ -import { initializeEditor, fireEvent } from 'test/helpers'; +import { initializeEditor, getEditorHtml, fireEvent } from 'test/helpers'; /** * WordPress dependencies */ +import { Platform } from '@wordpress/element'; import { getBlockTypes, unregisterBlockType } from '@wordpress/blocks'; import { registerCoreBlocks } from '@wordpress/block-library'; @@ -21,6 +22,12 @@ afterAll( () => { } ); } ); +const MEDIA_OPTIONS = [ + 'Choose from device', + 'Take a Photo', + 'WordPress Media Library', +]; + const initialHtml = `

First example paragraph.

@@ -63,6 +70,38 @@ describe( 'when title is focused', () => { screen.getAllByLabelText( /Paragraph Block. Row 3/ )[ 0 ] ).toBeDefined(); } ); + + it( 'media blocks should be displayed', async () => { + const screen = await initializeEditor( { + initialHtml, + } ); + + // Focus first block + fireEvent.press( + screen.getAllByLabelText( /Paragraph Block. Row 1/ )[ 0 ] + ); + + // Focus title + fireEvent( + screen.getAllByLabelText( 'Post title. test' )[ 0 ], + 'select' + ); + + // Assert that the media buttons are visible + const imageButton = await screen.findByTestId( 'insert-image-button' ); + expect( imageButton ).toBeVisible(); + + const videoButton = await screen.findByTestId( 'insert-video-button' ); + expect( videoButton ).toBeVisible(); + + const galleryButton = await screen.findByTestId( + 'insert-gallery-button' + ); + expect( galleryButton ).toBeVisible(); + + const audioButton = await screen.findByTestId( 'insert-audio-button' ); + expect( audioButton ).toBeVisible(); + } ); } ); describe( 'when title is no longer focused', () => { @@ -101,4 +140,82 @@ describe( 'when title is no longer focused', () => { screen.getAllByLabelText( /Heading Block. Row 3/ )[ 0 ] ).toBeDefined(); } ); + + it( 'media blocks should not be displayed', async () => { + const screen = await initializeEditor( { + initialHtml, + } ); + + // Focus first block + fireEvent.press( + screen.getAllByLabelText( /Paragraph Block. Row 1/ )[ 0 ] + ); + + // Focus title + fireEvent( + screen.getAllByLabelText( 'Post title. test' )[ 0 ], + 'select' + ); + + // Focus last block + fireEvent.press( + screen.getAllByLabelText( /Paragraph Block. Row 2/ )[ 0 ] + ); + + // Assert that the media buttons are not visible + const imageButton = screen.queryByTestId( 'insert-image-button' ); + expect( imageButton ).toBeNull(); + + const videoButton = screen.queryByTestId( 'insert-video-button' ); + expect( videoButton ).toBeNull(); + + const galleryButton = screen.queryByTestId( 'insert-gallery-button' ); + expect( galleryButton ).toBeNull(); + + const audioButton = screen.queryByTestId( 'insert-audio-button' ); + expect( audioButton ).toBeNull(); + } ); +} ); + +describe( 'when nothing is selected', () => { + it( 'media buttons and picker display correctly', async () => { + const screen = await initializeEditor( { + initialHtml, + } ); + + const { getByText, getByTestId } = screen; + + // Check that the gallery button is visible within the toolbar + const galleryButton = await screen.queryByTestId( + 'insert-gallery-button' + ); + expect( galleryButton ).toBeVisible(); + + // Press the toolbar Gallery button + fireEvent.press( galleryButton ); + + // Expect the block to be created + expect( + screen.getAllByLabelText( /Gallery Block. Row 3/ )[ 0 ] + ).toBeDefined(); + + expect( getByText( 'Choose images' ) ).toBeVisible(); + MEDIA_OPTIONS.forEach( ( option ) => + expect( getByText( option ) ).toBeVisible() + ); + + // Dismiss the picker + if ( Platform.isIOS ) { + fireEvent.press( getByText( 'Cancel' ) ); + } else { + fireEvent( getByTestId( 'media-options-picker' ), 'backdropPress' ); + } + + // Expect the Gallery block to remain + expect( + screen.getAllByLabelText( /Gallery Block. Row 3/ )[ 0 ] + ).toBeDefined(); + + expect( getEditorHtml() ).toMatchSnapshot(); + } ); } ); diff --git a/packages/edit-post/src/plugins/index.js b/packages/edit-post/src/plugins/index.js index e3bd1b2dd72bda..1cd03debbf7a75 100644 --- a/packages/edit-post/src/plugins/index.js +++ b/packages/edit-post/src/plugins/index.js @@ -2,6 +2,9 @@ * WordPress dependencies */ import { MenuItem, VisuallyHidden } from '@wordpress/components'; +import { store as coreStore } from '@wordpress/core-data'; +import { store as editorStore } from '@wordpress/editor'; +import { useSelect } from '@wordpress/data'; import { external } from '@wordpress/icons'; import { __ } from '@wordpress/i18n'; import { registerPlugin } from '@wordpress/plugins'; @@ -15,6 +18,34 @@ import KeyboardShortcutsHelpMenuItem from './keyboard-shortcuts-help-menu-item'; import ToolsMoreMenuGroup from '../components/header/tools-more-menu-group'; import WelcomeGuideMenuItem from './welcome-guide-menu-item'; +function ManagePatternsMenuItem() { + const url = useSelect( ( select ) => { + const { canUser } = select( coreStore ); + const { getEditorSettings } = select( editorStore ); + + const isBlockTheme = getEditorSettings().__unstableIsBlockBasedTheme; + const defaultUrl = addQueryArgs( 'edit.php', { + post_type: 'wp_block', + } ); + const patternsUrl = addQueryArgs( 'site-editor.php', { + path: '/patterns', + } ); + + // The site editor and templates both check whether the user has + // edit_theme_options capabilities. We can leverage that here and not + // display the manage patterns link if the user can't access it. + return canUser( 'read', 'templates' ) && isBlockTheme + ? patternsUrl + : defaultUrl; + }, [] ); + + return ( + + { __( 'Manage Patterns' ) } + + ); +} + registerPlugin( 'edit-post', { render() { return ( @@ -22,14 +53,7 @@ registerPlugin( 'edit-post', { { ( { onClose } ) => ( <> - - { __( 'Manage Patterns' ) } - + diff --git a/packages/edit-site/src/components/canvas-spinner/style.scss b/packages/edit-site/src/components/canvas-spinner/style.scss index 2f0626b80363fe..22b1b856257424 100644 --- a/packages/edit-site/src/components/canvas-spinner/style.scss +++ b/packages/edit-site/src/components/canvas-spinner/style.scss @@ -2,10 +2,24 @@ width: 100%; height: 100%; display: flex; + opacity: 0; align-items: center; justify-content: center; + animation: 0.5s ease 1s edit-site-canvas-spinner__fade-in-animation; + animation-fill-mode: forwards; + @include reduce-motion("animation"); + circle { stroke: rgba($black, 0.3); } } + +@keyframes edit-site-canvas-spinner__fade-in-animation { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/packages/edit-site/src/components/create-pattern-modal/index.js b/packages/edit-site/src/components/create-pattern-modal/index.js index 46d734b86fdd19..753dccfb961dd2 100644 --- a/packages/edit-site/src/components/create-pattern-modal/index.js +++ b/packages/edit-site/src/components/create-pattern-modal/index.js @@ -14,6 +14,7 @@ import { __ } from '@wordpress/i18n'; import { useState } from '@wordpress/element'; import { store as noticesStore } from '@wordpress/notices'; import { useDispatch } from '@wordpress/data'; +import { serialize } from '@wordpress/blocks'; /** * Internal dependencies @@ -21,9 +22,11 @@ import { useDispatch } from '@wordpress/data'; import { SYNC_TYPES, USER_PATTERN_CATEGORY } from '../page-patterns/utils'; export default function CreatePatternModal( { + blocks = [], closeModal, onCreate, onError, + title, } ) { const [ name, setName ] = useState( '' ); const [ syncType, setSyncType ] = useState( SYNC_TYPES.unsynced ); @@ -52,7 +55,7 @@ export default function CreatePatternModal( { 'wp_block', { title: name || __( 'Untitled Pattern' ), - content: '', + content: blocks?.length ? serialize( blocks ) : '', status: 'publish', meta: syncType === SYNC_TYPES.unsynced @@ -76,7 +79,7 @@ export default function CreatePatternModal( { return ( diff --git a/packages/edit-site/src/components/page-content-focus-manager/constants.js b/packages/edit-site/src/components/page-content-focus-manager/constants.js deleted file mode 100644 index a81b2fd37563af..00000000000000 --- a/packages/edit-site/src/components/page-content-focus-manager/constants.js +++ /dev/null @@ -1,5 +0,0 @@ -export const PAGE_CONTENT_BLOCK_TYPES = [ - 'core/post-title', - 'core/post-featured-image', - 'core/post-content', -]; diff --git a/packages/edit-site/src/components/page-content-focus-manager/disable-non-page-content-blocks.js b/packages/edit-site/src/components/page-content-focus-manager/disable-non-page-content-blocks.js index 33ea486863d203..7b184ff253c7e6 100644 --- a/packages/edit-site/src/components/page-content-focus-manager/disable-non-page-content-blocks.js +++ b/packages/edit-site/src/components/page-content-focus-manager/disable-non-page-content-blocks.js @@ -10,16 +10,22 @@ import { useEffect } from '@wordpress/element'; * Internal dependencies */ import { unlock } from '../../lock-unlock'; -import { PAGE_CONTENT_BLOCK_TYPES } from './constants'; const { useBlockEditingMode } = unlock( blockEditorPrivateApis ); +const PAGE_CONTENT_BLOCK_TYPES = [ + 'core/post-title', + 'core/post-featured-image', + 'core/post-content', +]; + /** * Component that when rendered, makes it so that the site editor allows only * page content to be edited. */ export default function DisableNonPageContentBlocks() { useDisableNonPageContentBlocks(); + return null; } /** @@ -43,8 +49,11 @@ export function useDisableNonPageContentBlocks() { const withDisableNonPageContentBlocks = createHigherOrderComponent( ( BlockEdit ) => ( props ) => { - const isContent = PAGE_CONTENT_BLOCK_TYPES.includes( props.name ); - const mode = isContent ? 'contentOnly' : undefined; + const isDescendentOfQueryLoop = !! props.context.queryId; + const isPageContent = + PAGE_CONTENT_BLOCK_TYPES.includes( props.name ) && + ! isDescendentOfQueryLoop; + const mode = isPageContent ? 'contentOnly' : undefined; useBlockEditingMode( mode ); return ; }, diff --git a/packages/edit-site/src/components/page-patterns/duplicate-menu-item.js b/packages/edit-site/src/components/page-patterns/duplicate-menu-item.js new file mode 100644 index 00000000000000..d2c14d15f341b0 --- /dev/null +++ b/packages/edit-site/src/components/page-patterns/duplicate-menu-item.js @@ -0,0 +1,196 @@ +/** + * WordPress dependencies + */ +import { MenuItem } from '@wordpress/components'; +import { store as coreStore } from '@wordpress/core-data'; +import { useDispatch } from '@wordpress/data'; +import { __, sprintf } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; + +/** + * Internal dependencies + */ +import { + TEMPLATE_PARTS, + PATTERNS, + SYNC_TYPES, + USER_PATTERNS, + USER_PATTERN_CATEGORY, +} from './utils'; +import { + useExistingTemplateParts, + getUniqueTemplatePartTitle, + getCleanTemplatePartSlug, +} from '../../utils/template-part-create'; +import { unlock } from '../../lock-unlock'; + +const { useHistory } = unlock( routerPrivateApis ); + +function getPatternMeta( item ) { + if ( item.type === PATTERNS ) { + return { wp_pattern_sync_status: SYNC_TYPES.unsynced }; + } + + const syncStatus = item.reusableBlock.wp_pattern_sync_status; + const isUnsynced = syncStatus === SYNC_TYPES.unsynced; + + return { + ...item.reusableBlock.meta, + wp_pattern_sync_status: isUnsynced ? syncStatus : undefined, + }; +} + +export default function DuplicateMenuItem( { + categoryId, + item, + label = __( 'Duplicate' ), + onClose, +} ) { + const { saveEntityRecord } = useDispatch( coreStore ); + const { createErrorNotice, createSuccessNotice } = + useDispatch( noticesStore ); + + const history = useHistory(); + const existingTemplateParts = useExistingTemplateParts(); + + async function createTemplatePart() { + try { + const copiedTitle = sprintf( + /* translators: %s: Existing template part title */ + __( '%s (Copy)' ), + item.title + ); + const title = getUniqueTemplatePartTitle( + copiedTitle, + existingTemplateParts + ); + const slug = getCleanTemplatePartSlug( title ); + const { area, content } = item.templatePart; + + const result = await saveEntityRecord( + 'postType', + 'wp_template_part', + { slug, title, content, area }, + { throwOnError: true } + ); + + createSuccessNotice( + sprintf( + // translators: %s: The new template part's title e.g. 'Call to action (copy)'. + __( '"%s" created.' ), + title + ), + { + type: 'snackbar', + id: 'edit-site-patterns-success', + actions: [ + { + label: __( 'Edit' ), + onClick: () => + history.push( { + postType: TEMPLATE_PARTS, + postId: result?.id, + categoryType: TEMPLATE_PARTS, + categoryId, + } ), + }, + ], + } + ); + + onClose(); + } catch ( error ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( + 'An error occurred while creating the template part.' + ); + + createErrorNotice( errorMessage, { + type: 'snackbar', + id: 'edit-site-patterns-error', + } ); + onClose(); + } + } + + async function createPattern() { + try { + const isThemePattern = item.type === PATTERNS; + const title = sprintf( + /* translators: %s: Existing pattern title */ + __( '%s (Copy)' ), + item.title + ); + + const result = await saveEntityRecord( + 'postType', + 'wp_block', + { + content: isThemePattern + ? item.content + : item.reusableBlock.content, + meta: getPatternMeta( item ), + status: 'publish', + title, + }, + { throwOnError: true } + ); + + const actionLabel = isThemePattern + ? __( 'View my patterns' ) + : __( 'Edit' ); + + const newLocation = isThemePattern + ? { + categoryType: USER_PATTERNS, + categoryId: USER_PATTERN_CATEGORY, + path: '/patterns', + } + : { + categoryType: USER_PATTERNS, + categoryId: USER_PATTERN_CATEGORY, + postType: USER_PATTERNS, + postId: result?.id, + }; + + createSuccessNotice( + sprintf( + // translators: %s: The new pattern's title e.g. 'Call to action (copy)'. + __( '"%s" added to my patterns.' ), + title + ), + { + type: 'snackbar', + id: 'edit-site-patterns-success', + actions: [ + { + label: actionLabel, + onClick: () => history.push( newLocation ), + }, + ], + } + ); + + onClose(); + } catch ( error ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( 'An error occurred while creating the pattern.' ); + + createErrorNotice( errorMessage, { + type: 'snackbar', + id: 'edit-site-patterns-error', + } ); + onClose(); + } + } + + const createItem = + item.type === TEMPLATE_PARTS ? createTemplatePart : createPattern; + + return { label }; +} diff --git a/packages/edit-site/src/components/page-patterns/grid-item.js b/packages/edit-site/src/components/page-patterns/grid-item.js index 8795e41eedd4f3..7f40fbce9035cf 100644 --- a/packages/edit-site/src/components/page-patterns/grid-item.js +++ b/packages/edit-site/src/components/page-patterns/grid-item.js @@ -24,7 +24,7 @@ import { Icon, header, footer, - symbolFilled, + symbolFilled as uncategorized, moreHorizontal, lockSmall, } from '@wordpress/icons'; @@ -35,23 +35,31 @@ import { DELETE, BACKSPACE } from '@wordpress/keycodes'; /** * Internal dependencies */ -import { PATTERNS, USER_PATTERNS } from './utils'; +import RenameMenuItem from './rename-menu-item'; +import DuplicateMenuItem from './duplicate-menu-item'; +import { PATTERNS, TEMPLATE_PARTS, USER_PATTERNS } from './utils'; +import { store as editSiteStore } from '../../store'; import { useLink } from '../routes/link'; -const THEME_PATTERN_TOOLTIP = __( 'Theme patterns cannot be edited.' ); +const templatePartIcons = { header, footer, uncategorized }; export default function GridItem( { categoryId, composite, icon, item } ) { const descriptionId = useId(); const [ isDeleteDialogOpen, setIsDeleteDialogOpen ] = useState( false ); + const { removeTemplate } = useDispatch( editSiteStore ); const { __experimentalDeleteReusableBlock } = useDispatch( reusableBlocksStore ); const { createErrorNotice, createSuccessNotice } = useDispatch( noticesStore ); + const isUserPattern = item.type === USER_PATTERNS; + const isNonUserPattern = item.type === PATTERNS; + const isTemplatePart = item.type === TEMPLATE_PARTS; + const { onClick } = useLink( { postType: item.type, - postId: item.type === USER_PATTERNS ? item.id : item.name, + postId: isUserPattern ? item.id : item.name, categoryId, categoryType: item.type, } ); @@ -67,27 +75,41 @@ export default function GridItem( { categoryId, composite, icon, item } ) { 'is-placeholder': isEmpty, } ); const previewClassNames = classnames( 'edit-site-patterns__preview', { - 'is-inactive': item.type === PATTERNS, + 'is-inactive': isNonUserPattern, } ); const deletePattern = async () => { try { await __experimentalDeleteReusableBlock( item.id ); - createSuccessNotice( __( 'Pattern successfully deleted.' ), { - type: 'snackbar', - } ); + createSuccessNotice( + sprintf( + // translators: %s: The pattern's title e.g. 'Call to action'. + __( '"%s" deleted.' ), + item.title + ), + { type: 'snackbar', id: 'edit-site-patterns-success' } + ); } catch ( error ) { const errorMessage = error.message && error.code !== 'unknown_error' ? error.message : __( 'An error occurred while deleting the pattern.' ); - createErrorNotice( errorMessage, { type: 'snackbar' } ); + createErrorNotice( errorMessage, { + type: 'snackbar', + id: 'edit-site-patterns-error', + } ); } }; + const deleteItem = () => + isTemplatePart ? removeTemplate( item ) : deletePattern(); - const isUserPattern = item.type === USER_PATTERNS; + // Only custom patterns or custom template parts can be renamed or deleted. + const isCustomPattern = + isUserPattern || ( isTemplatePart && item.isCustom ); + const hasThemeFile = isTemplatePart && item.templatePart.has_theme_file; const ariaDescriptions = []; - if ( isUserPattern ) { + + if ( isCustomPattern ) { // User patterns don't have descriptions, but can be edited and deleted, so include some help text. ariaDescriptions.push( __( 'Press Enter to edit, or Delete to delete the pattern.' ) @@ -95,19 +117,24 @@ export default function GridItem( { categoryId, composite, icon, item } ) { } else if ( item.description ) { ariaDescriptions.push( item.description ); } - if ( item.type === PATTERNS ) { - ariaDescriptions.push( THEME_PATTERN_TOOLTIP ); - } - let itemIcon = icon; - if ( categoryId === 'header' ) { - itemIcon = header; - } else if ( categoryId === 'footer' ) { - itemIcon = footer; - } else if ( categoryId === 'uncategorized' ) { - itemIcon = symbolFilled; + if ( isNonUserPattern ) { + ariaDescriptions.push( __( 'Theme patterns cannot be edited.' ) ); } + const itemIcon = templatePartIcons[ categoryId ] + ? templatePartIcons[ categoryId ] + : icon; + + const confirmButtonText = hasThemeFile ? __( 'Clear' ) : __( 'Delete' ); + const confirmPrompt = hasThemeFile + ? __( 'Are you sure you want to clear these customizations?' ) + : sprintf( + // translators: %s: The pattern or template part's title e.g. 'Call to action'. + __( 'Are you sure you want to delete "%s"?' ), + item.title + ); + return ( <>
@@ -117,7 +144,7 @@ export default function GridItem( { categoryId, composite, icon, item } ) { as="div" { ...composite } onClick={ item.type !== PATTERNS ? onClick : undefined } - onKeyDown={ isUserPattern ? onKeyDown : undefined } + onKeyDown={ isCustomPattern ? onKeyDown : undefined } aria-label={ item.title } aria-describedby={ ariaDescriptions.length @@ -169,58 +196,73 @@ export default function GridItem( { categoryId, composite, icon, item } ) { ) } > - + ) } - { item.type === USER_PATTERNS && ( - - { () => ( - + + { ( { onClose } ) => ( + + { isCustomPattern && ! hasThemeFile && ( + + ) } + + { isCustomPattern && ( setIsDeleteDialogOpen( true ) } > - { __( 'Delete' ) } + { hasThemeFile + ? __( 'Clear customizations' ) + : __( 'Delete' ) } - - ) } - - ) } + ) } + + ) } +
{ isDeleteDialogOpen && ( setIsDeleteDialogOpen( false ) } > - { __( 'Are you sure you want to delete this pattern?' ) } + { confirmPrompt } ) } diff --git a/packages/edit-site/src/components/page-patterns/rename-menu-item.js b/packages/edit-site/src/components/page-patterns/rename-menu-item.js new file mode 100644 index 00000000000000..938023a62cefd3 --- /dev/null +++ b/packages/edit-site/src/components/page-patterns/rename-menu-item.js @@ -0,0 +1,115 @@ +/** + * WordPress dependencies + */ +import { + Button, + MenuItem, + Modal, + TextControl, + __experimentalHStack as HStack, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { store as coreStore } from '@wordpress/core-data'; +import { useDispatch } from '@wordpress/data'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import { TEMPLATE_PARTS } from './utils'; + +export default function RenameMenuItem( { item, onClose } ) { + const [ title, setTitle ] = useState( () => item.title ); + const [ isModalOpen, setIsModalOpen ] = useState( false ); + + const { editEntityRecord, saveEditedEntityRecord } = + useDispatch( coreStore ); + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + + if ( item.type === TEMPLATE_PARTS && ! item.isCustom ) { + return null; + } + + async function onRename( event ) { + event.preventDefault(); + + try { + await editEntityRecord( 'postType', item.type, item.id, { title } ); + + // Update state before saving rerenders the list. + setTitle( '' ); + setIsModalOpen( false ); + onClose(); + + // Persist edited entity. + await saveEditedEntityRecord( 'postType', item.type, item.id, { + throwOnError: true, + } ); + + createSuccessNotice( __( 'Entity renamed.' ), { + type: 'snackbar', + } ); + } catch ( error ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( 'An error occurred while renaming the entity.' ); + + createErrorNotice( errorMessage, { type: 'snackbar' } ); + } + } + + return ( + <> + { + setIsModalOpen( true ); + setTitle( item.title ); + } } + > + { __( 'Rename' ) } + + { isModalOpen && ( + { + setIsModalOpen( false ); + onClose(); + } } + overlayClassName="edit-site-list__rename_modal" + > +
+ + + + + + + + + +
+
+ ) } + + ); +} diff --git a/packages/edit-site/src/components/page-patterns/style.scss b/packages/edit-site/src/components/page-patterns/style.scss index 9326a966123198..7a7bf026b9c625 100644 --- a/packages/edit-site/src/components/page-patterns/style.scss +++ b/packages/edit-site/src/components/page-patterns/style.scss @@ -101,6 +101,10 @@ .edit-site-patterns__pattern-lock-icon { display: inline-flex; + + svg { + fill: currentcolor; + } } } diff --git a/packages/edit-site/src/components/page-patterns/use-patterns.js b/packages/edit-site/src/components/page-patterns/use-patterns.js index cef7b4721193f4..295d1eee8e410f 100644 --- a/packages/edit-site/src/components/page-patterns/use-patterns.js +++ b/packages/edit-site/src/components/page-patterns/use-patterns.js @@ -31,21 +31,17 @@ const templatePartToPattern = ( templatePart ) => ( { blocks: parse( templatePart.content.raw ), categories: [ templatePart.area ], description: templatePart.description || '', + isCustom: templatePart.source === 'custom', keywords: templatePart.keywords || [], + id: createTemplatePartId( templatePart.theme, templatePart.slug ), name: createTemplatePartId( templatePart.theme, templatePart.slug ), title: templatePart.title.rendered, type: templatePart.type, templatePart, } ); -const templatePartCategories = [ 'header', 'footer', 'sidebar' ]; -const templatePartHasCategory = ( item, category ) => { - if ( category === 'uncategorized' ) { - return ! templatePartCategories.includes( item.templatePart.area ); - } - - return item.templatePart.area === category; -}; +const templatePartHasCategory = ( item, category ) => + item.templatePart.area === category; const useTemplatePartsAsPatterns = ( categoryId, diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-content.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-content.js index d6e7dd23a709fa..dd40bcaef9f707 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-content.js +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-content.js @@ -6,22 +6,24 @@ import { store as blockEditorStore, privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; +import { useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import { PAGE_CONTENT_BLOCK_TYPES } from '../../page-content-focus-manager/constants'; import { unlock } from '../../../lock-unlock'; const { BlockQuickNavigation } = unlock( blockEditorPrivateApis ); export default function PageContent() { - const clientIds = useSelect( + const clientIdsTree = useSelect( ( select ) => - select( blockEditorStore ).__experimentalGetGlobalBlocksByName( - PAGE_CONTENT_BLOCK_TYPES - ), + unlock( select( blockEditorStore ) ).getEnabledClientIdsTree(), [] ); + const clientIds = useMemo( + () => clientIdsTree.map( ( { clientId } ) => clientId ), + [ clientIdsTree ] + ); return ; } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-main/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-main/index.js index 58b93d61c45a65..152139870fa59f 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-main/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-main/index.js @@ -20,6 +20,7 @@ import SidebarNavigationItem from '../sidebar-navigation-item'; import { SidebarNavigationItemGlobalStyles } from '../sidebar-navigation-screen-global-styles'; import { unlock } from '../../lock-unlock'; import { store as editSiteStore } from '../../store'; +import TemplatePartHint from './template-part-hint'; export default function SidebarNavigationScreenMain() { const { location } = useNavigator(); @@ -42,46 +43,49 @@ export default function SidebarNavigationScreenMain() { 'Customize the appearance of your website using the block editor.' ) } content={ - - - { __( 'Navigation' ) } - - - { __( 'Styles' ) } - - - { __( 'Pages' ) } - - - { __( 'Templates' ) } - - - { __( 'Patterns' ) } - - + <> + + + { __( 'Navigation' ) } + + + { __( 'Styles' ) } + + + { __( 'Pages' ) } + + + { __( 'Templates' ) } + + + { __( 'Patterns' ) } + + + + } /> ); diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-main/template-part-hint.js b/packages/edit-site/src/components/sidebar-navigation-screen-main/template-part-hint.js new file mode 100644 index 00000000000000..8fbe74f81bb4d9 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-main/template-part-hint.js @@ -0,0 +1,36 @@ +/** + * WordPress dependencies + */ +import { Notice } from '@wordpress/components'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { store as preferencesStore } from '@wordpress/preferences'; + +const PREFERENCE_NAME = 'isTemplatePartMoveHintVisible'; + +export default function TemplatePartHint() { + const showTemplatePartHint = useSelect( + ( select ) => + select( preferencesStore ).get( 'core', PREFERENCE_NAME ) ?? true, + [] + ); + + const { set: setPreference } = useDispatch( preferencesStore ); + if ( ! showTemplatePartHint ) { + return null; + } + + return ( + { + setPreference( 'core', PREFERENCE_NAME, false ); + } } + > + { __( + 'Looking for template parts? You can now find them in the new "Patterns" page.' + ) } + + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/index.js index b28aa6687723b7..f200382f963113 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/index.js @@ -7,6 +7,7 @@ import { Flex, Icon, Tooltip, + __experimentalHeading as Heading, } from '@wordpress/components'; import { useViewportMatch } from '@wordpress/compose'; import { useSelect } from '@wordpress/data'; @@ -29,12 +30,79 @@ import usePatternCategories from './use-pattern-categories'; import useMyPatterns from './use-my-patterns'; import useTemplatePartAreas from './use-template-part-areas'; -const templatePartAreaLabels = { - header: __( 'Headers' ), - footer: __( 'Footers' ), - sidebar: __( 'Sidebar' ), - uncategorized: __( 'Uncategorized' ), -}; +function TemplatePartGroup( { areas, currentArea, currentType } ) { + return ( + <> +
+ { __( 'Template parts' ) } +

{ __( 'Synced patterns for use in template building.' ) }

+
+ + { Object.entries( areas ).map( + ( [ area, { label, templateParts } ] ) => ( + + ) + ) } + + + ); +} + +function ThemePatternsGroup( { categories, currentCategory, currentType } ) { + return ( + <> +
+ { __( 'Theme patterns' ) } +

+ { __( + 'For insertion into documents where they can then be customized.' + ) } +

+
+ + { categories.map( ( category ) => ( + + { category.label } + + + + + + + } + icon={ file } + id={ category.name } + type="pattern" + isActive={ + currentCategory === `${ category.name }` && + currentType === 'pattern' + } + /> + ) ) } + + + ); +} export default function SidebarNavigationScreenPatterns() { const isMobileViewport = useViewportMatch( 'medium', '<' ); @@ -109,76 +177,18 @@ export default function SidebarNavigationScreenPatterns() { ) } { hasTemplateParts && ( - - { Object.entries( templatePartAreas ).map( - ( [ area, parts ] ) => ( - - ) - ) } - + ) } { hasPatterns && ( - - { patternCategories.map( ( category ) => ( - - { category.label } - - - - - - - } - icon={ file } - id={ category.name } - type="pattern" - isActive={ - currentCategory === - `${ category.name }` && - currentType === 'pattern' - } - /> - ) ) } - + ) } ) } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/style.scss index f0edb96164abca..65790b5e862162 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/style.scss +++ b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/style.scss @@ -1,3 +1,28 @@ .edit-site-sidebar-navigation-screen-patterns__group { - margin-bottom: $grid-unit-30; + margin-bottom: $grid-unit-40; + padding-bottom: $grid-unit-30; + border-bottom: 1px solid $gray-800; + + &:last-of-type, + &:first-of-type { + border-bottom: 0; + padding-bottom: 0; + margin-bottom: 0; + } + + &:first-of-type { + margin-bottom: $grid-unit-40; + } +} + +.edit-site-sidebar-navigation-screen-patterns__group-header { + p { + color: $gray-600; + } + + h2 { + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + } } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-template-part-areas.js b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-template-part-areas.js index aa258344d132da..bc538c5e7a85fa 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-template-part-areas.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-template-part-areas.js @@ -2,19 +2,41 @@ * WordPress dependencies */ import { useEntityRecords } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; -const getTemplatePartAreas = ( items ) => { +const useTemplatePartsGroupedByArea = ( items ) => { const allItems = items || []; - const groupedByArea = allItems.reduce( - ( accumulator, item ) => { - const key = accumulator[ item.area ] ? item.area : 'uncategorized'; - accumulator[ key ].push( item ); - return accumulator; - }, - { header: [], footer: [], sidebar: [], uncategorized: [] } + const templatePartAreas = useSelect( + ( select ) => + select( editorStore ).__experimentalGetDefaultTemplatePartAreas(), + [] ); + // Create map of template areas ensuring that default areas are displayed before + // any custom registered template part areas. + const knownAreas = { + header: {}, + footer: {}, + sidebar: {}, + uncategorized: {}, + }; + + templatePartAreas.forEach( + ( templatePartArea ) => + ( knownAreas[ templatePartArea.area ] = { + ...templatePartArea, + templateParts: [], + } ) + ); + + const groupedByArea = allItems.reduce( ( accumulator, item ) => { + const key = accumulator[ item.area ] ? item.area : 'uncategorized'; + accumulator[ key ].templateParts.push( item ); + return accumulator; + }, knownAreas ); + return groupedByArea; }; @@ -28,6 +50,6 @@ export default function useTemplatePartAreas() { return { hasTemplateParts: templateParts ? !! templateParts.length : false, isLoading, - templatePartAreas: getTemplatePartAreas( templateParts ), + templatePartAreas: useTemplatePartsGroupedByArea( templateParts ), }; } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen/style.scss index 1fecf49d712215..26a30da286fc84 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen/style.scss +++ b/packages/edit-site/src/components/sidebar-navigation-screen/style.scss @@ -98,6 +98,20 @@ border-top: 1px solid $gray-800; } +.edit-site-sidebar__notice { + background: $gray-800; + color: $gray-300; + margin: $grid-unit-30 0; + &.is-dismissible { + padding-right: $grid-unit-10; + } + .components-notice__dismiss:not(:disabled):not([aria-disabled="true"]):focus, + .components-notice__dismiss:not(:disabled):not([aria-disabled="true"]):not(.is-secondary):active, + .components-notice__dismiss:not(:disabled):not([aria-disabled="true"]):not(.is-secondary):hover { + color: $gray-100; + } +} + /* In general style overrides are discouraged. * This is a temporary solution to override the InputControl component's styles. * The `Theme` component will potentially be the more appropriate approach diff --git a/packages/edit-site/src/hooks/push-changes-to-global-styles/index.js b/packages/edit-site/src/hooks/push-changes-to-global-styles/index.js index d82f3a86847da9..ab2487e76f1910 100644 --- a/packages/edit-site/src/hooks/push-changes-to-global-styles/index.js +++ b/packages/edit-site/src/hooks/push-changes-to-global-styles/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { get, set } from 'lodash'; +import { get } from 'lodash'; /** * WordPress dependencies @@ -119,6 +119,46 @@ function useChangesToPush( name, attributes ) { ); } +/** + * Sets the value at path of object. + * If a portion of path doesn’t exist, it’s created. + * Arrays are created for missing index properties while objects are created + * for all other missing properties. + * + * This function intentionally mutates the input object. + * + * Inspired by _.set(). + * + * @see https://lodash.com/docs/4.17.15#set + * + * @todo Needs to be deduplicated with its copy in `@wordpress/core-data`. + * + * @param {Object} object Object to modify + * @param {Array} path Path of the property to set. + * @param {*} value Value to set. + */ +function setNestedValue( object, path, value ) { + if ( ! object || typeof object !== 'object' ) { + return object; + } + + path.reduce( ( acc, key, idx ) => { + if ( acc[ key ] === undefined ) { + if ( Number.isInteger( path[ idx + 1 ] ) ) { + acc[ key ] = []; + } else { + acc[ key ] = {}; + } + } + if ( idx === path.length - 1 ) { + acc[ key ] = value; + } + return acc[ key ]; + }, object ); + + return object; +} + function cloneDeep( object ) { return ! object ? {} : JSON.parse( JSON.stringify( object ) ); } @@ -148,8 +188,12 @@ function PushChangesToGlobalStylesControl( { const newUserConfig = cloneDeep( userConfig ); for ( const { path, value } of changes ) { - set( newBlockStyles, path, undefined ); - set( newUserConfig, [ 'styles', 'blocks', name, ...path ], value ); + setNestedValue( newBlockStyles, path, undefined ); + setNestedValue( + newUserConfig, + [ 'styles', 'blocks', name, ...path ], + value + ); } // @wordpress/core-data doesn't support editing multiple entity types in diff --git a/packages/react-native-aztec/package.json b/packages/react-native-aztec/package.json index 371c890feae125..3087d3708a7047 100644 --- a/packages/react-native-aztec/package.json +++ b/packages/react-native-aztec/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-native-aztec", - "version": "1.98.1", + "version": "1.99.0", "description": "Aztec view for react-native.", "private": true, "author": "The WordPress Contributors", diff --git a/packages/react-native-bridge/package.json b/packages/react-native-bridge/package.json index 1e4039e8135e1d..8c07de0e29871d 100644 --- a/packages/react-native-bridge/package.json +++ b/packages/react-native-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-native-bridge", - "version": "1.98.1", + "version": "1.99.0", "description": "Native bridge library used to integrate the block editor into a native App.", "private": true, "author": "The WordPress Contributors", diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index d43144db54722a..5a1dc4d533a686 100644 --- a/packages/react-native-editor/CHANGELOG.md +++ b/packages/react-native-editor/CHANGELOG.md @@ -10,8 +10,11 @@ For each user feature we should also add a importance categorization label to i --> ## Unreleased + +## 1.99.0 - [*] Rename "Reusable blocks" to "Synced patterns", aligning with the web editor. [#51704] - [**] Fix a crash related to Reanimated when closing the editor [#52320] +- [**] Add media inserter buttons to editor toolbar [#51827] ## 1.98.1 - [*] fix: Display heading level dropdown icons and labels [#52004] diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-block-insertion-@canary.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-block-insertion-@canary.test.js index b413f3b6a42cea..05f7c6bfcd0777 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-block-insertion-@canary.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-block-insertion-@canary.test.js @@ -104,7 +104,7 @@ describe( 'Gutenberg Editor Slash Inserter tests', () => { ); expect( await editorPage.assertSlashInserterPresent() ).toBe( true ); - await editorPage.removeBlockAtPosition( blockNames.paragraph ); + await editorPage.removeBlock(); } ); it( 'should hide the menu after deleting the / character', async () => { @@ -139,7 +139,7 @@ describe( 'Gutenberg Editor Slash Inserter tests', () => { // Check if the slash inserter UI no longer exists. expect( await editorPage.assertSlashInserterPresent() ).toBe( false ); - await editorPage.removeBlockAtPosition( blockNames.paragraph ); + await editorPage.removeBlock(); } ); it( 'should add an Image block after tying /image and tapping on the Image block button', async () => { @@ -172,7 +172,7 @@ describe( 'Gutenberg Editor Slash Inserter tests', () => { expect( await editorPage.assertSlashInserterPresent() ).toBe( false ); // Remove image block. - await editorPage.removeBlockAtPosition( blockNames.image ); + await editorPage.removeBlock(); } ); it( 'should insert an embed image block with "/img" + enter', async () => { @@ -190,6 +190,6 @@ describe( 'Gutenberg Editor Slash Inserter tests', () => { await editorPage.hasBlockAtPosition( 1, blockNames.embed ) ).toBe( true ); - await editorPage.removeBlockAtPosition( blockNames.embed ); + await editorPage.removeBlock(); } ); } ); diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-device-actions.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-device-actions.test.js index 3a64ca37508980..e5e7b5c829f8e9 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-device-actions.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-device-actions.test.js @@ -108,8 +108,8 @@ describe( 'Gutenberg Editor Paste tests', () => { const text = await editorPage.getTextForParagraphBlockAtPosition( 2 ); expect( text ).toBe( testData.pastePlainText ); - await editorPage.removeBlockAtPosition( blockNames.paragraph, 2 ); - await editorPage.removeBlockAtPosition( blockNames.paragraph, 1 ); + await editorPage.removeBlock(); + await editorPage.removeBlock(); } ); it.skip( 'copies styled text from one paragraph block and pastes in another', async () => { diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-media-blocks-@canary.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-media-blocks-@canary.test.js index 6fd68a7a4aff34..e17e39bd8357f9 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-media-blocks-@canary.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-media-blocks-@canary.test.js @@ -134,7 +134,7 @@ onlyOniOS( 'Gutenberg Editor Cover Block test', () => { // Navigate upwards to select parent block await editorPage.moveBlockSelectionUp(); - await editorPage.removeBlockAtPosition( blockNames.cover ); + await editorPage.removeBlock(); } ); // Testing this for iOS on a device is valuable to ensure that it properly @@ -165,6 +165,6 @@ onlyOniOS( 'Gutenberg Editor Cover Block test', () => { await editorPage.chooseMediaLibrary(); expect( coverBlock ).toBeTruthy(); - await editorPage.removeBlockAtPosition( blockNames.cover ); + await editorPage.removeBlock(); } ); } ); diff --git a/packages/react-native-editor/__device-tests__/pages/editor-page.js b/packages/react-native-editor/__device-tests__/pages/editor-page.js index 3b06187482a6f4..f03dae92c4174c 100644 --- a/packages/react-native-editor/__device-tests__/pages/editor-page.js +++ b/packages/react-native-editor/__device-tests__/pages/editor-page.js @@ -341,6 +341,46 @@ class EditorPage { } } + async swipeToolbarToElement( elementSelector, options ) { + const { byId, swipeRight } = options || {}; + const offset = isAndroid() ? 300 : 50; + const maxLocatorAttempts = 5; + let locatorAttempts = 0; + let element; + + const toolbar = await this.getToolbar(); + const toolbarLocation = await toolbar.getLocation(); + const toolbarSize = await toolbar.getSize(); + + while ( locatorAttempts < maxLocatorAttempts ) { + element = byId + ? await this.driver.elementsByAccessibilityId( elementSelector ) + : await this.driver.elementsByXPath( elementSelector ); + if ( await element[ 0 ]?.isDisplayed() ) { + break; + } + + swipeFromTo( + this.driver, + { + x: ! swipeRight + ? toolbarSize.width - offset + : toolbarSize.width / 2, + y: toolbarLocation.y + toolbarSize.height / 2, + }, + { + x: ! swipeRight + ? toolbarSize.width / 2 + : toolbarSize.width - offset, + y: toolbarLocation.y + toolbarSize.height / 2, + }, + 1000 + ); + locatorAttempts++; + } + return element; + } + async openBlockSettings() { const settingsButtonElement = isAndroid() ? '//android.widget.Button[@content-desc="Open Settings"]/android.view.ViewGroup' @@ -356,10 +396,10 @@ class EditorPage { const blockActionsButtonElement = isAndroid() ? '//android.widget.Button[contains(@content-desc, "Open Block Actions Menu")]' : '//XCUIElementTypeButton[@name="Open Block Actions Menu"]'; - const blockActionsMenu = await this.waitForElementToBeDisplayedByXPath( + const blockActionsMenu = await this.swipeToolbarToElement( blockActionsButtonElement ); - await blockActionsMenu.click(); + await blockActionsMenu[ 0 ].click(); const removeElement = 'Remove block'; const removeBlockButton = await this.waitForElementToBeDisplayedById( @@ -378,13 +418,16 @@ class EditorPage { // ========================= async getToolbar() { - return await this.driver.elementsByAccessibilityId( 'Document tools' ); + return this.waitForElementToBeDisplayedById( 'Document tools', 4000 ); } async addNewBlock( blockName, { skipInserterOpen = false } = {} ) { if ( ! skipInserterOpen ) { - const addButton = await this.getAddBlockButton(); - await addButton.click(); + const addButton = await this.swipeToolbarToElement( ADD_BLOCK_ID, { + byId: true, + swipeRight: true, + } ); + await addButton[ 0 ].click(); } // Click on block of choice. @@ -599,10 +642,8 @@ class EditorPage { const identifier = isAndroid() ? `//android.widget.Button[@content-desc="${ formatting }"]/android.view.ViewGroup` : `//XCUIElementTypeButton[@name="${ formatting }"]`; - const toggleElement = await this.waitForElementToBeDisplayedByXPath( - identifier - ); - return await toggleElement.click(); + const toggleElement = await this.swipeToolbarToElement( identifier ); + return await toggleElement[ 0 ].click(); } async openLinkToSettings() { diff --git a/packages/react-native-editor/ios/Podfile.lock b/packages/react-native-editor/ios/Podfile.lock index e33ff9f46d7890..27d2e6ca929b51 100644 --- a/packages/react-native-editor/ios/Podfile.lock +++ b/packages/react-native-editor/ios/Podfile.lock @@ -13,7 +13,7 @@ PODS: - ReactCommon/turbomodule/core (= 0.69.4) - fmt (6.2.1) - glog (0.3.5) - - Gutenberg (1.98.1): + - Gutenberg (1.99.0): - React-Core (= 0.69.4) - React-CoreModules (= 0.69.4) - React-RCTImage (= 0.69.4) @@ -360,7 +360,7 @@ PODS: - React-Core - RNSVG (9.13.6): - React-Core - - RNTAztecView (1.98.1): + - RNTAztecView (1.99.0): - React-Core - WordPress-Aztec-iOS (~> 1.19.8) - SDWebImage (5.11.1): @@ -540,7 +540,7 @@ SPEC CHECKSUMS: FBReactNativeSpec: 2ff441cbe6e58c1778d8a5cf3311831a6a8c0809 fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 glog: 3d02b25ca00c2d456734d0bcff864cbc62f6ae1a - Gutenberg: 36708d354578d1fd904c5c93fa8199b31b4cbb42 + Gutenberg: 06d0e1bc1dbd7ad23b8f9b587cceba18aa8518da libwebp: 60305b2e989864154bd9be3d772730f08fc6a59c RCT-Folly: b9d9fe1fc70114b751c076104e52f3b1b5e5a95a RCTRequired: bd9d2ab0fda10171fcbcf9ba61a7df4dc15a28f4 @@ -556,13 +556,13 @@ SPEC CHECKSUMS: React-jsiexecutor: a27badbbdbc0ff781813370736a2d1c7261181d4 React-jsinspector: 8a3d3f5dcd23a91e8c80b1bf0e96902cd1dca999 React-logger: 1088859f145b8f6dd0d3ed051a647ef0e3e80fad - react-native-blur: 8cd9b4a8007166ad643f4dff914c3fddd2ff5b9a + react-native-blur: 3e9c8e8e9f7d17fa1b94e1a0ae9fd816675f5382 react-native-get-random-values: b6fb85e7169b9822976793e467458c151c3e8b69 react-native-safe-area: c9cf765aa2dd96159476a99633e7d462ce5bb94f - react-native-safe-area-context: e471852c5ed67eea4b10c5d9d43c1cebae3b231d + react-native-safe-area-context: f0906bf8bc9835ac9a9d3f97e8bde2a997d8da79 react-native-slider: dff0d8a46f368a8d1bacd8638570d75b9b0be400 - react-native-video: afb806880af4f6612683ab678a793ae41bc39705 - react-native-webview: e3b659a6d614bb37fb12a2de82c91a378c59d84b + react-native-video: 6dee623307ed9d04d1be2de87494f9a0fa2041d1 + react-native-webview: 9f111dfbcfc826084d6c507f569e5e03342ee1c1 React-perflogger: cb386fd44c97ec7f8199c04c12b22066b0f2e1e0 React-RCTActionSheet: f803a85e46cf5b4066c2ac5e122447f918e9c6e5 React-RCTAnimation: 19c80fa950ccce7f4db76a2a7f2cf79baae07fc7 @@ -575,14 +575,14 @@ SPEC CHECKSUMS: React-RCTVibration: 9adb4a3cbb598d1bbd46a05256f445e4b8c70603 React-runtimeexecutor: 61ee22a8cdf8b6bb2a7fb7b4ba2cc763e5285196 ReactCommon: 8f67bd7e0a6afade0f20718f859dc8c2275f2e83 - RNCClipboard: f49f3de56b40d0f4104680dabadc7a1f063f4fd4 - RNCMaskedView: d367b2a8df3992114e31b32b091a0c00dc800827 + RNCClipboard: 99fc8ad669a376b756fbc8098ae2fd05c0ed0668 + RNCMaskedView: c298b644a10c0c142055b3ae24d83879ecb13ccd RNFastImage: 1f2cab428712a4baaf78d6169eaec7f622556dd7 RNGestureHandler: f5c389f7c9947057ee47d16ca1d7d170289b2c2a RNReanimated: 5740ec9926f80bccd404bacd3e71108e87c94afa RNScreens: 953633729a42e23ad0c93574d676b361e3335e8b RNSVG: 36a7359c428dcb7c6bce1cc546fbfebe069809b0 - RNTAztecView: 3e9b521a52ad407c08235c82ab586bad6bb5f0f7 + RNTAztecView: 4ccd0ce94481a4026420a7b0725ce86e762f1c16 SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d WordPress-Aztec-iOS: 7d11d598f14c82c727c08b56bd35fbeb7dafb504 diff --git a/packages/react-native-editor/package.json b/packages/react-native-editor/package.json index 1369f8c193b36d..4034ada7699cab 100644 --- a/packages/react-native-editor/package.json +++ b/packages/react-native-editor/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-native-editor", - "version": "1.98.1", + "version": "1.99.0", "description": "Mobile WordPress gutenberg editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/reusable-blocks/src/components/reusable-blocks-menu-items/reusable-block-convert-button.js b/packages/reusable-blocks/src/components/reusable-blocks-menu-items/reusable-block-convert-button.js index 875adb8fc16e3a..981776880a1374 100644 --- a/packages/reusable-blocks/src/components/reusable-blocks-menu-items/reusable-block-convert-button.js +++ b/packages/reusable-blocks/src/components/reusable-blocks-menu-items/reusable-block-convert-button.js @@ -19,7 +19,7 @@ import { } from '@wordpress/components'; import { symbol } from '@wordpress/icons'; import { useDispatch, useSelect } from '@wordpress/data'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import { store as coreStore } from '@wordpress/core-data'; @@ -98,15 +98,25 @@ export default function ReusableBlockConvertButton( { ); createSuccessNotice( syncType === 'fully' - ? __( 'Synced Pattern created.' ) - : __( 'Unsynced Pattern created.' ), + ? sprintf( + // translators: %s: the name the user has given to the pattern. + __( 'Synced Pattern created: %s' ), + reusableBlockTitle + ) + : sprintf( + // translators: %s: the name the user has given to the pattern. + __( 'Unsynced Pattern created: %s' ), + reusableBlockTitle + ), { type: 'snackbar', + id: 'convert-to-reusable-block-success', } ); } catch ( error ) { createErrorNotice( error.message, { type: 'snackbar', + id: 'convert-to-reusable-block-error', } ); } }, diff --git a/packages/reusable-blocks/src/components/reusable-blocks-menu-items/reusable-blocks-manage-button.js b/packages/reusable-blocks/src/components/reusable-blocks-menu-items/reusable-blocks-manage-button.js index 6f339058885111..e3bbef8bf77388 100644 --- a/packages/reusable-blocks/src/components/reusable-blocks-menu-items/reusable-blocks-manage-button.js +++ b/packages/reusable-blocks/src/components/reusable-blocks-menu-items/reusable-blocks-manage-button.js @@ -18,28 +18,41 @@ import { store as coreStore } from '@wordpress/core-data'; import { store as reusableBlocksStore } from '../../store'; function ReusableBlocksManageButton( { clientId } ) { - const { canRemove, isVisible, innerBlockCount } = useSelect( - ( select ) => { - const { getBlock, canRemoveBlock, getBlockCount } = - select( blockEditorStore ); - const { canUser } = select( coreStore ); - const reusableBlock = getBlock( clientId ); + const { canRemove, isVisible, innerBlockCount, managePatternsUrl } = + useSelect( + ( select ) => { + const { getBlock, canRemoveBlock, getBlockCount, getSettings } = + select( blockEditorStore ); + const { canUser } = select( coreStore ); + const reusableBlock = getBlock( clientId ); + const isBlockTheme = getSettings().__unstableIsBlockBasedTheme; - return { - canRemove: canRemoveBlock( clientId ), - isVisible: - !! reusableBlock && - isReusableBlock( reusableBlock ) && - !! canUser( - 'update', - 'blocks', - reusableBlock.attributes.ref - ), - innerBlockCount: getBlockCount( clientId ), - }; - }, - [ clientId ] - ); + return { + canRemove: canRemoveBlock( clientId ), + isVisible: + !! reusableBlock && + isReusableBlock( reusableBlock ) && + !! canUser( + 'update', + 'blocks', + reusableBlock.attributes.ref + ), + innerBlockCount: getBlockCount( clientId ), + // The site editor and templates both check whether the user + // has edit_theme_options capabilities. We can leverage that here + // and omit the manage patterns link if the user can't access it. + managePatternsUrl: + isBlockTheme && canUser( 'read', 'templates' ) + ? addQueryArgs( 'site-editor.php', { + path: '/patterns', + } ) + : addQueryArgs( 'edit.php', { + post_type: 'wp_block', + } ), + }; + }, + [ clientId ] + ); const { __experimentalConvertBlockToStatic: convertBlockToStatic } = useDispatch( reusableBlocksStore ); @@ -50,9 +63,7 @@ function ReusableBlocksManageButton( { clientId } ) { return ( - + { __( 'Manage Patterns' ) } { canRemove && ( diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 9e44d5fea13474..6f40a38522ad6c 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -2,7 +2,7 @@ Sniffs for WordPress plugins, with minor modifications for Gutenberg - + *\.php$ diff --git a/phpunit/class-gutenberg-navigation-fallback-gutenberg-test.php b/phpunit/class-gutenberg-navigation-fallback-gutenberg-test.php index 7b0719950df8b5..edc48f1c71761b 100644 --- a/phpunit/class-gutenberg-navigation-fallback-gutenberg-test.php +++ b/phpunit/class-gutenberg-navigation-fallback-gutenberg-test.php @@ -32,7 +32,6 @@ public function test_it_exists() { $this->assertTrue( class_exists( 'Gutenberg_Navigation_Fallback' ), 'Gutenberg_Navigation_Fallback class should exist.' ); } - /** * @covers WP_REST_Navigation_Fallback_Controller::get_fallback */ @@ -54,6 +53,24 @@ public function test_should_return_a_default_fallback_navigation_menu_in_absence $this->assertCount( 1, $navs_in_db, 'The fallback Navigation post should be the only one in the database.' ); } + /** + * @covers WP_REST_Navigation_Fallback_Controller::get_fallback + */ + public function test_should_not_automatically_create_fallback_if_filter_is_falsey() { + + add_filter( 'gutenberg_navigation_should_create_fallback', '__return_false' ); + + $data = Gutenberg_Navigation_Fallback::get_fallback(); + + $this->assertEmpty( $data ); + + $navs_in_db = $this->get_navigations_in_database(); + + $this->assertCount( 0, $navs_in_db, 'The fallback Navigation post should not have been created.' ); + + remove_filter( 'gutenberg_navigation_should_create_fallback', '__return_false' ); + } + /** * @covers WP_REST_Navigation_Fallback_Controller::get_fallback */ diff --git a/test/e2e/specs/site-editor/multi-entity-saving.spec.js b/test/e2e/specs/site-editor/multi-entity-saving.spec.js new file mode 100644 index 00000000000000..b4d2f1dbda0d03 --- /dev/null +++ b/test/e2e/specs/site-editor/multi-entity-saving.spec.js @@ -0,0 +1,87 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Site Editor - Multi-entity save flow', () => { + test.beforeAll( async ( { requestUtils } ) => { + await Promise.all( [ + requestUtils.activateTheme( 'emptytheme' ), + requestUtils.deleteAllTemplates( 'wp_template' ), + ] ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await Promise.all( [ + requestUtils.activateTheme( 'twentytwentyone' ), + requestUtils.deleteAllTemplates( 'wp_template' ), + ] ); + } ); + + test.beforeEach( async ( { admin, editor } ) => { + await admin.visitSiteEditor( { + postId: 'emptytheme//index', + postType: 'wp_template', + } ); + await editor.canvas.click( 'body' ); + } ); + + test( 'save flow should work as expected', async ( { editor, page } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'Testing', + }, + } ); + + await expect( + page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Save' } ) + ).toBeEnabled(); + await expect( + page + .getByRole( 'region', { name: 'Save panel' } ) + .getByRole( 'button', { name: 'Open save panel' } ) + ).toBeVisible(); + + await editor.saveSiteEditorEntities(); + await expect( + page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Saved' } ) + ).toBeDisabled(); + } ); + + test( 'save flow should allow re-saving after changing the same block attribute', async ( { + editor, + page, + } ) => { + await editor.openDocumentSettingsSidebar(); + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'Testing', + }, + } ); + + const fontSizePicker = page + .getByRole( 'region', { name: 'Editor Settings' } ) + .getByRole( 'group', { name: 'Font size' } ); + + // Change font size. + await fontSizePicker.getByRole( 'radio', { name: 'Small' } ).click(); + + await editor.saveSiteEditorEntities(); + + // Change font size again. + await fontSizePicker.getByRole( 'radio', { name: 'Medium' } ).click(); + + // The save button has been re-enabled. + await expect( + page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Save' } ) + ).toBeEnabled(); + } ); +} );