From f10f85f5779eab8a9daeb00ef6721543dce91201 Mon Sep 17 00:00:00 2001 From: Marin Atanasov <8436925+tyxla@users.noreply.github.com> Date: Thu, 25 May 2023 09:23:08 +0300 Subject: [PATCH 01/91] Lodash: Remove _.isEmpty() from site editor (#50917) --- .../screen-revisions/use-global-styles-revisions.js | 12 +++++++----- .../header-edit-mode/tools-more-menu-group/index.js | 7 +------ .../edit-site/src/components/revisions/index.js | 13 ++++++------- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js b/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js index 94d9296989eee1..a35f9092248fde 100644 --- a/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js +++ b/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js @@ -5,10 +5,7 @@ import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; import { useContext, useMemo } from '@wordpress/element'; import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; -/** - * External dependencies - */ -import { isEmpty } from 'lodash'; + /** * Internal dependencies */ @@ -75,7 +72,12 @@ export default function useGlobalStylesRevisions() { } // Adds an item for unsaved changes. - if ( isDirty && ! isEmpty( userConfig ) && currentUser ) { + if ( + isDirty && + userConfig && + Object.keys( userConfig ).length > 0 && + currentUser + ) { const unsavedRevision = { id: 'unsaved', styles: userConfig?.styles, diff --git a/packages/edit-site/src/components/header-edit-mode/tools-more-menu-group/index.js b/packages/edit-site/src/components/header-edit-mode/tools-more-menu-group/index.js index d0f05487716b7e..8babbdd0c3dc71 100644 --- a/packages/edit-site/src/components/header-edit-mode/tools-more-menu-group/index.js +++ b/packages/edit-site/src/components/header-edit-mode/tools-more-menu-group/index.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import { isEmpty } from 'lodash'; - /** * WordPress dependencies */ @@ -14,7 +9,7 @@ const { Fill: ToolsMoreMenuGroup, Slot } = createSlotFill( ToolsMoreMenuGroup.Slot = ( { fillProps } ) => ( - { ( fills ) => ! isEmpty( fills ) && fills } + { ( fills ) => fills && fills.length > 0 } ); diff --git a/packages/edit-site/src/components/revisions/index.js b/packages/edit-site/src/components/revisions/index.js index 3e06b6415cc4ea..221166d2d998eb 100644 --- a/packages/edit-site/src/components/revisions/index.js +++ b/packages/edit-site/src/components/revisions/index.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import { isEmpty } from 'lodash'; - /** * WordPress dependencies */ @@ -30,6 +25,10 @@ import EditorCanvasContainer from '../editor-canvas-container'; const { ExperimentalBlockEditorProvider, useGlobalStylesOutputWithConfig } = unlock( blockEditorPrivateApis ); +function isObjectEmpty( object ) { + return ! object || Object.keys( object ).length === 0; +} + function Revisions( { onClose, userConfig, blocks } ) { const { baseConfig } = useSelect( ( select ) => ( { @@ -42,7 +41,7 @@ function Revisions( { onClose, userConfig, blocks } ) { ); const mergedConfig = useMemo( () => { - if ( ! isEmpty( userConfig ) && ! isEmpty( baseConfig ) ) { + if ( ! isObjectEmpty( userConfig ) && ! isObjectEmpty( baseConfig ) ) { return mergeBaseAndUserConfigs( baseConfig, userConfig ); } return {}; @@ -65,7 +64,7 @@ function Revisions( { onClose, userConfig, blocks } ) { const [ globalStyles ] = useGlobalStylesOutputWithConfig( mergedConfig ); const editorStyles = - ! isEmpty( globalStyles ) && ! isEmpty( userConfig ) + ! isObjectEmpty( globalStyles ) && ! isObjectEmpty( userConfig ) ? globalStyles : settings.styles; From 2b687d03ecdfb52229c25d5d455038b14fc8fa89 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Thu, 25 May 2023 13:16:38 +0100 Subject: [PATCH 02/91] Link Control require user to manually submit any changes (#50668) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement initial shallow checking * Don’t assyne value when comparing value changes * Fix recrusive re-renders in hook Avoid passing a default when default is handled internally. Set default to be an object as that is what we are tracking. * Fix ENTER submission bug * Require settings changes to be “Applied” * Refactor for readability and tidy * Improve test naming * Add test for new UX * Improve fetching of settings * Rename hook to convey new purpose * Improve test coverage of test * Fix tab stops now Apply is disabled when there are no changes * Wait for settings drawer to open * Extract retrival of settings keys * Move setters to hook * Make API clearer to consumers of hook --- .../src/components/link-control/index.js | 74 ++++++++++++++----- .../src/components/link-control/test/index.js | 65 +++++++++++++++- .../link-control/use-internal-input-value.js | 23 ------ .../link-control/use-internal-value.js | 60 +++++++++++++++ .../specs/editor/various/links.test.js | 5 +- 5 files changed, 179 insertions(+), 48 deletions(-) delete mode 100644 packages/block-editor/src/components/link-control/use-internal-input-value.js create mode 100644 packages/block-editor/src/components/link-control/use-internal-value.js diff --git a/packages/block-editor/src/components/link-control/index.js b/packages/block-editor/src/components/link-control/index.js index 142ffd6db1e503..bbb56ab72db8de 100644 --- a/packages/block-editor/src/components/link-control/index.js +++ b/packages/block-editor/src/components/link-control/index.js @@ -11,6 +11,7 @@ import { __ } from '@wordpress/i18n'; import { useRef, useState, useEffect } from '@wordpress/element'; import { focus } from '@wordpress/dom'; import { ENTER } from '@wordpress/keycodes'; +import { isShallowEqualObjects } from '@wordpress/is-shallow-equal'; /** * Internal dependencies @@ -19,7 +20,7 @@ import LinkControlSettingsDrawer from './settings-drawer'; import LinkControlSearchInput from './search-input'; import LinkPreview from './link-preview'; import useCreatePage from './use-create-page'; -import useInternalInputValue from './use-internal-input-value'; +import useInternalValue from './use-internal-value'; import { ViewerFill } from './viewer-slot'; import { DEFAULT_LINK_SETTINGS } from './constants'; @@ -136,13 +137,20 @@ function LinkControl( { const textInputRef = useRef(); const isEndingEditWithFocus = useRef( false ); + const settingsKeys = settings.map( ( { id } ) => id ); + const [ settingsOpen, setSettingsOpen ] = useState( false ); - const [ internalUrlInputValue, setInternalUrlInputValue ] = - useInternalInputValue( value?.url || '' ); + const [ + internalControlValue, + setInternalControlValue, + setInternalURLInputValue, + setInternalTextInputValue, + createSetInternalSettingValueHandler, + ] = useInternalValue( value ); - const [ internalTextInputValue, setInternalTextInputValue ] = - useInternalInputValue( value?.title || '' ); + const valueHasChanges = + value && ! isShallowEqualObjects( internalControlValue, value ); const [ isEditingLink, setIsEditingLink ] = useState( forceIsEditingLink !== undefined @@ -160,6 +168,8 @@ function LinkControl( { ) { setIsEditingLink( forceIsEditingLink ); } + // Todo: bug if the missing dep is introduced. Will need a fix. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ forceIsEditingLink ] ); useEffect( () => { @@ -208,22 +218,39 @@ function LinkControl( { }; const handleSelectSuggestion = ( updatedValue ) => { + // Suggestions may contains "settings" values (e.g. `opensInNewTab`) + // which should not overide any existing settings values set by the + // user. This filters out any settings values from the suggestion. + const nonSettingsChanges = Object.keys( updatedValue ).reduce( + ( acc, key ) => { + if ( ! settingsKeys.includes( key ) ) { + acc[ key ] = updatedValue[ key ]; + } + return acc; + }, + {} + ); + onChange( { - ...updatedValue, - title: internalTextInputValue || updatedValue?.title, + ...internalControlValue, + ...nonSettingsChanges, + // As title is not a setting, it must be manually applied + // in such a way as to preserve the users changes over + // any "title" value provided by the "suggestion". + title: internalControlValue?.title || updatedValue?.title, } ); + stopEditing(); }; const handleSubmit = () => { - if ( - currentUrlInputValue !== value?.url || - internalTextInputValue !== value?.title - ) { + if ( valueHasChanges ) { + // Submit the original value with new stored values applied + // on top. URL is a special case as it may also be a prop. onChange( { ...value, + ...internalControlValue, url: currentUrlInputValue, - title: internalTextInputValue, } ); } stopEditing(); @@ -231,6 +258,7 @@ function LinkControl( { const handleSubmitWithEnter = ( event ) => { const { keyCode } = event; + if ( keyCode === ENTER && ! currentInputIsEmpty // Disallow submitting empty values. @@ -241,8 +269,7 @@ function LinkControl( { }; const resetInternalValues = () => { - setInternalUrlInputValue( value?.url ); - setInternalTextInputValue( value?.title ); + setInternalControlValue( value ); }; const handleCancel = ( event ) => { @@ -263,7 +290,8 @@ function LinkControl( { onCancel?.(); }; - const currentUrlInputValue = propInputValue || internalUrlInputValue; + const currentUrlInputValue = + propInputValue || internalControlValue?.url || ''; const currentInputIsEmpty = ! currentUrlInputValue?.trim()?.length; @@ -306,7 +334,7 @@ function LinkControl( { value={ currentUrlInputValue } withCreateSuggestion={ withCreateSuggestion } onCreateSuggestion={ createPage } - onChange={ setInternalUrlInputValue } + onChange={ setInternalURLInputValue } onSelect={ handleSelectSuggestion } showInitialSuggestions={ showInitialSuggestions } allowDirectEntry={ ! noDirectEntry } @@ -351,14 +379,18 @@ function LinkControl( { showTextControl={ showTextControl } showSettings={ showSettings } textInputRef={ textInputRef } - internalTextInputValue={ internalTextInputValue } + internalTextInputValue={ + internalControlValue?.title + } setInternalTextInputValue={ setInternalTextInputValue } handleSubmitWithEnter={ handleSubmitWithEnter } - value={ value } + value={ internalControlValue } settings={ settings } - onChange={ onChange } + onChange={ createSetInternalSettingValueHandler( + settingsKeys + ) } /> ) } @@ -367,7 +399,9 @@ function LinkControl( { variant="primary" onClick={ handleSubmit } className="block-editor-link-control__search-submit" - disabled={ currentInputIsEmpty } // Disallow submitting empty values. + disabled={ + ! valueHasChanges || currentInputIsEmpty + } > { __( 'Apply' ) } diff --git a/packages/block-editor/src/components/link-control/test/index.js b/packages/block-editor/src/components/link-control/test/index.js index 4e2428fc0ac3f8..5dfa5ad2f45870 100644 --- a/packages/block-editor/src/components/link-control/test/index.js +++ b/packages/block-editor/src/components/link-control/test/index.js @@ -1784,6 +1784,63 @@ describe( 'Addition Settings UI', () => { } ) ).toBeChecked(); } ); + + it( 'should require settings changes to be submitted/applied', async () => { + const user = userEvent.setup(); + + const mockOnChange = jest.fn(); + + const selectedLink = { + ...fauxEntitySuggestions[ 0 ], + // Including a setting here helps to assert on a potential bug + // whereby settings on the suggestion override the current (internal) + // settings values set by the user in the UI. + opensInNewTab: false, + }; + + render( + + ); + + // check that the "Apply" button is disabled by default. + const submitButton = screen.queryByRole( 'button', { + name: 'Apply', + } ); + + expect( submitButton ).toBeDisabled(); + + await toggleSettingsDrawer( user ); + + const opensInNewTabToggle = screen.queryByRole( 'checkbox', { + name: 'Open in new tab', + } ); + + // toggle the checkbox + await user.click( opensInNewTabToggle ); + + // Check settings are **not** directly submitted + // which would trigger the onChange handler. + expect( mockOnChange ).not.toHaveBeenCalled(); + + // Check Apply button is now enabled because changes + // have been detected. + expect( submitButton ).toBeEnabled(); + + // Submit the changed setting value using the Apply button + await user.click( submitButton ); + + // Assert the value is updated. + expect( mockOnChange ).toHaveBeenCalledWith( + expect.objectContaining( { + opensInNewTab: true, + } ) + ); + } ); } ); describe( 'Post types', () => { @@ -2199,7 +2256,7 @@ describe( 'Controlling link title text', () => { it( 'should allow `ENTER` keypress within the text field to trigger submission of value', async () => { const user = userEvent.setup(); - const textValue = 'My new text value'; + const newTextValue = 'My new text value'; const mockOnChange = jest.fn(); render( @@ -2218,14 +2275,14 @@ describe( 'Controlling link title text', () => { expect( textInput ).toBeVisible(); await user.clear( textInput ); - await user.keyboard( textValue ); + await user.keyboard( newTextValue ); // Attempt to submit the empty search value in the input. triggerEnter( textInput ); expect( mockOnChange ).toHaveBeenCalledWith( expect.objectContaining( { - title: textValue, + title: newTextValue, url: selectedLink.url, } ) ); @@ -2236,7 +2293,7 @@ describe( 'Controlling link title text', () => { ).not.toBeInTheDocument(); } ); - it( 'should reset state on value change', async () => { + it( 'should reset state upon controlled value change', async () => { const user = userEvent.setup(); const textValue = 'My new text value'; const mockOnChange = jest.fn(); diff --git a/packages/block-editor/src/components/link-control/use-internal-input-value.js b/packages/block-editor/src/components/link-control/use-internal-input-value.js deleted file mode 100644 index 5dd3c59f3e873a..00000000000000 --- a/packages/block-editor/src/components/link-control/use-internal-input-value.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * WordPress dependencies - */ -import { useState, useEffect } from '@wordpress/element'; - -export default function useInternalInputValue( value ) { - const [ internalInputValue, setInternalInputValue ] = useState( - value || '' - ); - - // If the value prop changes, update the internal state. - useEffect( () => { - setInternalInputValue( ( prevValue ) => { - if ( value && value !== prevValue ) { - return value; - } - - return prevValue; - } ); - }, [ value ] ); - - return [ internalInputValue, setInternalInputValue ]; -} diff --git a/packages/block-editor/src/components/link-control/use-internal-value.js b/packages/block-editor/src/components/link-control/use-internal-value.js new file mode 100644 index 00000000000000..ac58c05b10a870 --- /dev/null +++ b/packages/block-editor/src/components/link-control/use-internal-value.js @@ -0,0 +1,60 @@ +/** + * WordPress dependencies + */ +import { useState, useEffect } from '@wordpress/element'; + +export default function useInternalValue( value ) { + const [ internalValue, setInternalValue ] = useState( value || {} ); + + // If the value prop changes, update the internal state. + useEffect( () => { + setInternalValue( ( prevValue ) => { + if ( value && value !== prevValue ) { + return value; + } + + return prevValue; + } ); + }, [ value ] ); + + const setInternalURLInputValue = ( nextValue ) => { + setInternalValue( { + ...internalValue, + url: nextValue, + } ); + }; + + const setInternalTextInputValue = ( nextValue ) => { + setInternalValue( { + ...internalValue, + title: nextValue, + } ); + }; + + const createSetInternalSettingValueHandler = + ( settingsKeys ) => ( nextValue ) => { + // Only apply settings values which are defined in the settings prop. + const settingsUpdates = Object.keys( nextValue ).reduce( + ( acc, key ) => { + if ( settingsKeys.includes( key ) ) { + acc[ key ] = nextValue[ key ]; + } + return acc; + }, + {} + ); + + setInternalValue( { + ...internalValue, + ...settingsUpdates, + } ); + }; + + return [ + internalValue, + setInternalValue, + setInternalURLInputValue, + setInternalTextInputValue, + createSetInternalSettingValueHandler, + ]; +} diff --git a/packages/e2e-tests/specs/editor/various/links.test.js b/packages/e2e-tests/specs/editor/various/links.test.js index 9c3e8a722a7f0e..65b44ea9164c95 100644 --- a/packages/e2e-tests/specs/editor/various/links.test.js +++ b/packages/e2e-tests/specs/editor/various/links.test.js @@ -792,8 +792,11 @@ describe( 'Links', () => { ); await settingsToggle.click(); + // Wait for settings to open. + await page.waitForXPath( `//label[text()='Open in new tab']` ); + // Move focus back to RichText for the underlying link. - await pressKeyTimes( 'Tab', 5 ); + await pressKeyTimes( 'Tab', 4 ); // Make a selection within the RichText. await pressKeyWithModifier( 'shift', 'ArrowRight' ); From 34c5d3c821eee09d03cb38d55fbeae63114b6a73 Mon Sep 17 00:00:00 2001 From: Ella <4710635+ellatrix@users.noreply.github.com> Date: Thu, 25 May 2023 16:42:29 +0200 Subject: [PATCH 03/91] Writing flow: fix tab into iframe (#50955) --- .../src/components/writing-flow/use-tab-nav.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/block-editor/src/components/writing-flow/use-tab-nav.js b/packages/block-editor/src/components/writing-flow/use-tab-nav.js index fccc9132bf98e5..bb1d22b2865de5 100644 --- a/packages/block-editor/src/components/writing-flow/use-tab-nav.js +++ b/packages/block-editor/src/components/writing-flow/use-tab-nav.js @@ -43,13 +43,24 @@ export default function useTabNav() { } else { setNavigationMode( true ); + const canvasElement = + container.current.ownerDocument === event.target.ownerDocument + ? container.current + : container.current.ownerDocument.defaultView.frameElement; + const isBefore = // eslint-disable-next-line no-bitwise - event.target.compareDocumentPosition( container.current ) & + event.target.compareDocumentPosition( canvasElement ) & event.target.DOCUMENT_POSITION_FOLLOWING; - const action = isBefore ? 'findNext' : 'findPrevious'; + const tabbables = focus.tabbable.find( container.current ); + + if ( tabbables.length ) { + const next = isBefore + ? tabbables[ 0 ] + : tabbables[ tabbables.length - 1 ]; - focus.tabbable[ action ]( event.target ).focus(); + next.focus(); + } } } From 1100545f439ac734214d99799bfadfda368f4521 Mon Sep 17 00:00:00 2001 From: "okmttdhr, tada" Date: Fri, 26 May 2023 00:27:00 +0900 Subject: [PATCH 04/91] Extract BlockThemePreviews-related code from the editor package (#50863) * Extract BlockThemePreviews-related code from the editor package * Use props to pass values * Lift up the states * Reuse existing functions * extract _EntitiesSavedStates component * Make EntitiesSavedStatesExtensible provate API * Remove window.__experimentalEnableThemePreviews * Remove the experiment option * Fix missing props * Revert "Remove the experiment option" This reverts commit 1f8961aa2b89552fc6f6b6e521dc696923c85547. * Remove unnecessary destructuring assignment --- lib/experimental/editor-settings.php | 3 - .../src/components/save-panel/index.js | 77 ++++++++-- .../src/utils/is-previewing-theme.js | 5 +- .../hooks/use-is-dirty.js | 85 ++++++++++++ .../components/entities-saved-states/index.js | 131 ++++-------------- packages/editor/src/components/index.js | 1 + packages/editor/src/private-apis.js | 2 + 7 files changed, 176 insertions(+), 128 deletions(-) create mode 100644 packages/editor/src/components/entities-saved-states/hooks/use-is-dirty.js diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index 96cd4e48440394..9571d50fdf3f27 100644 --- a/lib/experimental/editor-settings.php +++ b/lib/experimental/editor-settings.php @@ -89,9 +89,6 @@ function gutenberg_enable_experiments() { if ( $gutenberg_experiments && array_key_exists( 'gutenberg-details-blocks', $gutenberg_experiments ) ) { wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableDetailsBlocks = true', 'before' ); } - if ( $gutenberg_experiments && array_key_exists( 'gutenberg-theme-previews', $gutenberg_experiments ) ) { - wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableThemePreviews = true', 'before' ); - } if ( $gutenberg_experiments && array_key_exists( 'gutenberg-pattern-enhancements', $gutenberg_experiments ) ) { wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnablePatternEnhancements = true', 'before' ); } diff --git a/packages/edit-site/src/components/save-panel/index.js b/packages/edit-site/src/components/save-panel/index.js index fa9516f3599d9b..04f97e2afcf0e5 100644 --- a/packages/edit-site/src/components/save-panel/index.js +++ b/packages/edit-site/src/components/save-panel/index.js @@ -7,10 +7,15 @@ import classnames from 'classnames'; * WordPress dependencies */ import { Button, Modal } from '@wordpress/components'; -import { EntitiesSavedStates } from '@wordpress/editor'; +import { + EntitiesSavedStates, + useEntitiesSavedStatesIsDirty, + privateApis, +} from '@wordpress/editor'; import { useDispatch, useSelect } from '@wordpress/data'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { NavigableRegion } from '@wordpress/interface'; +import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies @@ -18,6 +23,59 @@ import { NavigableRegion } from '@wordpress/interface'; import { store as editSiteStore } from '../../store'; import { unlock } from '../../private-apis'; import { useActivateTheme } from '../../utils/use-activate-theme'; +import { + currentlyPreviewingTheme, + isPreviewingTheme, +} from '../../utils/is-previewing-theme'; + +const { EntitiesSavedStatesExtensible } = unlock( privateApis ); + +const EntitiesSavedStatesForPreview = ( { onClose } ) => { + const isDirtyProps = useEntitiesSavedStatesIsDirty(); + let activateSaveLabel; + if ( isDirtyProps.isDirty ) { + activateSaveLabel = __( 'Activate & Save' ); + } else { + activateSaveLabel = __( 'Activate' ); + } + + const { getTheme } = useSelect( coreStore ); + const theme = getTheme( currentlyPreviewingTheme() ); + const additionalPrompt = ( +

+ { sprintf( + 'Saving your changes will change your active theme to %1$s.', + theme?.name?.rendered + ) } +

+ ); + + const activateTheme = useActivateTheme(); + const onSave = async ( values ) => { + await activateTheme(); + return values; + }; + + return ( + + ); +}; + +const _EntitiesSavedStates = ( { onClose } ) => { + if ( isPreviewingTheme() ) { + return ; + } + return ; +}; export default function SavePanel() { const { isSaveViewOpen, canvasMode } = useSelect( ( select ) => { @@ -33,18 +91,7 @@ export default function SavePanel() { }; }, [] ); const { setIsSaveViewOpened } = useDispatch( editSiteStore ); - const activateTheme = useActivateTheme(); const onClose = () => setIsSaveViewOpened( false ); - const onSave = async ( values ) => { - await activateTheme(); - return values; - }; - - const entitySavedStates = window?.__experimentalEnableThemePreviews ? ( - - ) : ( - - ); if ( canvasMode === 'view' ) { return isSaveViewOpen ? ( @@ -56,7 +103,7 @@ export default function SavePanel() { 'Save site, content, and template changes' ) } > - { entitySavedStates } + <_EntitiesSavedStates onClose={ onClose } /> ) : null; } @@ -69,7 +116,7 @@ export default function SavePanel() { ariaLabel={ __( 'Save panel' ) } > { isSaveViewOpen ? ( - entitySavedStates + <_EntitiesSavedStates onClose={ onClose } /> ) : (
+ ) ) } + + ); +}; +VariantStates.args = { + children: 'Code is poetry', +}; + +export const Icon = VariantStates.bind( {} ); +Icon.args = { + icon: wordpress, +}; diff --git a/test/storybook-playwright/specs/button.spec.ts b/test/storybook-playwright/specs/button.spec.ts new file mode 100644 index 00000000000000..66049d8c63f4cc --- /dev/null +++ b/test/storybook-playwright/specs/button.spec.ts @@ -0,0 +1,57 @@ +/** + * External dependencies + */ +import { expect, test } from '@playwright/test'; + +/** + * Internal dependencies + */ +import { + gotoStoryId, + getAllPropsPermutations, + testSnapshotForPropsConfig, +} from '../utils'; + +test.describe( 'Button', () => { + test.describe( 'variant states', () => { + test.beforeEach( async ( { page } ) => { + gotoStoryId( page, 'components-button--variant-states', { + decorators: { customE2EControls: 'show' }, + } ); + } ); + + getAllPropsPermutations( [ + { + propName: '__next40pxDefaultSize', + valuesToTest: [ true, false ], + }, + ] ).forEach( ( propsConfig ) => { + test( `should render with ${ JSON.stringify( + propsConfig + ) }`, async ( { page } ) => { + await testSnapshotForPropsConfig( page, propsConfig ); + } ); + } ); + } ); + + test.describe( 'icon', () => { + test.beforeEach( async ( { page } ) => { + gotoStoryId( page, 'components-button--icon', { + decorators: { customE2EControls: 'show' }, + } ); + } ); + + getAllPropsPermutations( [ + { + propName: '__next40pxDefaultSize', + valuesToTest: [ true, false ], + }, + ] ).forEach( ( propsConfig ) => { + test( `should render with ${ JSON.stringify( + propsConfig + ) }`, async ( { page } ) => { + await testSnapshotForPropsConfig( page, propsConfig ); + } ); + } ); + } ); +} ); diff --git a/test/storybook-playwright/utils.ts b/test/storybook-playwright/utils.ts index d3f37aa7df26bc..1f0fe7235abeed 100644 --- a/test/storybook-playwright/utils.ts +++ b/test/storybook-playwright/utils.ts @@ -62,9 +62,10 @@ export const getAllPropsPermutations = ( // Test all values for the given prop. for ( const value of propObject.valuesToTest ) { + const valueAsString = value === undefined ? 'undefined' : value; const newAccProps = { ...accProps, - [ propObject.propName ]: value, + [ propObject.propName ]: valueAsString, }; if ( restProps.length === 0 ) { @@ -99,5 +100,7 @@ export const testSnapshotForPropsConfig = async ( await submitButton.click(); - expect( await page.screenshot() ).toMatchSnapshot(); + expect( + await page.screenshot( { animations: 'disabled' } ) + ).toMatchSnapshot(); }; From 6a16313d470a7f53739011c23e48792e3fd37696 Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Fri, 26 May 2023 14:10:40 -0700 Subject: [PATCH 27/91] Preferentially Execute Local `wp-env` (#50980) Instead of always running the chosen version of `wp-env` we are going to try and find a local version in the current directory. This prevents the global `wp-env` from being used when a different local version is expected. --- packages/env/CHANGELOG.md | 4 ++++ packages/env/README.md | 4 ++-- packages/env/bin/wp-env | 20 ++++++++++++++++++-- packages/env/lib/cli.js | 5 +++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/env/CHANGELOG.md b/packages/env/CHANGELOG.md index 54bbd44d2b4485..1c2b35b28da947 100644 --- a/packages/env/CHANGELOG.md +++ b/packages/env/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### New feature + +- Execute the local package's `wp-env` instead of the globally installed version if one is available. + ## 8.0.0 (2023-05-24) ### Breaking Change diff --git a/packages/env/README.md b/packages/env/README.md index 0bc36092830330..fb9e9751d9c666 100644 --- a/packages/env/README.md +++ b/packages/env/README.md @@ -44,9 +44,9 @@ If your project already has a package.json, it's also possible to use `wp-env` a $ npm i @wordpress/env --save-dev ``` -At this point, you can use the local, project-level version of wp-env via [`npx`](https://www.npmjs.com/package/npx), a utility automatically installed with `npm`.`npx` finds binaries like wp-env installed through node modules. As an example: `npx wp-env start --update`. +If you have also installed `wp-env` globally, running it will automatically execute the local, project-level package. Alternatively, you can execute `wp-env` via [`npx`](https://www.npmjs.com/package/npx), a utility automatically installed with `npm`.`npx` finds binaries like `wp-env` installed through node modules. As an example: `npx wp-env start --update`. -If you don't wish to use `npx`, modify your package.json and add an extra command to npm `scripts` (https://docs.npmjs.com/misc/scripts): +If you don't wish to use the global installation or `npx`, modify your `package.json` and add an extra command to npm `scripts` (https://docs.npmjs.com/misc/scripts): ```json "scripts": { diff --git a/packages/env/bin/wp-env b/packages/env/bin/wp-env index 7ce3e39103bcd1..a6c4784a3e7fb5 100755 --- a/packages/env/bin/wp-env +++ b/packages/env/bin/wp-env @@ -1,4 +1,20 @@ #!/usr/bin/env node 'use strict'; -const command = process.argv.slice( 2 ); -require( '../lib/cli' )().parse( command.length ? command : [ '--help' ] ); + +// Remove 'node' and the name of the script from the arguments. +let command = process.argv.slice( 2 ); +// Default to help text when they aren't running any commands. +if ( ! command.length ) { + command = [ '--help' ]; +} + +// Rather than just executing the current CLI we will attempt to find a local version +// and execute that one instead. This prevents users from accidentally using the +// global CLI when a potentially different local version is expected. +const localPath = require.resolve( '@wordpress/env/lib/cli.js', { + paths: [ process.cwd(), __dirname ], +} ); +const cli = require( localPath )(); + +// Now we can execute the CLI with the given command. +cli.parse( command ); diff --git a/packages/env/lib/cli.js b/packages/env/lib/cli.js index 72a5eec911e087..1788315b60b9db 100644 --- a/packages/env/lib/cli.js +++ b/packages/env/lib/cli.js @@ -11,6 +11,7 @@ const { execSync } = require( 'child_process' ); /** * Internal dependencies */ +const pkg = require( '../package.json' ); const env = require( './env' ); const parseXdebugMode = require( './parse-xdebug-mode' ); const { @@ -110,6 +111,10 @@ module.exports = function cli() { 'populate--': true, } ); + // Since we might be running a different CLI version than the one that was called + // we need to set the version manually from the correct package.json. + yargs.version( pkg.version ); + yargs.command( 'start', wpGreen( From da158af9aea61b725e9bf244b6d643174b6caf2c Mon Sep 17 00:00:00 2001 From: Carolina Nymark Date: Sat, 27 May 2023 06:55:16 +0200 Subject: [PATCH 28/91] Add aria labels to the focal point picker component (#50993) * Add aria labels to the focal point picker component * Update CHANGELOG.md * Update button names in the focal point picker test --- packages/components/CHANGELOG.md | 5 +++++ .../components/src/focal-point-picker/controls.tsx | 2 ++ .../components/src/focal-point-picker/test/index.js | 12 +++++++++--- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 97bb1a706aaa3c..4d6ea42e8bc0e1 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -2,10 +2,15 @@ ## Unreleased +### Bug Fix + +- `FocalPointUnitControl`: Add aria-labels ([#50993](https://github.com/WordPress/gutenberg/pull/50993)). + ### Experimental - `DropdownMenu` v2: Tweak styles ([#50967](https://github.com/WordPress/gutenberg/pull/50967)). + ## 25.0.0 (2023-05-24) ### Breaking Changes diff --git a/packages/components/src/focal-point-picker/controls.tsx b/packages/components/src/focal-point-picker/controls.tsx index 3e6d33011da730..f204d5736779cb 100644 --- a/packages/components/src/focal-point-picker/controls.tsx +++ b/packages/components/src/focal-point-picker/controls.tsx @@ -54,6 +54,7 @@ export default function FocalPointPickerControls( { > @@ -66,6 +67,7 @@ export default function FocalPointPickerControls( { /> diff --git a/packages/components/src/focal-point-picker/test/index.js b/packages/components/src/focal-point-picker/test/index.js index af295150f6cb87..d5c7946cffd860 100644 --- a/packages/components/src/focal-point-picker/test/index.js +++ b/packages/components/src/focal-point-picker/test/index.js @@ -120,7 +120,9 @@ describe( 'FocalPointPicker', () => { const { rerender } = render( ); - const xInput = screen.getByRole( 'spinbutton', { name: 'Left' } ); + const xInput = screen.getByRole( 'spinbutton', { + name: 'Focal point left position', + } ); rerender( ); expect( xInput.value ).toBe( '93' ); } ); @@ -155,10 +157,14 @@ describe( 'FocalPointPicker', () => { ); expect( - screen.getByRole( 'spinbutton', { name: 'Left' } ).value + screen.getByRole( 'spinbutton', { + name: 'Focal point left position', + } ).value ).toBe( '10' ); expect( - screen.getByRole( 'spinbutton', { name: 'Top' } ).value + screen.getByRole( 'spinbutton', { + name: 'Focal point top position', + } ).value ).toBe( '20' ); expect( onChangeSpy ).not.toHaveBeenCalled(); } ); From 0488e292db40ecd17eb4b2e8484a10bfe57665a9 Mon Sep 17 00:00:00 2001 From: Aki Hamano <54422211+t-hamano@users.noreply.github.com> Date: Sat, 27 May 2023 16:56:02 +0900 Subject: [PATCH 29/91] Details Block: Remove experimental flag and stabilize (#50997) * Details Block: Enable by default on Gutenberg plugin without opt-in * Add fixture * Stabilize the block --- docs/reference-guides/core-blocks.md | 1 - lib/experimental/editor-settings.php | 3 --- lib/experiments-page.php | 12 --------- packages/block-library/src/details/block.json | 1 - packages/block-library/src/index.js | 4 +-- .../fixtures/blocks/core__details.html | 7 ++++++ .../fixtures/blocks/core__details.json | 22 ++++++++++++++++ .../fixtures/blocks/core__details.parsed.json | 25 +++++++++++++++++++ .../blocks/core__details.serialized.html | 5 ++++ 9 files changed, 60 insertions(+), 20 deletions(-) create mode 100644 test/integration/fixtures/blocks/core__details.html create mode 100644 test/integration/fixtures/blocks/core__details.json create mode 100644 test/integration/fixtures/blocks/core__details.parsed.json create mode 100644 test/integration/fixtures/blocks/core__details.serialized.html diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index b84d8edb8e9d98..559417fa5dff76 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -247,7 +247,6 @@ Add an image or video with a text overlay. ([Source](https://github.com/WordPres Hide and show additional content. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/details)) - **Name:** core/details -- **Experimental:** true - **Category:** text - **Supports:** align (full, wide), color (background, gradients, link, text), spacing (margin, padding), typography (fontSize, lineHeight), ~~html~~ - **Attributes:** showContent, summary diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index 9571d50fdf3f27..67e08265558e1f 100644 --- a/lib/experimental/editor-settings.php +++ b/lib/experimental/editor-settings.php @@ -86,9 +86,6 @@ function gutenberg_enable_experiments() { if ( $gutenberg_experiments && array_key_exists( 'gutenberg-group-grid-variation', $gutenberg_experiments ) ) { wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableGroupGridVariation = true', 'before' ); } - if ( $gutenberg_experiments && array_key_exists( 'gutenberg-details-blocks', $gutenberg_experiments ) ) { - wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableDetailsBlocks = true', 'before' ); - } if ( $gutenberg_experiments && array_key_exists( 'gutenberg-pattern-enhancements', $gutenberg_experiments ) ) { wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnablePatternEnhancements = true', 'before' ); } diff --git a/lib/experiments-page.php b/lib/experiments-page.php index 9e31815f3f50ff..eb6a1ea8a7336c 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -77,18 +77,6 @@ function gutenberg_initialize_experiments_settings() { ) ); - add_settings_field( - 'gutenberg-details-blocks', - __( 'Details block', 'gutenberg' ), - 'gutenberg_display_experiment_field', - 'gutenberg-experiments', - 'gutenberg_experiments_section', - array( - 'label' => __( 'Test the Details block', 'gutenberg' ), - 'id' => 'gutenberg-details-blocks', - ) - ); - add_settings_field( 'gutenberg-theme-previews', __( 'Block Theme Previews', 'gutenberg' ), diff --git a/packages/block-library/src/details/block.json b/packages/block-library/src/details/block.json index 40321ee6b0c9c1..4eb7af8d5ce623 100644 --- a/packages/block-library/src/details/block.json +++ b/packages/block-library/src/details/block.json @@ -1,7 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", "apiVersion": 2, - "__experimental": true, "name": "core/details", "title": "Details", "category": "text", diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index a0c7b75eac19b8..73c2f1eb1140a2 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -147,6 +147,7 @@ const getAllBlocks = () => { columns, commentAuthorAvatar, cover, + details, embed, file, group, @@ -226,9 +227,6 @@ const getAllBlocks = () => { queryTitle, postAuthorBiography, ]; - if ( window?.__experimentalEnableDetailsBlocks ) { - blocks.push( details ); - } return blocks.filter( Boolean ); }; diff --git a/test/integration/fixtures/blocks/core__details.html b/test/integration/fixtures/blocks/core__details.html new file mode 100644 index 00000000000000..855ea3f0a4f556 --- /dev/null +++ b/test/integration/fixtures/blocks/core__details.html @@ -0,0 +1,7 @@ + +
Details Summary + +

Details Content

+ +
+ diff --git a/test/integration/fixtures/blocks/core__details.json b/test/integration/fixtures/blocks/core__details.json new file mode 100644 index 00000000000000..e3873e4702db3f --- /dev/null +++ b/test/integration/fixtures/blocks/core__details.json @@ -0,0 +1,22 @@ +[ + { + "name": "core/details", + "isValid": true, + "attributes": { + "showContent": false, + "summary": "Details Summary" + }, + "innerBlocks": [ + { + "name": "core/paragraph", + "isValid": true, + "attributes": { + "content": "Details Content", + "dropCap": false, + "placeholder": "Type / to add a hidden block" + }, + "innerBlocks": [] + } + ] + } +] diff --git a/test/integration/fixtures/blocks/core__details.parsed.json b/test/integration/fixtures/blocks/core__details.parsed.json new file mode 100644 index 00000000000000..3240c013e8e866 --- /dev/null +++ b/test/integration/fixtures/blocks/core__details.parsed.json @@ -0,0 +1,25 @@ +[ + { + "blockName": "core/details", + "attrs": { + "summary": "Details Summary" + }, + "innerBlocks": [ + { + "blockName": "core/paragraph", + "attrs": { + "placeholder": "Type / to add a hidden block" + }, + "innerBlocks": [], + "innerHTML": "\n\t

Details Content

\n\t", + "innerContent": [ "\n\t

Details Content

\n\t" ] + } + ], + "innerHTML": "\n
Details Summary\n\t\n
\n", + "innerContent": [ + "\n
Details Summary\n\t", + null, + "\n
\n" + ] + } +] diff --git a/test/integration/fixtures/blocks/core__details.serialized.html b/test/integration/fixtures/blocks/core__details.serialized.html new file mode 100644 index 00000000000000..d5d169983bbf38 --- /dev/null +++ b/test/integration/fixtures/blocks/core__details.serialized.html @@ -0,0 +1,5 @@ + +
Details Summary +

Details Content

+
+ From 39eb0453e1f422e5d4a8e3781b37f13a88bec58a Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Sun, 28 May 2023 13:00:10 +0800 Subject: [PATCH 30/91] [ListView] Allow deleting blocks using keyboard (#50422) * Allow deleting blocks using keyboard from listview * Update updateSelection instead * Add support for kyeboard shortcut in the dropdown * Only select block if there isn't any already * More things! --- .../block-settings-dropdown.js | 122 ++-- .../list-view/block-select-button.js | 60 +- .../src/components/list-view/block.js | 42 +- .../src/components/list-view/index.js | 10 +- .../list-view/use-block-selection.js | 4 +- .../specs/editor/various/list-view.spec.js | 560 +++++++++++++----- 6 files changed, 607 insertions(+), 191 deletions(-) diff --git a/packages/block-editor/src/components/block-settings-menu/block-settings-dropdown.js b/packages/block-editor/src/components/block-settings-menu/block-settings-dropdown.js index 2ee452b4fca96f..a4087ed84cee3c 100644 --- a/packages/block-editor/src/components/block-settings-menu/block-settings-dropdown.js +++ b/packages/block-editor/src/components/block-settings-menu/block-settings-dropdown.js @@ -16,7 +16,10 @@ import { useRef, } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; -import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; +import { + store as keyboardShortcutsStore, + __unstableUseShortcutEventMatch, +} from '@wordpress/keyboard-shortcuts'; import { pipe, useCopyToClipboard } from '@wordpress/compose'; /** @@ -30,7 +33,6 @@ import BlockSettingsMenuControls from '../block-settings-menu-controls'; import { store as blockEditorStore } from '../../store'; import { useShowMoversGestures } from '../block-toolbar/utils'; -const noop = () => {}; const POPOVER_PROPS = { className: 'block-editor-block-settings-menu__popover', position: 'bottom right', @@ -63,7 +65,6 @@ export function BlockSettingsDropdown( { onlyBlock, parentBlockType, previousBlockClientId, - nextBlockClientId, selectedBlockClientIds, } = useSelect( ( select ) => { @@ -72,7 +73,6 @@ export function BlockSettingsDropdown( { getBlockName, getBlockRootClientId, getPreviousBlockClientId, - getNextBlockClientId, getSelectedBlockClientIds, getSettings, getBlockAttributes, @@ -98,12 +98,13 @@ export function BlockSettingsDropdown( { getBlockType( parentBlockName ) ), previousBlockClientId: getPreviousBlockClientId( firstBlockClientId ), - nextBlockClientId: getNextBlockClientId( firstBlockClientId ), selectedBlockClientIds: getSelectedBlockClientIds(), }; }, [ firstBlockClientId ] ); + const { getBlockOrder, getSelectedBlockClientIds } = + useSelect( blockEditorStore ); const shortcuts = useSelect( ( select ) => { const { getShortcutRepresentation } = select( keyboardShortcutsStore ); @@ -120,51 +121,47 @@ export function BlockSettingsDropdown( { ), }; }, [] ); + const isMatch = __unstableUseShortcutEventMatch(); const { selectBlock, toggleBlockHighlight } = useDispatch( blockEditorStore ); + const hasSelectedBlocks = selectedBlockClientIds.length > 0; const updateSelectionAfterDuplicate = useCallback( - __experimentalSelectBlock - ? async ( clientIdsPromise ) => { - const ids = await clientIdsPromise; - if ( ids && ids[ 0 ] ) { - __experimentalSelectBlock( ids[ 0 ] ); - } - } - : noop, + async ( clientIdsPromise ) => { + if ( __experimentalSelectBlock ) { + const ids = await clientIdsPromise; + if ( ids && ids[ 0 ] ) { + __experimentalSelectBlock( ids[ 0 ], false ); + } + } + }, [ __experimentalSelectBlock ] ); - const updateSelectionAfterRemove = useCallback( - __experimentalSelectBlock - ? () => { - const blockToSelect = - previousBlockClientId || - nextBlockClientId || - firstParentClientId; + const updateSelectionAfterRemove = useCallback( () => { + if ( __experimentalSelectBlock ) { + let blockToFocus = previousBlockClientId || firstParentClientId; - if ( - blockToSelect && - // From the block options dropdown, it's possible to remove a block that is not selected, - // in this case, it's not necessary to update the selection since the selected block wasn't removed. - selectedBlockClientIds.includes( firstBlockClientId ) && - // Don't update selection when next/prev block also is in the selection ( and gets removed ), - // In case someone selects all blocks and removes them at once. - ! selectedBlockClientIds.includes( blockToSelect ) - ) { - __experimentalSelectBlock( blockToSelect ); - } - } - : noop, - [ - __experimentalSelectBlock, - previousBlockClientId, - nextBlockClientId, - firstParentClientId, - selectedBlockClientIds, - ] - ); + // Focus the first block if there's no previous block nor parent block. + if ( ! blockToFocus ) { + blockToFocus = getBlockOrder()[ 0 ]; + } + + // Only update the selection if the original selection is removed. + const shouldUpdateSelection = + hasSelectedBlocks && getSelectedBlockClientIds().length === 0; + + __experimentalSelectBlock( blockToFocus, shouldUpdateSelection ); + } + }, [ + __experimentalSelectBlock, + previousBlockClientId, + firstParentClientId, + getBlockOrder, + hasSelectedBlocks, + getSelectedBlockClientIds, + ] ); const removeBlockLabel = count === 1 ? __( 'Delete' ) : __( 'Delete blocks' ); @@ -212,6 +209,49 @@ export function BlockSettingsDropdown( { className="block-editor-block-settings-menu" popoverProps={ POPOVER_PROPS } noIcons + menuProps={ { + /** + * @param {KeyboardEvent} event + */ + onKeyDown( event ) { + if ( event.defaultPrevented ) return; + + if ( + isMatch( 'core/block-editor/remove', event ) && + canRemove + ) { + event.preventDefault(); + updateSelectionAfterRemove( onRemove() ); + } else if ( + isMatch( + 'core/block-editor/duplicate', + event + ) && + canDuplicate + ) { + event.preventDefault(); + updateSelectionAfterDuplicate( onDuplicate() ); + } else if ( + isMatch( + 'core/block-editor/insert-after', + event + ) && + canInsertDefaultBlock + ) { + event.preventDefault(); + onInsertAfter(); + } else if ( + isMatch( + 'core/block-editor/insert-before', + event + ) && + canInsertDefaultBlock + ) { + event.preventDefault(); + onInsertBefore(); + } + }, + } } { ...props } > { ( { onClose } ) => ( diff --git a/packages/block-editor/src/components/list-view/block-select-button.js b/packages/block-editor/src/components/list-view/block-select-button.js index 068688a7d56030..ca5e414ae65769 100644 --- a/packages/block-editor/src/components/list-view/block-select-button.js +++ b/packages/block-editor/src/components/list-view/block-select-button.js @@ -13,7 +13,9 @@ import { } from '@wordpress/components'; import { forwardRef } from '@wordpress/element'; import { Icon, lockSmall as lock } from '@wordpress/icons'; -import { SPACE, ENTER } from '@wordpress/keycodes'; +import { SPACE, ENTER, BACKSPACE, DELETE } from '@wordpress/keycodes'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { __unstableUseShortcutEventMatch as useShortcutEventMatch } from '@wordpress/keyboard-shortcuts'; /** * Internal dependencies @@ -23,6 +25,7 @@ import useBlockDisplayInformation from '../use-block-display-information'; import useBlockDisplayTitle from '../block-title/use-block-display-title'; import ListViewExpander from './expander'; import { useBlockLock } from '../block-lock'; +import { store as blockEditorStore } from '../../store'; function ListViewBlockSelectButton( { @@ -38,6 +41,7 @@ function ListViewBlockSelectButton( isExpanded, ariaLabel, ariaDescribedBy, + updateFocusAndSelection, }, ref ) { @@ -47,6 +51,15 @@ function ListViewBlockSelectButton( context: 'list-view', } ); const { isLocked } = useBlockLock( clientId ); + const { + getSelectedBlockClientIds, + getPreviousBlockClientId, + getBlockRootClientId, + getBlockOrder, + canRemoveBlocks, + } = useSelect( blockEditorStore ); + const { removeBlocks } = useDispatch( blockEditorStore ); + const isMatch = useShortcutEventMatch(); // The `href` attribute triggers the browser's native HTML drag operations. // When the link is dragged, the element's outerHTML is set in DataTransfer object as text/html. @@ -57,9 +70,54 @@ function ListViewBlockSelectButton( onDragStart?.( event ); }; + /** + * @param {KeyboardEvent} event + */ function onKeyDownHandler( event ) { if ( event.keyCode === ENTER || event.keyCode === SPACE ) { onClick( event ); + } else if ( + event.keyCode === BACKSPACE || + event.keyCode === DELETE || + isMatch( 'core/block-editor/remove', event ) + ) { + const selectedBlockClientIds = getSelectedBlockClientIds(); + const isDeletingSelectedBlocks = + selectedBlockClientIds.includes( clientId ); + const firstBlockClientId = isDeletingSelectedBlocks + ? selectedBlockClientIds[ 0 ] + : clientId; + const firstBlockRootClientId = + getBlockRootClientId( firstBlockClientId ); + + const blocksToDelete = isDeletingSelectedBlocks + ? selectedBlockClientIds + : [ clientId ]; + + // Don't update the selection if the blocks cannot be deleted. + if ( ! canRemoveBlocks( blocksToDelete, firstBlockRootClientId ) ) { + return; + } + + let blockToFocus = + getPreviousBlockClientId( firstBlockClientId ) ?? + // If the previous block is not found (when the first block is deleted), + // fallback to focus the parent block. + firstBlockRootClientId; + + removeBlocks( blocksToDelete, false ); + + // Update the selection if the original selection has been removed. + const shouldUpdateSelection = + selectedBlockClientIds.length > 0 && + getSelectedBlockClientIds().length === 0; + + // If there's no previous block nor parent block, focus the first block. + if ( ! blockToFocus ) { + blockToFocus = getBlockOrder()[ 0 ]; + } + + updateFocusAndSelection( blockToFocus, shouldUpdateSelection ); } } diff --git a/packages/block-editor/src/components/list-view/block.js b/packages/block-editor/src/components/list-view/block.js index dc863dd337c0c3..20a385537f9b8e 100644 --- a/packages/block-editor/src/components/list-view/block.js +++ b/packages/block-editor/src/components/list-view/block.js @@ -22,6 +22,7 @@ import { } from '@wordpress/element'; import { useDispatch, useSelect } from '@wordpress/data'; import { sprintf, __ } from '@wordpress/i18n'; +import { focus } from '@wordpress/dom'; /** * Internal dependencies @@ -125,6 +126,7 @@ function ListViewBlock( { listViewInstanceId, expandedState, setInsertedBlock, + treeGridElementRef, } = useListViewContext(); const hasSiblings = siblingBlockCount > 0; @@ -165,11 +167,38 @@ function ListViewBlock( { [ clientId, selectBlock ] ); - const updateSelection = useCallback( - ( newClientId ) => { - selectBlock( undefined, newClientId ); + const updateFocusAndSelection = useCallback( + ( focusClientId, shouldSelectBlock ) => { + if ( shouldSelectBlock ) { + selectBlock( undefined, focusClientId, null, null ); + } + + const getFocusElement = () => { + const row = treeGridElementRef.current?.querySelector( + `[role=row][data-block="${ focusClientId }"]` + ); + if ( ! row ) return null; + // Focus the first focusable in the row, which is the ListViewBlockSelectButton. + return focus.focusable.find( row )[ 0 ]; + }; + + let focusElement = getFocusElement(); + if ( focusElement ) { + focusElement.focus(); + } else { + // The element hasn't been painted yet. Defer focusing on the next frame. + // This could happen when all blocks have been deleted and the default block + // hasn't been added to the editor yet. + window.requestAnimationFrame( () => { + focusElement = getFocusElement(); + // Ignore if the element still doesn't exist. + if ( focusElement ) { + focusElement.focus(); + } + } ); + } }, - [ selectBlock ] + [ selectBlock, treeGridElementRef ] ); const toggleExpanded = useCallback( @@ -266,6 +295,7 @@ function ListViewBlock( { selectedClientIds={ selectedClientIds } ariaLabel={ blockAriaLabel } ariaDescribedBy={ descriptionId } + updateFocusAndSelection={ updateFocusAndSelection } />
) } diff --git a/packages/block-editor/src/components/list-view/index.js b/packages/block-editor/src/components/list-view/index.js index 90d85ae8422def..ea637f0fe3131a 100644 --- a/packages/block-editor/src/components/list-view/index.js +++ b/packages/block-editor/src/components/list-view/index.js @@ -141,8 +141,13 @@ function ListViewComponent( setExpandedState, } ); const selectEditorBlock = useCallback( - ( event, blockClientId ) => { - updateBlockSelection( event, blockClientId ); + /** + * @param {MouseEvent | KeyboardEvent | undefined} event + * @param {string} blockClientId + * @param {null | undefined | -1 | 1} focusPosition + */ + ( event, blockClientId, focusPosition ) => { + updateBlockSelection( event, blockClientId, null, focusPosition ); setSelectedTreeId( blockClientId ); if ( onSelect ) { onSelect( getBlock( blockClientId ) ); @@ -222,6 +227,7 @@ function ListViewComponent( renderAdditionalBlockUI, insertedBlock, setInsertedBlock, + treeGridElementRef: elementRef, } ), [ draggedClientIds, diff --git a/packages/block-editor/src/components/list-view/use-block-selection.js b/packages/block-editor/src/components/list-view/use-block-selection.js index 716995edbdd53f..d1bf465d10a9c2 100644 --- a/packages/block-editor/src/components/list-view/use-block-selection.js +++ b/packages/block-editor/src/components/list-view/use-block-selection.js @@ -29,9 +29,9 @@ export default function useBlockSelection() { const { getBlockType } = useSelect( blocksStore ); const updateBlockSelection = useCallback( - async ( event, clientId, destinationClientId ) => { + async ( event, clientId, destinationClientId, focusPosition ) => { if ( ! event?.shiftKey ) { - selectBlock( clientId ); + selectBlock( clientId, focusPosition ); return; } diff --git a/test/e2e/specs/editor/various/list-view.spec.js b/test/e2e/specs/editor/various/list-view.spec.js index b7021752ea8cfb..971d571128bce7 100644 --- a/test/e2e/specs/editor/various/list-view.spec.js +++ b/test/e2e/specs/editor/various/list-view.spec.js @@ -4,6 +4,12 @@ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); test.describe( 'List View', () => { + test.use( { + listViewUtils: async ( { page, pageUtils, editor }, use ) => { + await use( new ListViewUtils( { page, pageUtils, editor } ) ); + }, + } ); + test.beforeEach( async ( { admin } ) => { await admin.createNewPost(); } ); @@ -115,146 +121,6 @@ test.describe( 'List View', () => { await expect( listView.getByRole( 'row' ) ).toHaveCount( 2 ); } ); - // Check for regression of https://github.com/WordPress/gutenberg/issues/39026. - test( 'selects the previous block after removing the selected one', async ( { - editor, - page, - pageUtils, - } ) => { - // Insert a couple of blocks of different types. - await editor.insertBlock( { name: 'core/image' } ); - await editor.insertBlock( { name: 'core/heading' } ); - await editor.insertBlock( { name: 'core/paragraph' } ); - - // Open List View. - await pageUtils.pressKeys( 'access+o' ); - const listView = page.getByRole( 'treegrid', { - name: 'Block navigation structure', - } ); - - // The last inserted block should be selected. - await expect( - listView.getByRole( 'gridcell', { - name: 'Paragraph', - exact: true, - selected: true, - } ) - ).toBeVisible(); - - // Remove the Paragraph block via its options menu in List View. - await listView - .getByRole( 'button', { name: 'Options for Paragraph' } ) - .click(); - await page.getByRole( 'menuitem', { name: /Delete/i } ).click(); - - // Heading block should be selected as previous block. - await expect( - editor.canvas.getByRole( 'document', { - name: 'Block: Heading', - } ) - ).toBeFocused(); - } ); - - // Check for regression of https://github.com/WordPress/gutenberg/issues/39026. - test( 'selects the next block after removing the very first block', async ( { - editor, - page, - pageUtils, - } ) => { - // Insert a couple of blocks of different types. - await editor.insertBlock( { name: 'core/image' } ); - await editor.insertBlock( { name: 'core/heading' } ); - await editor.insertBlock( { name: 'core/paragraph' } ); - - // Open List View. - await pageUtils.pressKeys( 'access+o' ); - const listView = page.getByRole( 'treegrid', { - name: 'Block navigation structure', - } ); - - // The last inserted block should be selected. - await expect( - listView.getByRole( 'gridcell', { - name: 'Paragraph', - exact: true, - selected: true, - } ) - ).toBeVisible(); - - // Select the image block in List View. - await pageUtils.pressKeys( 'ArrowUp', { times: 2 } ); - await expect( - listView.getByRole( 'link', { - name: 'Image', - } ) - ).toBeFocused(); - await page.keyboard.press( 'Enter' ); - - // Remove the Image block via its options menu in List View. - await listView - .getByRole( 'button', { name: 'Options for Image' } ) - .click(); - await page.getByRole( 'menuitem', { name: /Delete/i } ).click(); - - // Heading block should be selected as previous block. - await expect( - editor.canvas.getByRole( 'document', { - name: 'Block: Heading', - } ) - ).toBeFocused(); - } ); - - /** - * When all the blocks gets removed from the editor, it inserts a default - * paragraph block; make sure that paragraph block gets selected after - * removing blocks from ListView. - */ - test( 'selects the default paragraph block after removing all blocks', async ( { - editor, - page, - pageUtils, - } ) => { - // Insert a couple of blocks of different types. - await editor.insertBlock( { name: 'core/image' } ); - await editor.insertBlock( { name: 'core/heading' } ); - - // Open List View. - await pageUtils.pressKeys( 'access+o' ); - const listView = page.getByRole( 'treegrid', { - name: 'Block navigation structure', - } ); - - // The last inserted block should be selected. - await expect( - listView.getByRole( 'gridcell', { - name: 'Heading', - exact: true, - selected: true, - } ) - ).toBeVisible(); - - // Select the Image block as well. - await pageUtils.pressKeys( 'shift+ArrowUp' ); - await expect( - listView.getByRole( 'gridcell', { - name: 'Image', - exact: true, - selected: true, - } ) - ).toBeVisible(); - - // Remove both blocks. - await listView - .getByRole( 'button', { name: 'Options for Image' } ) - .click(); - await page.getByRole( 'menuitem', { name: /Delete blocks/i } ).click(); - - // Newly created paragraph block should be selected. - await expect( - editor.canvas.getByRole( 'document', { name: /Empty block/i } ) - ).toBeFocused(); - } ); - test( 'expands nested list items', async ( { editor, page, @@ -557,4 +423,418 @@ test.describe( 'List View', () => { } ) ).toBeFocused(); } ); + + test( 'should delete blocks using keyboard', async ( { + editor, + page, + pageUtils, + listViewUtils, + } ) => { + // Insert some blocks of different types. + await editor.insertBlock( { + name: 'core/group', + innerBlocks: [ { name: 'core/pullquote' } ], + } ); + await editor.insertBlock( { + name: 'core/columns', + innerBlocks: [ + { + name: 'core/column', + innerBlocks: [ + { name: 'core/heading' }, + { name: 'core/paragraph' }, + ], + }, + { + name: 'core/column', + innerBlocks: [ { name: 'core/verse' } ], + }, + ], + } ); + await editor.insertBlock( { name: 'core/file' } ); + + // Open List View. + const listView = await listViewUtils.openListView(); + + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'The last inserted block should be selected and focused.' + ) + .toMatchObject( [ + { name: 'core/group' }, + { name: 'core/columns' }, + { name: 'core/file', selected: true, focused: true }, + ] ); + + await page.keyboard.press( 'Delete' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting a block should move focus and selection to the previous block' + ) + .toMatchObject( [ + { name: 'core/group' }, + { name: 'core/columns', selected: true, focused: true }, + ] ); + + // Expand the current column. + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowDown' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Move focus but do not select the second column' + ) + .toMatchObject( [ + { name: 'core/group' }, + { + name: 'core/columns', + selected: true, + innerBlocks: [ + { name: 'core/column' }, + { name: 'core/column', focused: true }, + ], + }, + ] ); + + await page.keyboard.press( 'Delete' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting a inner block moves focus to the previous inner block' + ) + .toMatchObject( [ + { name: 'core/group' }, + { + name: 'core/columns', + selected: true, + innerBlocks: [ + { + name: 'core/column', + selected: false, + focused: true, + }, + ], + }, + ] ); + + // Expand the current column. + await page.keyboard.press( 'ArrowRight' ); + // Move focus and select the Heading block. + await listView + .getByRole( 'gridcell', { name: 'Heading', exact: true } ) + .dblclick(); + // Select both inner blocks in the column. + await page.keyboard.press( 'Shift+ArrowDown' ); + + await page.keyboard.press( 'Backspace' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting multiple blocks moves focus to the parent block' + ) + .toMatchObject( [ + { name: 'core/group' }, + { + name: 'core/columns', + innerBlocks: [ + { + name: 'core/column', + selected: true, + focused: true, + innerBlocks: [], + }, + ], + }, + ] ); + + // Move focus and select the first block. + await listView + .getByRole( 'gridcell', { name: 'Group', exact: true } ) + .dblclick(); + await page.keyboard.press( 'Backspace' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting the first block moves focus to the second block' + ) + .toMatchObject( [ + { + name: 'core/columns', + selected: true, + focused: true, + }, + ] ); + + // Keyboard shortcut should also work. + await pageUtils.pressKeys( 'access+z' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting the only block left will create a default block and focus/select it' + ) + .toMatchObject( [ + { + name: 'core/paragraph', + selected: true, + focused: true, + }, + ] ); + + await editor.insertBlock( { name: 'core/heading' } ); + await page.evaluate( () => + window.wp.data.dispatch( 'core/block-editor' ).clearSelectedBlock() + ); + await listView + .getByRole( 'gridcell', { name: 'Paragraph' } ) + .getByRole( 'link' ) + .focus(); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Block selection is cleared and focus is on the paragraph block' + ) + .toMatchObject( [ + { name: 'core/paragraph', selected: false, focused: true }, + { name: 'core/heading', selected: false }, + ] ); + + await pageUtils.pressKeys( 'access+z' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting blocks without existing selection will not select blocks' + ) + .toMatchObject( [ + { name: 'core/heading', selected: false, focused: true }, + ] ); + + // Insert a block that is locked and cannot be removed. + await editor.insertBlock( { + name: 'core/file', + attributes: { lock: { move: false, remove: true } }, + } ); + // Click on the Heading block to select it. + await listView + .getByRole( 'gridcell', { name: 'Heading', exact: true } ) + .click(); + await listView + .getByRole( 'gridcell', { name: 'File' } ) + .getByRole( 'link' ) + .focus(); + for ( const keys of [ 'Delete', 'Backspace', 'access+z' ] ) { + await pageUtils.pressKeys( keys ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Trying to delete locked blocks should not do anything' + ) + .toMatchObject( [ + { name: 'core/heading', selected: true, focused: false }, + { name: 'core/file', selected: false, focused: true }, + ] ); + } + } ); + + test( 'block settings dropdown menu', async ( { + editor, + page, + pageUtils, + listViewUtils, + } ) => { + // Insert some blocks of different types. + await editor.insertBlock( { name: 'core/heading' } ); + await editor.insertBlock( { name: 'core/file' } ); + + // Open List View. + const listView = await listViewUtils.openListView(); + + await listView + .getByRole( 'button', { name: 'Options for Heading' } ) + .click(); + + await page + .getByRole( 'menu', { name: 'Options for Heading' } ) + .getByRole( 'menuitem', { name: 'Duplicate' } ) + .click(); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Should duplicate a block and move focus' + ) + .toMatchObject( [ + { name: 'core/heading', selected: false }, + { name: 'core/heading', selected: false, focused: true }, + { name: 'core/file', selected: true }, + ] ); + + await page.keyboard.press( 'Shift+ArrowUp' ); + await listView + .getByRole( 'button', { name: 'Options for Heading' } ) + .first() + .click(); + await page + .getByRole( 'menu', { name: 'Options for Heading' } ) + .getByRole( 'menuitem', { name: 'Delete blocks' } ) + .click(); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Should delete multiple selected blocks using the dropdown menu' + ) + .toMatchObject( [ + { name: 'core/file', selected: true, focused: true }, + ] ); + + await page.keyboard.press( 'ArrowRight' ); + const optionsForFileToggle = listView + .getByRole( 'row' ) + .filter( { + has: page.getByRole( 'gridcell', { name: 'File' } ), + } ) + .getByRole( 'button', { name: 'Options for File' } ); + const optionsForFileMenu = page.getByRole( 'menu', { + name: 'Options for File', + } ); + await expect( + optionsForFileToggle, + 'Pressing arrow right should move focus to the menu dropdown toggle button' + ).toBeFocused(); + + await page.keyboard.press( 'Enter' ); + await expect( + optionsForFileMenu, + 'Pressing Enter should open the menu dropdown' + ).toBeVisible(); + + await page.keyboard.press( 'Escape' ); + await expect( + optionsForFileMenu, + 'Pressing Escape should close the menu dropdown' + ).toBeHidden(); + await expect( + optionsForFileToggle, + 'Should move focus back to the toggle button' + ).toBeFocused(); + + await page.keyboard.press( 'Space' ); + await expect( + optionsForFileMenu, + 'Pressing Space should also open the menu dropdown' + ).toBeVisible(); + + await pageUtils.pressKeys( 'primaryAlt+t' ); // Keyboard shortcut for Insert before. + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Pressing keyboard shortcut should also work when the menu is opened and focused' + ) + .toMatchObject( [ + { name: 'core/paragraph', selected: true, focused: false }, + { name: 'core/file', selected: false, focused: false }, + ] ); + await expect( + optionsForFileMenu, + 'The menu should be closed after pressing keyboard shortcut' + ).toBeHidden(); + + await optionsForFileToggle.click(); + await pageUtils.pressKeys( 'access+z' ); // Keyboard shortcut for Delete. + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Deleting blocks should move focus and selection' + ) + .toMatchObject( [ + { name: 'core/paragraph', selected: true, focused: true }, + ] ); + + // Insert a block that is locked and cannot be removed. + await editor.insertBlock( { + name: 'core/file', + attributes: { lock: { move: false, remove: true } }, + } ); + await optionsForFileToggle.click(); + await expect( + optionsForFileMenu.getByRole( 'menuitem', { name: 'Delete' } ), + 'The delete menu item should be hidden for locked blocks' + ).toBeHidden(); + await pageUtils.pressKeys( 'access+z' ); + await expect + .poll( + listViewUtils.getBlocksWithA11yAttributes, + 'Pressing keyboard shortcut should not delete locked blocks either' + ) + .toMatchObject( [ + { name: 'core/paragraph' }, + { name: 'core/file', selected: true }, + ] ); + await expect( + optionsForFileMenu, + 'The dropdown menu should also be visible' + ).toBeVisible(); + } ); } ); + +/** @typedef {import('@playwright/test').Locator} Locator */ +class ListViewUtils { + #page; + #pageUtils; + #editor; + + constructor( { page, pageUtils, editor } ) { + this.#page = page; + this.#pageUtils = pageUtils; + this.#editor = editor; + + /** @type {Locator} */ + this.listView = page.getByRole( 'treegrid', { + name: 'Block navigation structure', + } ); + } + + /** + * @return {Promise} The list view locator. + */ + openListView = async () => { + await this.#pageUtils.pressKeys( 'access+o' ); + return this.listView; + }; + + getBlocksWithA11yAttributes = async () => { + const selectedRows = await this.listView + .getByRole( 'row' ) + .filter( { + has: this.#page.getByRole( 'gridcell', { selected: true } ), + } ) + .all(); + const selectedClientIds = await Promise.all( + selectedRows.map( ( row ) => row.getAttribute( 'data-block' ) ) + ); + const focusedRows = await this.listView + .getByRole( 'row' ) + .filter( { has: this.#page.locator( ':focus' ) } ) + .all(); + const focusedClientId = + focusedRows.length > 0 + ? await focusedRows[ focusedRows.length - 1 ].getAttribute( + 'data-block' + ) + : null; + // Don't use the util to get the unmodified default block when it's empty. + const blocks = await this.#page.evaluate( () => + window.wp.data.select( 'core/block-editor' ).getBlocks() + ); + function recursivelyApplyAttributes( _blocks ) { + return _blocks.map( ( block ) => ( { + name: block.name, + selected: selectedClientIds.includes( block.clientId ), + focused: block.clientId === focusedClientId, + innerBlocks: recursivelyApplyAttributes( block.innerBlocks ), + } ) ); + } + return recursivelyApplyAttributes( blocks ); + }; +} From d5f04b7a14c964caede1ccb5afad56c0e22187a8 Mon Sep 17 00:00:00 2001 From: Jarda Snajdr Date: Sun, 28 May 2023 11:47:17 +0200 Subject: [PATCH 31/91] Refactor code that adds resolver fulfillment to selector calls (#51009) * mapSelector: always look at canonical mappedResolvers * fulfillResolver: doesn't need to check resolver existence * fulfillSelector: doesn't need to be async * fulfillSelector: make it common for all mapped selectors * fulfillResolver: inline into fulfillSelector * fulfillSelector: add missing setTimeout param * mapResolvers: map resolvers and resolving selectors separately --- packages/data/src/redux-store/index.js | 175 +++++++++++-------------- 1 file changed, 75 insertions(+), 100 deletions(-) diff --git a/packages/data/src/redux-store/index.js b/packages/data/src/redux-store/index.js index c63c8fa4a6557a..11bda27c128ac1 100644 --- a/packages/data/src/redux-store/index.js +++ b/packages/data/src/redux-store/index.js @@ -176,7 +176,6 @@ export default function createReduxStore( key, options ) { lock( store, privateRegistrationFunctions ); const resolversCache = createResolversCache(); - let resolvers; const actions = mapActions( { ...metadataActions, @@ -236,15 +235,15 @@ export default function createReduxStore( key, options ) { } ) ); + let resolvers; if ( options.resolvers ) { - const result = mapResolvers( - options.resolvers, + resolvers = mapResolvers( options.resolvers ); + selectors = mapSelectorsWithResolvers( selectors, + resolvers, store, resolversCache ); - resolvers = result.resolvers; - selectors = result.selectors; } const resolveSelectors = mapResolveSelectors( selectors, store ); @@ -504,20 +503,13 @@ function mapSuspendSelectors( selectors, store ) { } /** - * Returns resolvers with matched selectors for a given namespace. - * Resolvers are side effects invoked once per argument set of a given selector call, - * used in ensuring that the data needs for the selector are satisfied. + * Convert resolvers to a normalized form, an object with `fulfill` method and + * optional methods like `isFulfilled`. * - * @param {Object} resolvers Resolvers to register. - * @param {Object} selectors The current selectors to be modified. - * @param {Object} store The redux store to which the resolvers should be mapped. - * @param {Object} resolversCache Resolvers Cache. + * @param {Object} resolvers Resolver to convert */ -function mapResolvers( resolvers, selectors, store, resolversCache ) { - // The `resolver` can be either a function that does the resolution, or, in more advanced - // cases, an object with a `fullfill` method and other optional methods like `isFulfilled`. - // Here we normalize the `resolver` function to an object with `fulfill` method. - const mappedResolvers = mapValues( resolvers, ( resolver ) => { +function mapResolvers( resolvers ) { + return mapValues( resolvers, ( resolver ) => { if ( resolver.fulfill ) { return resolver; } @@ -527,99 +519,82 @@ function mapResolvers( resolvers, selectors, store, resolversCache ) { fulfill: resolver, // Add the fulfill method. }; } ); +} - const mapSelector = ( selector, selectorName ) => { - const resolver = resolvers[ selectorName ]; - if ( ! resolver ) { - selector.hasResolver = false; - return selector; +/** + * Returns resolvers with matched selectors for a given namespace. + * Resolvers are side effects invoked once per argument set of a given selector call, + * used in ensuring that the data needs for the selector are satisfied. + * + * @param {Object} selectors The current selectors to be modified. + * @param {Object} resolvers Resolvers to register. + * @param {Object} store The redux store to which the resolvers should be mapped. + * @param {Object} resolversCache Resolvers Cache. + */ +function mapSelectorsWithResolvers( + selectors, + resolvers, + store, + resolversCache +) { + function fulfillSelector( resolver, selectorName, args ) { + const state = store.getState(); + + if ( + resolversCache.isRunning( selectorName, args ) || + ( typeof resolver.isFulfilled === 'function' && + resolver.isFulfilled( state, ...args ) ) + ) { + return; } - const selectorResolver = ( ...args ) => { - async function fulfillSelector() { - const state = store.getState(); - - if ( - resolversCache.isRunning( selectorName, args ) || - ( typeof resolver.isFulfilled === 'function' && - resolver.isFulfilled( state, ...args ) ) - ) { - return; - } + const { metadata } = store.__unstableOriginalGetState(); - const { metadata } = store.__unstableOriginalGetState(); - - if ( - metadataSelectors.hasStartedResolution( - metadata, - selectorName, - args - ) - ) { - return; - } + if ( + metadataSelectors.hasStartedResolution( + metadata, + selectorName, + args + ) + ) { + return; + } - resolversCache.markAsRunning( selectorName, args ); + resolversCache.markAsRunning( selectorName, args ); - setTimeout( async () => { - resolversCache.clear( selectorName, args ); - store.dispatch( - metadataActions.startResolution( selectorName, args ) - ); - try { - await fulfillResolver( - store, - mappedResolvers, - selectorName, - ...args - ); - store.dispatch( - metadataActions.finishResolution( - selectorName, - args - ) - ); - } catch ( error ) { - store.dispatch( - metadataActions.failResolution( - selectorName, - args, - error - ) - ); - } - } ); + setTimeout( async () => { + resolversCache.clear( selectorName, args ); + store.dispatch( + metadataActions.startResolution( selectorName, args ) + ); + try { + const action = resolver.fulfill( ...args ); + if ( action ) { + await store.dispatch( action ); + } + store.dispatch( + metadataActions.finishResolution( selectorName, args ) + ); + } catch ( error ) { + store.dispatch( + metadataActions.failResolution( selectorName, args, error ) + ); } + }, 0 ); + } + + return mapValues( selectors, ( selector, selectorName ) => { + const resolver = resolvers[ selectorName ]; + if ( ! resolver ) { + selector.hasResolver = false; + return selector; + } - fulfillSelector( ...args ); + const selectorResolver = ( ...args ) => { + fulfillSelector( resolver, selectorName, args ); return selector( ...args ); }; selectorResolver.hasResolver = true; return selectorResolver; - }; - - return { - resolvers: mappedResolvers, - selectors: mapValues( selectors, mapSelector ), - }; -} - -/** - * Calls a resolver given arguments - * - * @param {Object} store Store reference, for fulfilling via resolvers - * @param {Object} resolvers Store Resolvers - * @param {string} selectorName Selector name to fulfill. - * @param {Array} args Selector Arguments. - */ -async function fulfillResolver( store, resolvers, selectorName, ...args ) { - const resolver = resolvers[ selectorName ]; - if ( ! resolver ) { - return; - } - - const action = resolver.fulfill( ...args ); - if ( action ) { - await store.dispatch( action ); - } + } ); } From 4c60efe0d991c202b6bdc2f93d56e4a8273ab074 Mon Sep 17 00:00:00 2001 From: Glen Davies Date: Mon, 29 May 2023 11:38:53 +1200 Subject: [PATCH 32/91] Site: editor: Add view site link to site editor nav (#50420) --- .../src/components/site-hub/index.js | 23 +++++++++++++--- .../src/components/site-hub/style.scss | 26 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/edit-site/src/components/site-hub/index.js b/packages/edit-site/src/components/site-hub/index.js index b477c5b14f1622..520ff5db2c2dd3 100644 --- a/packages/edit-site/src/components/site-hub/index.js +++ b/packages/edit-site/src/components/site-hub/index.js @@ -19,7 +19,7 @@ import { store as blockEditorStore } from '@wordpress/block-editor'; import { store as coreStore } from '@wordpress/core-data'; import { decodeEntities } from '@wordpress/html-entities'; import { forwardRef } from '@wordpress/element'; -import { search } from '@wordpress/icons'; +import { search, external } from '@wordpress/icons'; import { privateApis as commandsPrivateApis } from '@wordpress/commands'; /** @@ -34,15 +34,20 @@ const { store: commandsStore } = unlock( commandsPrivateApis ); const HUB_ANIMATION_DURATION = 0.3; const SiteHub = forwardRef( ( props, ref ) => { - const { canvasMode, dashboardLink } = useSelect( ( select ) => { + const { canvasMode, dashboardLink, homeUrl } = useSelect( ( select ) => { const { getCanvasMode, getSettings } = unlock( select( editSiteStore ) ); + const { + getUnstableBase, // Site index. + } = select( coreStore ); + return { canvasMode: getCanvasMode(), dashboardLink: getSettings().__experimentalDashboardLink || 'index.php', + homeUrl: getUnstableBase()?.home, }; }, [] ); const { open: openCommandCenter } = useDispatch( commandsStore ); @@ -87,7 +92,11 @@ const SiteHub = forwardRef( ( props, ref ) => { ease: 'easeOut', } } > - + { { decodeEntities( siteTitle ) } + ' + ' . $img[0] . '
'; $body_content = preg_replace( '/]+>/', $button, $content ); - // For the modal, set an ID on the image to be used for an aria-labelledby attribute. - $modal_content = new WP_HTML_Tag_Processor( $content ); - $modal_content->next_tag( 'img' ); - $image_lightbox_id = $modal_content->get_attribute( 'class' ) . '-lightbox'; - $modal_content->set_attribute( 'id', $image_lightbox_id ); - $modal_content = $modal_content->get_updated_html(); - $background_color = wp_get_global_styles( array( 'color', 'background' ) ); $close_button_icon = ''; + $dialog_label = $alt_attribute ? $alt_attribute : __( 'Image' ); + + $close_button_label = __( 'Close' ); + return << - - $modal_content + $content
diff --git a/test/e2e/specs/editor/blocks/image.spec.js b/test/e2e/specs/editor/blocks/image.spec.js index f1c566d7ae1d94..48ece12d8f3472 100644 --- a/test/e2e/specs/editor/blocks/image.spec.js +++ b/test/e2e/specs/editor/blocks/image.spec.js @@ -831,14 +831,12 @@ test.describe( 'Image - interactivity', () => { const image = lightbox.locator( 'img' ); await expect( image ).toHaveAttribute( 'src', new RegExp( filename ) ); - await page - .getByRole( 'button', { name: 'Open image lightbox' } ) - .click(); + await page.getByRole( 'button', { name: 'Enlarge image' } ).click(); await expect( lightbox ).toBeVisible(); - const closeButton = page.getByRole( 'button', { - name: 'Close lightbox', + const closeButton = lightbox.getByRole( 'button', { + name: 'Close', } ); await closeButton.click(); @@ -860,11 +858,11 @@ test.describe( 'Image - interactivity', () => { await page.goto( `/?p=${ postId }` ); openLightboxButton = page.getByRole( 'button', { - name: 'Open image lightbox', + name: 'Enlarge image', } ); lightbox = page.getByRole( 'dialog' ); closeButton = lightbox.getByRole( 'button', { - name: 'Close lightbox', + name: 'Close', } ); } ); From 0cceaced4b037586a2e0b8f194cc320d65aeb9c7 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Mon, 29 May 2023 14:18:38 +0400 Subject: [PATCH 38/91] Don't use global 'select' in the Behaviors controls component (#51028) --- packages/block-editor/src/hooks/behaviors.js | 138 +++++++++++-------- 1 file changed, 77 insertions(+), 61 deletions(-) diff --git a/packages/block-editor/src/hooks/behaviors.js b/packages/block-editor/src/hooks/behaviors.js index 8e6ce479f174d1..706d3638e345ac 100644 --- a/packages/block-editor/src/hooks/behaviors.js +++ b/packages/block-editor/src/hooks/behaviors.js @@ -5,7 +5,7 @@ import { addFilter } from '@wordpress/hooks'; import { SelectControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { createHigherOrderComponent } from '@wordpress/compose'; -import { select } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -18,6 +18,66 @@ import { store as blockEditorStore } from '../store'; */ import merge from 'deepmerge'; +function BehaviorsControl( { blockName, blockBehaviors, onChange } ) { + const { settings, themeBehaviors } = useSelect( + ( select ) => { + const { getBehaviors, getSettings } = select( blockEditorStore ); + + return { + settings: + getSettings()?.__experimentalFeatures?.blocks?.[ blockName ] + ?.behaviors, + themeBehaviors: getBehaviors()?.blocks?.[ blockName ], + }; + }, + [ blockName ] + ); + + if ( + ! settings || + // If every behavior is disabled, do not show the behaviors inspector control. + Object.entries( settings ).every( ( [ , value ] ) => ! value ) + ) { + return null; + } + + // Block behaviors take precedence over theme behaviors. + const behaviors = merge( themeBehaviors, blockBehaviors || {} ); + + const noBehaviorsOption = { + value: '', + label: __( 'No behaviors' ), + }; + + const behaviorsOptions = Object.entries( settings ) + .filter( ( [ , behaviorValue ] ) => behaviorValue ) // Filter out behaviors that are disabled. + .map( ( [ behaviorName ] ) => ( { + value: behaviorName, + label: + // Capitalize the first letter of the behavior name. + behaviorName[ 0 ].toUpperCase() + + behaviorName.slice( 1 ).toLowerCase(), + } ) ); + + const options = [ noBehaviorsOption, ...behaviorsOptions ]; + + return ( + + + + ); +} + /** * Override the default edit UI to include a new block inspector control for * assigning behaviors to blocks if behaviors are enabled in the theme.json. @@ -30,72 +90,28 @@ import merge from 'deepmerge'; */ export const withBehaviors = createHigherOrderComponent( ( BlockEdit ) => { return ( props ) => { + const blockEdit = ; // Only add behaviors to the core/image block. if ( props.name !== 'core/image' ) { - return ; + return blockEdit; } - const settings = - select( blockEditorStore ).getSettings()?.__experimentalFeatures - ?.blocks?.[ props.name ]?.behaviors; - if ( - ! settings || - // If every behavior is disabled, do not show the behaviors inspector control. - Object.entries( settings ).every( ( [ , value ] ) => ! value ) - ) { - return ; - } - - const { behaviors: blockBehaviors } = props.attributes; - - // Get the theme behaviors for the block from the theme.json. - const themeBehaviors = - select( blockEditorStore ).getBehaviors()?.blocks?.[ props.name ]; - - // Block behaviors take precedence over theme behaviors. - const behaviors = merge( themeBehaviors, blockBehaviors || {} ); - - const noBehaviorsOption = { - value: '', - label: __( 'No behaviors' ), - }; - - const behaviorsOptions = Object.entries( settings ) - .filter( ( [ , behaviorValue ] ) => behaviorValue ) // Filter out behaviors that are disabled. - .map( ( [ behaviorName ] ) => ( { - value: behaviorName, - label: - // Capitalize the first letter of the behavior name. - behaviorName[ 0 ].toUpperCase() + - behaviorName.slice( 1 ).toLowerCase(), - } ) ); - - const options = [ noBehaviorsOption, ...behaviorsOptions ]; - return ( <> - - - { - // If the user selects something, it means that they want to - // change the default value (true) so we save it in the attributes. - props.setAttributes( { - behaviors: { - lightbox: nextValue === 'lightbox', - }, - } ); - } } - hideCancelButton={ true } - help={ __( 'Add behaviors.' ) } - size="__unstable-large" - /> - + { blockEdit } + { + // If the user selects something, it means that they want to + // change the default value (true) so we save it in the attributes. + props.setAttributes( { + behaviors: { + lightbox: nextValue === 'lightbox', + }, + } ); + } } + /> ); }; From dd992244e90f7c722247da2400c01fd98a25ee65 Mon Sep 17 00:00:00 2001 From: Nick Diego Date: Mon, 29 May 2023 05:23:20 -0500 Subject: [PATCH 39/91] Social Links block: Add color classes so icon colors correctly reflect changes in Global Styles (#51020) * Add color classes. * Linting. --- .../block-library/src/social-link/block.json | 2 ++ .../block-library/src/social-link/edit.js | 11 ++++++++- .../block-library/src/social-link/index.php | 23 ++++++++++++++++++- .../block-library/src/social-links/block.json | 2 ++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/block-library/src/social-link/block.json b/packages/block-library/src/social-link/block.json index e81894591b4b33..140cc123ec484c 100644 --- a/packages/block-library/src/social-link/block.json +++ b/packages/block-library/src/social-link/block.json @@ -24,7 +24,9 @@ "usesContext": [ "openInNewTab", "showLabels", + "iconColor", "iconColorValue", + "iconBackgroundColor", "iconBackgroundColorValue" ], "supports": { diff --git a/packages/block-library/src/social-link/edit.js b/packages/block-library/src/social-link/edit.js index 738d70e3bdd5ee..110bc397136992 100644 --- a/packages/block-library/src/social-link/edit.js +++ b/packages/block-library/src/social-link/edit.js @@ -92,10 +92,19 @@ const SocialLinkEdit = ( { clientId, } ) => { const { url, service, label, rel } = attributes; - const { showLabels, iconColorValue, iconBackgroundColorValue } = context; + const { + showLabels, + iconColor, + iconColorValue, + iconBackgroundColor, + iconBackgroundColorValue, + } = context; const [ showURLPopover, setPopover ] = useState( false ); const classes = classNames( 'wp-social-link', 'wp-social-link-' + service, { 'wp-social-link__is-incomplete': ! url, + [ `has-${ iconColor }-color` ]: iconColor, + [ `has-${ iconBackgroundColor }-background-color` ]: + iconBackgroundColor, } ); // Use internal state instead of a ref to make sure that the component diff --git a/packages/block-library/src/social-link/index.php b/packages/block-library/src/social-link/index.php index 53af6a2e5f485a..51ed9374c9bcdf 100644 --- a/packages/block-library/src/social-link/index.php +++ b/packages/block-library/src/social-link/index.php @@ -47,7 +47,7 @@ function render_block_core_social_link( $attributes, $content, $block ) { $icon = block_core_social_link_get_icon( $service ); $wrapper_attributes = get_block_wrapper_attributes( array( - 'class' => 'wp-social-link wp-social-link-' . $service, + 'class' => 'wp-social-link wp-social-link-' . $service . block_core_social_link_get_color_classes( $block->context ), 'style' => block_core_social_link_get_color_styles( $block->context ), ) ); @@ -337,3 +337,24 @@ function block_core_social_link_get_color_styles( $context ) { return implode( '', $styles ); } + +/** + * Returns CSS classes for icon and icon background colors. + * + * @param array $context Block context passed to Social Sharing Link. + * + * @return string CSS classes for link's icon and background colors. + */ +function block_core_social_link_get_color_classes( $context ) { + $classes = array(); + + if ( array_key_exists( 'iconColor', $context ) ) { + $classes[] = 'has-' . $context['iconColor'] . '-color'; + } + + if ( array_key_exists( 'iconBackgroundColor', $context ) ) { + $classes[] = 'has-' . $context['iconBackgroundColor'] . '-background-color'; + } + + return ' ' . implode( ' ', $classes ); +} diff --git a/packages/block-library/src/social-links/block.json b/packages/block-library/src/social-links/block.json index a7707cf1951345..0e47209f0b8b10 100644 --- a/packages/block-library/src/social-links/block.json +++ b/packages/block-library/src/social-links/block.json @@ -41,7 +41,9 @@ "providesContext": { "openInNewTab": "openInNewTab", "showLabels": "showLabels", + "iconColor": "iconColor", "iconColorValue": "iconColorValue", + "iconBackgroundColor": "iconBackgroundColor", "iconBackgroundColorValue": "iconBackgroundColorValue" }, "supports": { From 41907dc315d46e2c74b2fcbb6467761e425d437a Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Mon, 29 May 2023 12:31:58 +0100 Subject: [PATCH 40/91] Fix multi-entity multi-property undo redo (#50911) --- docs/reference-guides/data/data-core.md | 4 + packages/core-data/README.md | 4 + packages/core-data/src/actions.js | 17 +- packages/core-data/src/index.js | 1 - packages/core-data/src/private-selectors.ts | 30 +++ packages/core-data/src/reducer.js | 234 +++++++++++--------- packages/core-data/src/selectors.ts | 46 ++-- packages/core-data/src/test/reducer.js | 143 +++++++----- packages/core-data/src/test/selectors.js | 16 +- test/e2e/specs/editor/various/undo.spec.js | 29 +++ 10 files changed, 336 insertions(+), 188 deletions(-) create mode 100644 packages/core-data/src/private-selectors.ts diff --git a/docs/reference-guides/data/data-core.md b/docs/reference-guides/data/data-core.md index 1ee04e09550e2d..8db3a26dd09772 100644 --- a/docs/reference-guides/data/data-core.md +++ b/docs/reference-guides/data/data-core.md @@ -358,6 +358,8 @@ _Returns_ ### getRedoEdit +> **Deprecated** since 6.3 + Returns the next edit from the current undo offset for the entity records edits history, if any. _Parameters_ @@ -401,6 +403,8 @@ _Returns_ ### getUndoEdit +> **Deprecated** since 6.3 + Returns the previous edit from the current undo offset for the entity records edits history, if any. _Parameters_ diff --git a/packages/core-data/README.md b/packages/core-data/README.md index dddc3550e03b26..d2ac90a4a5165b 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -535,6 +535,8 @@ _Returns_ ### getRedoEdit +> **Deprecated** since 6.3 + Returns the next edit from the current undo offset for the entity records edits history, if any. _Parameters_ @@ -578,6 +580,8 @@ _Returns_ ### getUndoEdit +> **Deprecated** since 6.3 + Returns the previous edit from the current undo offset for the entity records edits history, if any. _Parameters_ diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index ffae417a83cd13..cfab95aae9f8fc 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -18,6 +18,7 @@ import { receiveItems, removeItems, receiveQueriedItems } from './queried-data'; import { getOrLoadEntitiesConfig, DEFAULT_ENTITY_KEY } from './entities'; import { createBatch } from './batch'; import { STORE_NAME } from './name'; +import { getUndoEdits, getRedoEdits } from './private-selectors'; /** * Returns an action object used in signalling that authors have been received. @@ -406,14 +407,14 @@ export const editEntityRecord = export const undo = () => ( { select, dispatch } ) => { - const undoEdit = select.getUndoEdit(); + // Todo: we shouldn't have to pass "root" here. + const undoEdit = select( ( state ) => getUndoEdits( state.root ) ); if ( ! undoEdit ) { return; } dispatch( { - type: 'EDIT_ENTITY_RECORD', - ...undoEdit, - meta: { isUndo: true }, + type: 'UNDO', + stackedEdits: undoEdit, } ); }; @@ -424,14 +425,14 @@ export const undo = export const redo = () => ( { select, dispatch } ) => { - const redoEdit = select.getRedoEdit(); + // Todo: we shouldn't have to pass "root" here. + const redoEdit = select( ( state ) => getRedoEdits( state.root ) ); if ( ! redoEdit ) { return; } dispatch( { - type: 'EDIT_ENTITY_RECORD', - ...redoEdit, - meta: { isRedo: true }, + type: 'REDO', + stackedEdits: redoEdit, } ); }; diff --git a/packages/core-data/src/index.js b/packages/core-data/src/index.js index 43fa4a0b3cd074..c2b491fa8c1ea1 100644 --- a/packages/core-data/src/index.js +++ b/packages/core-data/src/index.js @@ -62,7 +62,6 @@ const storeConfig = () => ( { * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/data/README.md#createReduxStore */ export const store = createReduxStore( STORE_NAME, storeConfig() ); - register( store ); export { default as EntityProvider } from './entity-provider'; diff --git a/packages/core-data/src/private-selectors.ts b/packages/core-data/src/private-selectors.ts new file mode 100644 index 00000000000000..0ac2c750239692 --- /dev/null +++ b/packages/core-data/src/private-selectors.ts @@ -0,0 +1,30 @@ +/** + * Internal dependencies + */ +import type { State, UndoEdit } from './selectors'; + +type Optional< T > = T | undefined; + +/** + * Returns the previous edit from the current undo offset + * for the entity records edits history, if any. + * + * @param state State tree. + * + * @return The edit. + */ +export function getUndoEdits( state: State ): Optional< UndoEdit[] > { + return state.undo.list[ state.undo.list.length - 1 + state.undo.offset ]; +} + +/** + * Returns the next edit from the current undo offset + * for the entity records edits history, if any. + * + * @param state State tree. + * + * @return The edit. + */ +export function getRedoEdits( state: State ): Optional< UndoEdit[] > { + return state.undo.list[ state.undo.list.length + state.undo.offset ]; +} diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index f04d543919b8c8..b7dd9d73df15a7 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -183,6 +183,30 @@ export function themeGlobalStyleVariations( state = {}, action ) { return state; } +const withMultiEntityRecordEdits = ( reducer ) => ( state, action ) => { + if ( action.type === 'UNDO' || action.type === 'REDO' ) { + const { stackedEdits } = action; + + let newState = state; + stackedEdits.forEach( + ( { kind, name, recordId, property, from, to } ) => { + newState = reducer( newState, { + type: 'EDIT_ENTITY_RECORD', + kind, + name, + recordId, + edits: { + [ property ]: action.type === 'UNDO' ? from : to, + }, + } ); + } + ); + return newState; + } + + return reducer( state, action ); +}; + /** * Higher Order Reducer for a given entity config. It supports: * @@ -196,6 +220,8 @@ export function themeGlobalStyleVariations( state = {}, action ) { */ function entity( entityConfig ) { return compose( [ + withMultiEntityRecordEdits, + // Limit to matching action type so we don't attempt to replace action on // an unhandled action. ifMatchingAction( @@ -411,8 +437,9 @@ export const entities = ( state = {}, action ) => { /** * @typedef {Object} UndoStateMeta * - * @property {number} offset Where in the undo stack we are. - * @property {Object} [flattenedUndo] Flattened form of undo stack. + * @property {number} list The undo stack. + * @property {number} offset Where in the undo stack we are. + * @property {Object} cache Cache of unpersisted transient edits. */ /** @typedef {Array & UndoStateMeta} UndoState */ @@ -422,10 +449,7 @@ export const entities = ( state = {}, action ) => { * * @todo Given how we use this we might want to make a custom class for it. */ -const UNDO_INITIAL_STATE = Object.assign( [], { offset: 0 } ); - -/** @type {Object} */ -let lastEditAction; +const UNDO_INITIAL_STATE = { list: [], offset: 0 }; /** * Reducer keeping track of entity edit undo history. @@ -436,107 +460,114 @@ let lastEditAction; * @return {UndoState} Updated state. */ export function undo( state = UNDO_INITIAL_STATE, action ) { + const omitPendingRedos = ( currentState ) => { + return { + ...currentState, + list: currentState.list.slice( + 0, + currentState.offset || undefined + ), + offset: 0, + }; + }; + + const appendCachedEditsToLastUndo = ( currentState ) => { + if ( ! currentState.cache ) { + return currentState; + } + + let nextState = { + ...currentState, + list: [ ...currentState.list ], + }; + nextState = omitPendingRedos( nextState ); + const previousUndoState = nextState.list.pop(); + const updatedUndoState = currentState.cache.reduce( + appendEditToStack, + previousUndoState + ); + nextState.list.push( updatedUndoState ); + + return { + ...nextState, + cache: undefined, + }; + }; + + const appendEditToStack = ( + stack = [], + { kind, name, recordId, property, from, to } + ) => { + const existingEditIndex = stack?.findIndex( + ( { kind: k, name: n, recordId: r, property: p } ) => { + return ( + k === kind && n === name && r === recordId && p === property + ); + } + ); + const nextStack = [ ...stack ]; + if ( existingEditIndex !== -1 ) { + // If the edit is already in the stack leave the initial "from" value. + nextStack[ existingEditIndex ] = { + ...nextStack[ existingEditIndex ], + to, + }; + } else { + nextStack.push( { + kind, + name, + recordId, + property, + from, + to, + } ); + } + return nextStack; + }; + switch ( action.type ) { - case 'EDIT_ENTITY_RECORD': case 'CREATE_UNDO_LEVEL': - let isCreateUndoLevel = action.type === 'CREATE_UNDO_LEVEL'; - const isUndoOrRedo = - ! isCreateUndoLevel && - ( action.meta.isUndo || action.meta.isRedo ); - if ( isCreateUndoLevel ) { - action = lastEditAction; - } else if ( ! isUndoOrRedo ) { - // Don't lose the last edit cache if the new one only has transient edits. - // Transient edits don't create new levels so updating the cache would make - // us skip an edit later when creating levels explicitly. - if ( - Object.keys( action.edits ).some( - ( key ) => ! action.transientEdits[ key ] - ) - ) { - lastEditAction = action; - } else { - lastEditAction = { - ...action, - edits: { - ...( lastEditAction && lastEditAction.edits ), - ...action.edits, - }, - }; - } - } + return appendCachedEditsToLastUndo( state ); - /** @type {UndoState} */ - let nextState; - - if ( isUndoOrRedo ) { - // @ts-ignore we might consider using Object.assign({}, state) - nextState = [ ...state ]; - nextState.offset = - state.offset + ( action.meta.isUndo ? -1 : 1 ); - - if ( state.flattenedUndo ) { - // The first undo in a sequence of undos might happen while we have - // flattened undos in state. If this is the case, we want execution - // to continue as if we were creating an explicit undo level. This - // will result in an extra undo level being appended with the flattened - // undo values. - // We also have to take into account if the `lastEditAction` had opted out - // of being tracked in undo history, like the action that persists the latest - // content right before saving. In that case we have to update the `lastEditAction` - // to avoid returning early before applying the existing flattened undos. - isCreateUndoLevel = true; - if ( ! lastEditAction.meta.undo ) { - lastEditAction.meta.undo = { - edits: {}, - }; - } - action = lastEditAction; - } else { - return nextState; - } - } + case 'UNDO': + case 'REDO': { + const nextState = appendCachedEditsToLastUndo( state ); + return { + ...nextState, + offset: state.offset + ( action.type === 'UNDO' ? -1 : 1 ), + }; + } + case 'EDIT_ENTITY_RECORD': { if ( ! action.meta.undo ) { return state; } - // Transient edits don't create an undo level, but are - // reachable in the next meaningful edit to which they - // are merged. They are defined in the entity's config. - if ( - ! isCreateUndoLevel && - ! Object.keys( action.edits ).some( - ( key ) => ! action.transientEdits[ key ] - ) - ) { - // @ts-ignore we might consider using Object.assign({}, state) - nextState = [ ...state ]; - nextState.flattenedUndo = { - ...state.flattenedUndo, - ...action.edits, + const isCachedChange = Object.keys( action.edits ).every( + ( key ) => action.transientEdits[ key ] + ); + + const edits = Object.keys( action.edits ).map( ( key ) => { + return { + kind: action.kind, + name: action.name, + recordId: action.recordId, + property: key, + from: action.meta.undo.edits[ key ], + to: action.edits[ key ], }; - nextState.offset = state.offset; - return nextState; - } + } ); - // Clear potential redos, because this only supports linear history. - nextState = - // @ts-ignore this needs additional cleanup, probably involving code-level changes - nextState || state.slice( 0, state.offset || undefined ); - nextState.offset = nextState.offset || 0; - nextState.pop(); - if ( ! isCreateUndoLevel ) { - nextState.push( { - kind: action.meta.undo.kind, - name: action.meta.undo.name, - recordId: action.meta.undo.recordId, - edits: { - ...state.flattenedUndo, - ...action.meta.undo.edits, - }, - } ); + if ( isCachedChange ) { + return { + ...state, + cache: edits.reduce( appendEditToStack, state.cache ), + }; } + + let nextState = omitPendingRedos( state ); + nextState = appendCachedEditsToLastUndo( nextState ); + nextState = { ...nextState, list: [ ...nextState.list ] }; // When an edit is a function it's an optimization to avoid running some expensive operation. // We can't rely on the function references being the same so we opt out of comparing them here. const comparisonUndoEdits = Object.values( @@ -546,16 +577,11 @@ export function undo( state = UNDO_INITIAL_STATE, action ) { ( edit ) => typeof edit !== 'function' ); if ( ! isShallowEqual( comparisonUndoEdits, comparisonEdits ) ) { - nextState.push( { - kind: action.kind, - name: action.name, - recordId: action.recordId, - edits: isCreateUndoLevel - ? { ...state.flattenedUndo, ...action.edits } - : action.edits, - } ); + nextState.list.push( edits ); } + return nextState; + } } return state; diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 7513d918109673..a6b7774d37094c 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -22,6 +22,7 @@ import { setNestedValue, } from './utils'; import type * as ET from './entity-types'; +import { getUndoEdits, getRedoEdits } from './private-selectors'; // This is an incomplete, high-level approximation of the State type. // It makes the selectors slightly more safe, but is intended to evolve @@ -73,9 +74,18 @@ interface EntityConfig { kind: string; } -interface UndoState extends Array< Object > { - flattenedUndo: unknown; +export interface UndoEdit { + name: string; + kind: string; + recordId: string; + from: any; + to: any; +} + +interface UndoState { + list: Array< UndoEdit[] >; offset: number; + cache: UndoEdit[]; } interface UserState { @@ -884,24 +894,38 @@ function getCurrentUndoOffset( state: State ): number { * Returns the previous edit from the current undo offset * for the entity records edits history, if any. * - * @param state State tree. + * @deprecated since 6.3 + * + * @param state State tree. * * @return The edit. */ export function getUndoEdit( state: State ): Optional< any > { - return state.undo[ state.undo.length - 2 + getCurrentUndoOffset( state ) ]; + deprecated( "select( 'core' ).getUndoEdit()", { + since: '6.3', + } ); + return state.undo.list[ + state.undo.list.length - 2 + getCurrentUndoOffset( state ) + ]?.[ 0 ]; } /** * Returns the next edit from the current undo offset * for the entity records edits history, if any. * - * @param state State tree. + * @deprecated since 6.3 + * + * @param state State tree. * * @return The edit. */ export function getRedoEdit( state: State ): Optional< any > { - return state.undo[ state.undo.length + getCurrentUndoOffset( state ) ]; + deprecated( "select( 'core' ).getRedoEdit()", { + since: '6.3', + } ); + return state.undo.list[ + state.undo.list.length + getCurrentUndoOffset( state ) + ]?.[ 0 ]; } /** @@ -913,7 +937,7 @@ export function getRedoEdit( state: State ): Optional< any > { * @return Whether there is a previous edit or not. */ export function hasUndo( state: State ): boolean { - return Boolean( getUndoEdit( state ) ); + return Boolean( getUndoEdits( state ) ); } /** @@ -925,7 +949,7 @@ export function hasUndo( state: State ): boolean { * @return Whether there is a next edit or not. */ export function hasRedo( state: State ): boolean { - return Boolean( getRedoEdit( state ) ); + return Boolean( getRedoEdits( state ) ); } /** @@ -1142,11 +1166,7 @@ export const hasFetchedAutosaves = createRegistrySelector( export const getReferenceByDistinctEdits = createSelector( // This unused state argument is listed here for the documentation generating tool (docgen). ( state: State ) => [], - ( state: State ) => [ - state.undo.length, - state.undo.offset, - state.undo.flattenedUndo, - ] + ( state: State ) => [ state.undo.list.length, state.undo.offset ] ); /** diff --git a/packages/core-data/src/test/reducer.js b/packages/core-data/src/test/reducer.js index 63caf5fb83b177..4f7d9b9c0d2aec 100644 --- a/packages/core-data/src/test/reducer.js +++ b/packages/core-data/src/test/reducer.js @@ -143,28 +143,34 @@ describe( 'entities', () => { } ); describe( 'undo', () => { - let lastEdits; + let lastValues; let undoState; let expectedUndoState; - const createEditActionPart = ( edits ) => ( { + + const createExpectedDiff = ( property, { from, to } ) => ( { kind: 'someKind', name: 'someName', recordId: 'someRecordId', - edits, + property, + from, + to, } ); const createNextEditAction = ( edits, transientEdits = {} ) => { let action = { - ...createEditActionPart( edits ), + kind: 'someKind', + name: 'someName', + recordId: 'someRecordId', + edits, transientEdits, }; action = { type: 'EDIT_ENTITY_RECORD', ...action, meta: { - undo: { ...action, edits: lastEdits }, + undo: { edits: lastValues }, }, }; - lastEdits = { ...lastEdits, ...edits }; + lastValues = { ...lastValues, ...edits }; return action; }; const createNextUndoState = ( ...args ) => { @@ -172,17 +178,17 @@ describe( 'undo', () => { if ( args[ 0 ] === 'isUndo' || args[ 0 ] === 'isRedo' ) { // We need to "apply" the undo level here and build // the action to move the offset. - lastEdits = - undoState[ - undoState.length + - undoState.offset - - ( args[ 0 ] === 'isUndo' ? 2 : 0 ) - ].edits; + const lastEdits = + undoState.list[ + undoState.list.length - + ( args[ 0 ] === 'isUndo' ? 1 : 0 ) + + undoState.offset + ]; + lastEdits.forEach( ( { property, from, to } ) => { + lastValues[ property ] = args[ 0 ] === 'isUndo' ? from : to; + } ); action = { - type: 'EDIT_ENTITY_RECORD', - meta: { - [ args[ 0 ] ]: true, - }, + type: args[ 0 ] === 'isUndo' ? 'UNDO' : 'REDO', }; } else if ( args[ 0 ] === 'isCreate' ) { action = { type: 'CREATE_UNDO_LEVEL' }; @@ -192,10 +198,9 @@ describe( 'undo', () => { return deepFreeze( undo( undoState, action ) ); }; beforeEach( () => { - lastEdits = {}; + lastValues = {}; undoState = undefined; - expectedUndoState = []; - expectedUndoState.offset = 0; + expectedUndoState = { list: [], offset: 0 }; } ); it( 'initializes', () => { @@ -208,19 +213,41 @@ describe( 'undo', () => { // Check that the first edit creates an undo level for the current state and // one for the new one. undoState = createNextUndoState( { value: 1 } ); - expectedUndoState.push( - createEditActionPart( {} ), - createEditActionPart( { value: 1 } ) - ); + expectedUndoState.list.push( [ + createExpectedDiff( 'value', { from: undefined, to: 1 } ), + ] ); expect( undoState ).toEqual( expectedUndoState ); // Check that the second and third edits just create an undo level for // themselves. undoState = createNextUndoState( { value: 2 } ); - expectedUndoState.push( createEditActionPart( { value: 2 } ) ); + expectedUndoState.list.push( [ + createExpectedDiff( 'value', { from: 1, to: 2 } ), + ] ); expect( undoState ).toEqual( expectedUndoState ); undoState = createNextUndoState( { value: 3 } ); - expectedUndoState.push( createEditActionPart( { value: 3 } ) ); + expectedUndoState.list.push( [ + createExpectedDiff( 'value', { from: 2, to: 3 } ), + ] ); + expect( undoState ).toEqual( expectedUndoState ); + } ); + + it( 'stacks multi-property undo levels', () => { + undoState = createNextUndoState(); + + undoState = createNextUndoState( { value: 1 } ); + undoState = createNextUndoState( { value2: 2 } ); + expectedUndoState.list.push( + [ createExpectedDiff( 'value', { from: undefined, to: 1 } ) ], + [ createExpectedDiff( 'value2', { from: undefined, to: 2 } ) ] + ); + expect( undoState ).toEqual( expectedUndoState ); + + // Check that that creating another undo level merges the "edits" + undoState = createNextUndoState( { value: 2 } ); + expectedUndoState.list.push( [ + createExpectedDiff( 'value', { from: 1, to: 2 } ), + ] ); expect( undoState ).toEqual( expectedUndoState ); } ); @@ -229,11 +256,10 @@ describe( 'undo', () => { undoState = createNextUndoState( { value: 1 } ); undoState = createNextUndoState( { value: 2 } ); undoState = createNextUndoState( { value: 3 } ); - expectedUndoState.push( - createEditActionPart( {} ), - createEditActionPart( { value: 1 } ), - createEditActionPart( { value: 2 } ), - createEditActionPart( { value: 3 } ) + expectedUndoState.list.push( + [ createExpectedDiff( 'value', { from: undefined, to: 1 } ) ], + [ createExpectedDiff( 'value', { from: 1, to: 2 } ) ], + [ createExpectedDiff( 'value', { from: 2, to: 3 } ) ] ); expect( undoState ).toEqual( expectedUndoState ); @@ -255,17 +281,22 @@ describe( 'undo', () => { // Check that another edit will go on top when there // is no undo level offset. undoState = createNextUndoState( { value: 4 } ); - expectedUndoState.push( createEditActionPart( { value: 4 } ) ); + expectedUndoState.list.push( [ + createExpectedDiff( 'value', { from: 3, to: 4 } ), + ] ); expect( undoState ).toEqual( expectedUndoState ); // Check that undoing and editing will slice of // all the levels after the current one. undoState = createNextUndoState( 'isUndo' ); undoState = createNextUndoState( 'isUndo' ); + undoState = createNextUndoState( { value: 5 } ); - expectedUndoState.pop(); - expectedUndoState.pop(); - expectedUndoState.push( createEditActionPart( { value: 5 } ) ); + expectedUndoState.list.pop(); + expectedUndoState.list.pop(); + expectedUndoState.list.push( [ + createExpectedDiff( 'value', { from: 2, to: 5 } ), + ] ); expect( undoState ).toEqual( expectedUndoState ); } ); @@ -277,10 +308,15 @@ describe( 'undo', () => { { transientValue: true } ); undoState = createNextUndoState( { value: 3 } ); - expectedUndoState.push( - createEditActionPart( {} ), - createEditActionPart( { value: 1, transientValue: 2 } ), - createEditActionPart( { value: 3 } ) + expectedUndoState.list.push( + [ + createExpectedDiff( 'value', { from: undefined, to: 1 } ), + createExpectedDiff( 'transientValue', { + from: undefined, + to: 2, + } ), + ], + [ createExpectedDiff( 'value', { from: 1, to: 3 } ) ] ); expect( undoState ).toEqual( expectedUndoState ); } ); @@ -292,10 +328,9 @@ describe( 'undo', () => { // transient edits. undoState = createNextUndoState( { value: 1 } ); undoState = createNextUndoState( 'isCreate' ); - expectedUndoState.push( - createEditActionPart( {} ), - createEditActionPart( { value: 1 } ) - ); + expectedUndoState.list.push( [ + createExpectedDiff( 'value', { from: undefined, to: 1 } ), + ] ); expect( undoState ).toEqual( expectedUndoState ); // Check that transient edits are merged into the last @@ -305,18 +340,19 @@ describe( 'undo', () => { { transientValue: true } ); undoState = createNextUndoState( 'isCreate' ); - expectedUndoState[ - expectedUndoState.length - 1 - ].edits.transientValue = 2; + expectedUndoState.list[ expectedUndoState.list.length - 1 ].push( + createExpectedDiff( 'transientValue', { from: undefined, to: 2 } ) + ); expect( undoState ).toEqual( expectedUndoState ); - // Check that undo levels are created with the latest action, - // even if undone. + // Check that create after undo does nothing. undoState = createNextUndoState( { value: 3 } ); undoState = createNextUndoState( 'isUndo' ); undoState = createNextUndoState( 'isCreate' ); - expectedUndoState.pop(); - expectedUndoState.push( createEditActionPart( { value: 3 } ) ); + expectedUndoState.list.push( [ + createExpectedDiff( 'value', { from: 1, to: 3 } ), + ] ); + expectedUndoState.offset = -1; expect( undoState ).toEqual( expectedUndoState ); } ); @@ -328,10 +364,10 @@ describe( 'undo', () => { { transientValue: true } ); undoState = createNextUndoState( 'isUndo' ); - expectedUndoState.push( - createEditActionPart( {} ), - createEditActionPart( { value: 1, transientValue: 2 } ) - ); + expectedUndoState.list.push( [ + createExpectedDiff( 'value', { from: undefined, to: 1 } ), + createExpectedDiff( 'transientValue', { from: undefined, to: 2 } ), + ] ); expectedUndoState.offset--; expect( undoState ).toEqual( expectedUndoState ); } ); @@ -341,7 +377,6 @@ describe( 'undo', () => { undoState = createNextUndoState(); undoState = createNextUndoState( { value } ); undoState = createNextUndoState( { value: () => {} } ); - expectedUndoState.push( createEditActionPart( { value } ) ); expect( undoState ).toEqual( expectedUndoState ); } ); } ); diff --git a/packages/core-data/src/test/selectors.js b/packages/core-data/src/test/selectors.js index 0ea9e26e505437..84fecc7d07cda9 100644 --- a/packages/core-data/src/test/selectors.js +++ b/packages/core-data/src/test/selectors.js @@ -838,20 +838,20 @@ describe( 'getCurrentUser', () => { describe( 'getReferenceByDistinctEdits', () => { it( 'should return referentially equal values across empty states', () => { - const state = { undo: [] }; + const state = { undo: { list: [] } }; expect( getReferenceByDistinctEdits( state ) ).toBe( getReferenceByDistinctEdits( state ) ); - const beforeState = { undo: [] }; - const afterState = { undo: [] }; + const beforeState = { undo: { list: [] } }; + const afterState = { undo: { list: [] } }; expect( getReferenceByDistinctEdits( beforeState ) ).toBe( getReferenceByDistinctEdits( afterState ) ); } ); it( 'should return referentially equal values across unchanging non-empty state', () => { - const undoStates = [ {} ]; + const undoStates = { list: [ {} ] }; const state = { undo: undoStates }; expect( getReferenceByDistinctEdits( state ) ).toBe( getReferenceByDistinctEdits( state ) @@ -866,9 +866,9 @@ describe( 'getReferenceByDistinctEdits', () => { describe( 'when adding edits', () => { it( 'should return referentially different values across changing states', () => { - const beforeState = { undo: [ {} ] }; + const beforeState = { undo: { list: [ {} ] } }; beforeState.undo.offset = 0; - const afterState = { undo: [ {}, {} ] }; + const afterState = { undo: { list: [ {}, {} ] } }; afterState.undo.offset = 1; expect( getReferenceByDistinctEdits( beforeState ) ).not.toBe( getReferenceByDistinctEdits( afterState ) @@ -878,9 +878,9 @@ describe( 'getReferenceByDistinctEdits', () => { describe( 'when using undo', () => { it( 'should return referentially different values across changing states', () => { - const beforeState = { undo: [ {}, {} ] }; + const beforeState = { undo: { list: [ {}, {} ] } }; beforeState.undo.offset = 1; - const afterState = { undo: [ {}, {} ] }; + const afterState = { undo: { list: [ {}, {} ] } }; afterState.undo.offset = 0; expect( getReferenceByDistinctEdits( beforeState ) ).not.toBe( getReferenceByDistinctEdits( afterState ) diff --git a/test/e2e/specs/editor/various/undo.spec.js b/test/e2e/specs/editor/various/undo.spec.js index 29b34ea416ff29..a4b68e1036dcf5 100644 --- a/test/e2e/specs/editor/various/undo.spec.js +++ b/test/e2e/specs/editor/various/undo.spec.js @@ -455,6 +455,35 @@ test.describe( 'undo', () => { }, ] ); } ); + + // @see https://github.com/WordPress/gutenberg/issues/12075 + test( 'should be able to undo and redo property cross property changes', async ( { + page, + pageUtils, + } ) => { + await page.getByRole( 'textbox', { name: 'Add title' } ).type( 'a' ); // First step. + await page.keyboard.press( 'Backspace' ); // Second step. + await page.getByRole( 'button', { name: 'Add default block' } ).click(); // third step. + + // Title should be empty + await expect( + page.getByRole( 'textbox', { name: 'Add title' } ) + ).toHaveText( '' ); + + // First undo removes the block. + // Second undo restores the title. + await pageUtils.pressKeys( 'primary+z' ); + await pageUtils.pressKeys( 'primary+z' ); + await expect( + page.getByRole( 'textbox', { name: 'Add title' } ) + ).toHaveText( 'a' ); + + // Redoing the "backspace" should clear the title again. + await pageUtils.pressKeys( 'primaryShift+z' ); + await expect( + page.getByRole( 'textbox', { name: 'Add title' } ) + ).toHaveText( '' ); + } ); } ); class UndoUtils { From 5912a6017956ab834a5aa1da811f6a274e0119fd Mon Sep 17 00:00:00 2001 From: Gutenberg Repository Automation Date: Mon, 29 May 2023 12:27:03 +0000 Subject: [PATCH 41/91] Bump plugin version to 15.9.0-rc.2 --- gutenberg.php | 2 +- package-lock.json | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gutenberg.php b/gutenberg.php index 9c9e324756258d..b3dfd934b68a28 100644 --- a/gutenberg.php +++ b/gutenberg.php @@ -5,7 +5,7 @@ * Description: Printing since 1440. This is the development plugin for the block editor, site editor, and other future WordPress core functionality. * Requires at least: 6.1 * Requires PHP: 5.6 - * Version: 15.9.0-rc.1 + * Version: 15.9.0-rc.2 * Author: Gutenberg Team * Text Domain: gutenberg * diff --git a/package-lock.json b/package-lock.json index 8067c573107d55..963bea8a9dc2f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "15.9.0-rc.1", + "version": "15.9.0-rc.2", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 8eccfbe28a79f9..42746000729e8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "15.9.0-rc.1", + "version": "15.9.0-rc.2", "private": true, "description": "A new WordPress editor experience.", "author": "The WordPress Contributors", From e49343dd84ba165e0f747b293d5cc21b5d64253b Mon Sep 17 00:00:00 2001 From: Gutenberg Repository Automation Date: Mon, 29 May 2023 12:56:23 +0000 Subject: [PATCH 42/91] Update Changelog for 15.9.0-rc.2 --- changelog.txt | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/changelog.txt b/changelog.txt index a86bea24af4db0..a5748fef9c8cce 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,37 @@ == Changelog == += 15.9.0-rc.2 = + +## Changelog + +### Bug Fixes + +#### Patterns +- Library: Revert description change until new grid view lands. ([51039](https://github.com/WordPress/gutenberg/pull/51039)) + +#### Block library +- Social Links: Add color classes so icon colors correctly reflect changes in Global Styles. ([51020](https://github.com/WordPress/gutenberg/pull/51020)) +- Image: Improve the image block lightbox translations, labelling, and escaping. ([50962](https://github.com/WordPress/gutenberg/pull/50962)) + +#### Block editor + +- Don't use global 'select' in the Behaviors controls component. ([51028](https://github.com/WordPress/gutenberg/pull/51028)) +- Lightbox UI appearing with interactivity experiment disabled. ([51025](https://github.com/WordPress/gutenberg/pull/51025)) +- Move "No Behaviors" to be the first option in the list of behaviors. ([50979](https://github.com/WordPress/gutenberg/pull/50979)) +- Revert "Browse Mode: Add snackbar notices. ([50937](https://github.com/WordPress/gutenberg/pull/50937)) + +## First time contributors + +The following PRs were merged by first time contributors: + + +## Contributors + +The following contributors merged PRs in this release: + +@aaronrobertshaw @afercia @andrewserong @c4rl0sbr4v0 @Mamaduka @michalczaplinski @ndiego @talldan + + = 15.9.0-rc.1 = There has been an error with the Github action that creates the backlog. It will be updated as soon as possible. More info From c885bb15e1793d6ef861fbb4d2f9fedc84d71dce Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Mon, 29 May 2023 15:52:20 +0100 Subject: [PATCH 43/91] Add documentation about the entites abstraction and the undo/redo stack (#51052) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: André <583546+oandregal@users.noreply.github.com> --- docs/explanations/architecture/README.md | 1 + docs/explanations/architecture/entities.md | 63 ++++++++++++++++++++++ docs/manifest.json | 6 +++ docs/toc.json | 1 + 4 files changed, 71 insertions(+) create mode 100644 docs/explanations/architecture/entities.md diff --git a/docs/explanations/architecture/README.md b/docs/explanations/architecture/README.md index 774df183618d4a..2cecdfd70e2d6f 100644 --- a/docs/explanations/architecture/README.md +++ b/docs/explanations/architecture/README.md @@ -6,6 +6,7 @@ Let’s look at the big picture and the architectural and UX principles of the b - [Key concepts](/docs/explanations/architecture/key-concepts.md). - [Data format and data flow](/docs/explanations/architecture/data-flow.md). +- [Entities and undo/redo](/docs/explanations/architecture/entities.md). - [Site editing templates](/docs/explanations/architecture/full-site-editing-templates.md). - [Styles in the editor](/docs/explanations/architecture/styles.md). - [Performance](/docs/explanations/architecture/performance.md). diff --git a/docs/explanations/architecture/entities.md b/docs/explanations/architecture/entities.md new file mode 100644 index 00000000000000..368d0a73f87b65 --- /dev/null +++ b/docs/explanations/architecture/entities.md @@ -0,0 +1,63 @@ +# Entities and Undo/Redo. + +The WordPress editors, whether it's the post or site editor, manipulate what we call entity records. These are objects that represent a post, a page, a user, a term, a template, etc. They are the data that is stored in the database and that is manipulated by the editor. Each editor can fetch, edit and save multiple entity records at the same time. + +For instance, when opening a page in the site editor: + - you can edit properties of the page itself (title, content...) + - you can edit properties of the template of the page (content of the template, design...) + - you can edit properties of template parts (header, footer) used with the template. + +The editor keeps track of all these modifications and orchestrates the saving of all these modified records. This happens within the `@wordpress/core-data` package. + + +## Editing entities + +To be able to edit an entity, you need to first fetch it and load it into the `core-data` store. For example, the following code loads the post with ID 1 into the store. (The entity is the post, the post 1 is the entity record). + +````js +wp.data.dispatch( 'core' ).getEntityRecord( 'postType', 'post', 1 ); +```` + +Once the entity is loaded, you can edit it. For example, the following code sets the title of the post to "Hello World". For each fetched entity record, the `core-data` store keeps track of: + - the "persisted" record: The last state of the record as it was fetched from the backend. + - A list of "edits": Unsaved local modifications for one or several properties of the record. + +The package also exposes a set of actions to manipulate the fetched entity records. + +To fetch an entity record, you can call `editEntityRecord`, which takes the entity type, the entity ID and the new entity record as parameters. The following example sets the title of the post with ID 1 to "Hello World". + +````js +wp.data.dispatch( 'core' ).editEntityRecord( 'postType', 'post', 1, { title: 'Hello World' } ); +```` + +Once you have edited an entity record, you can save it. The following code saves the post with ID 1. + +````js +wp.data.dispatch( 'core' ).saveEditedEntityRecord( 'postType', 'post', 1 ); +```` + +## Undo/Redo + +Since the WordPress editors allow multiple entity records to be edited at the same time, the `core-data` package keeps track of all the entity records that have been fetched and edited in a common undo/redo stack. Each step in the undo/redo stack contains a list of "edits" that should be undone or redone at the same time when calling the `undo` or `redo` action. + +And to be able to perform both undo and redo operations propertly, each modification in the list of edits contains the following information: + + - Entity kind and name: Each entity in core-data is identified by a tuple kind, name. This corresponds to the identifier of the modified entity. + - Entity Record ID: The ID of the modified record. + - Property: The name of the modified property. + - From: The previous value of the property (needed to apply the undo operation). + - To: The new value of the property (needed to apply the redo operation). + +For example, let's say a user edits the title of a post, followed by a modification to the post slug, and then a modification of the title of a reusable block used with the post for instance. The following information is stored in the undo/redo stack: + + - `[ { kind: 'postType', name: 'post', id: 1, property: 'title', from: '', to: 'Hello World' } ]` + - `[ { kind: 'postType', name: 'post', id: 1, property: 'slug', from: 'Previous slug', to: 'This is the slug of the hello world post' } ]` + - `[ { kind: 'postType', name: 'wp_block', id: 2, property: 'title', from: 'Reusable Block', to: 'Awesome Reusable Block' } ]` + +The store also keep tracks of a "pointer" to the current "undo/redo" step. By default, the pointer always points to the last item in the stack. This pointer is updated when the user performs an undo or redo operation. + +### Transient changes + +The undo/redo core behavior also supports what we call "transient modifications". These are modifications that are not stored in the undo/redo stack right away. For instance, when a user starts typing in a text field, the value of the field is modified in the store, but this modification is not stored in the undo/redo stack until after the user moves to the next word or after a few milliseconds. This is done to avoid creating a new undo/redo step for each character typed by the user. + +So by default, `core-data` store considers all modifications to properties that are marked as "transient" (like the `blocks` property in the post entity) as transient modifications. It keeps these modifications outside the undo/redo stack in what is called a "cache" of modifications and these modifications are only stored in the undo/redo stack when we explicitely call `__unstableCreateUndoLevel` or when the next non transient modification is performed. diff --git a/docs/manifest.json b/docs/manifest.json index d6759f051a6791..f10b4dbe06a171 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -2039,6 +2039,12 @@ "markdown_source": "../docs/explanations/architecture/data-flow.md", "parent": "architecture" }, + { + "title": "Entities and Undo/Redo.", + "slug": "entities", + "markdown_source": "../docs/explanations/architecture/entities.md", + "parent": "architecture" + }, { "title": "Modularity", "slug": "modularity", diff --git a/docs/toc.json b/docs/toc.json index 7b48e68cbab564..d9acd5fbb38aee 100644 --- a/docs/toc.json +++ b/docs/toc.json @@ -302,6 +302,7 @@ "docs/explanations/architecture/README.md": [ { "docs/explanations/architecture/key-concepts.md": [] }, { "docs/explanations/architecture/data-flow.md": [] }, + { "docs/explanations/architecture/entities.md": [] }, { "docs/explanations/architecture/modularity.md": [] }, { "docs/explanations/architecture/performance.md": [] }, { From 2746bda978aea81f84570f393ac7bfcb61a81d47 Mon Sep 17 00:00:00 2001 From: Nik Tsekouras Date: Mon, 29 May 2023 19:08:25 +0300 Subject: [PATCH 44/91] Properly decode new template title in snackbar (#51057) --- .../edit-site/src/components/add-new-template/new-template.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/edit-site/src/components/add-new-template/new-template.js b/packages/edit-site/src/components/add-new-template/new-template.js index 0a3037603226fc..a0ea141bda52e3 100644 --- a/packages/edit-site/src/components/add-new-template/new-template.js +++ b/packages/edit-site/src/components/add-new-template/new-template.js @@ -13,6 +13,7 @@ import { __experimentalText as Text, __experimentalVStack as VStack, } from '@wordpress/components'; +import { decodeEntities } from '@wordpress/html-entities'; import { useState } from '@wordpress/element'; import { useDispatch } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; @@ -140,7 +141,7 @@ export default function NewTemplate( { sprintf( // translators: %s: Title of the created template e.g: "Category". __( '"%s" successfully created.' ), - newTemplate.title?.rendered || title + decodeEntities( newTemplate.title?.rendered || title ) ), { type: 'snackbar', From 069d5415233663a35dfa8fd1032d44de1da47817 Mon Sep 17 00:00:00 2001 From: Maggie Date: Mon, 29 May 2023 18:24:38 +0200 Subject: [PATCH 45/91] Link control: migrate tests to Playwright. Can be created by selecting text and using keyboard shortcuts (#50996) * initial setup * fixed utils * update snapshots, commented waitForURLFieldAutoFocus * removed old test * removed unneeded code * removed old snapshots * use locator instead of component class * remove appender function * use locators instead of tabbing around * rename button * avoid snapshots and fix locator for Save button * removed snapshots * added comment --- .../various/__snapshots__/links.test.js.snap | 12 ---- .../specs/editor/various/links.test.js | 42 ----------- test/e2e/specs/editor/blocks/links.spec.js | 69 +++++++++++++++++++ 3 files changed, 69 insertions(+), 54 deletions(-) create mode 100644 test/e2e/specs/editor/blocks/links.spec.js diff --git a/packages/e2e-tests/specs/editor/various/__snapshots__/links.test.js.snap b/packages/e2e-tests/specs/editor/various/__snapshots__/links.test.js.snap index 7b54b5bd2f598f..542f3ed2133db4 100644 --- a/packages/e2e-tests/specs/editor/various/__snapshots__/links.test.js.snap +++ b/packages/e2e-tests/specs/editor/various/__snapshots__/links.test.js.snap @@ -12,18 +12,6 @@ exports[`Links can be created by selecting text and clicking Link 1`] = ` " `; -exports[`Links can be created by selecting text and using keyboard shortcuts 1`] = ` -" -

This is Gutenberg

-" -`; - -exports[`Links can be created by selecting text and using keyboard shortcuts 2`] = ` -" -

This is Gutenberg

-" -`; - exports[`Links can be created instantly when a URL is selected 1`] = ` "

This is Gutenberg: https://wordpress.org/gutenberg

diff --git a/packages/e2e-tests/specs/editor/various/links.test.js b/packages/e2e-tests/specs/editor/various/links.test.js index 9d8bddff952b60..5920f8463277bb 100644 --- a/packages/e2e-tests/specs/editor/various/links.test.js +++ b/packages/e2e-tests/specs/editor/various/links.test.js @@ -103,48 +103,6 @@ describe( 'Links', () => { expect( urlInputValue ).toBe( '' ); } ); - it( 'can be created by selecting text and using keyboard shortcuts', async () => { - // Create a block with some text. - await clickBlockAppender(); - await page.keyboard.type( 'This is Gutenberg' ); - - // Select some text. - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - - // Press Cmd+K to insert a link. - await pressKeyWithModifier( 'primary', 'K' ); - - // Wait for the URL field to auto-focus. - await waitForURLFieldAutoFocus(); - - // Type a URL. - await page.keyboard.type( 'https://wordpress.org/gutenberg' ); - - // Open settings. - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Space' ); - - // Navigate to and toggle the "Open in new tab" checkbox. - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Space' ); - - // Toggle should still have focus and be checked. - await page.waitForSelector( - ':focus:checked.components-checkbox-control__input' - ); - - // Ensure that the contents of the post have not been changed, since at - // this point the link is still not inserted. - expect( await getEditedPostContent() ).toMatchSnapshot(); - - // Tab back to the Submit and apply the link. - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Enter' ); - - // The link should have been inserted. - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - it( 'can be created without any text selected', async () => { // Create a block with some text. await clickBlockAppender(); diff --git a/test/e2e/specs/editor/blocks/links.spec.js b/test/e2e/specs/editor/blocks/links.spec.js new file mode 100644 index 00000000000000..1e03da549c2347 --- /dev/null +++ b/test/e2e/specs/editor/blocks/links.spec.js @@ -0,0 +1,69 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Links', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test( `can be created by selecting text and using keyboard shortcuts`, async ( { + page, + editor, + pageUtils, + } ) => { + // Create a block with some text. + await editor.insertBlock( { + name: 'core/paragraph', + } ); + await page.keyboard.type( 'This is Gutenberg' ); + + // Select some text. + await pageUtils.pressKeys( 'shiftAlt+ArrowLeft' ); + + // Press Cmd+K to insert a link. + await pageUtils.pressKeys( 'primary+K' ); + + // Type a URL. + await page.keyboard.type( 'https://wordpress.org/gutenberg' ); + + // Open settings. + await page.getByRole( 'button', { name: 'Link Settings' } ).click(); + + // Navigate to and toggle the "Open in new tab" checkbox. + const checkbox = page.getByLabel( 'Open in new tab' ); + await checkbox.click(); + + // Toggle should still have focus and be checked. + await expect( checkbox ).toBeChecked(); + await expect( checkbox ).toBeFocused(); + + // Ensure that the contents of the post have not been changed, since at + // this point the link is still not inserted. + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'This is Gutenberg' }, + }, + ] ); + + // Tab back to the Submit and apply the link. + await page + //TODO: change to a better selector when https://github.com/WordPress/gutenberg/issues/51060 is resolved. + .locator( '.block-editor-link-control' ) + .getByRole( 'button', { name: 'Save' } ) + .click(); + + // The link should have been inserted. + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: + 'This is Gutenberg', + }, + }, + ] ); + } ); +} ); From 62f23538c968f863d91d4eeb610b927b28f8879f Mon Sep 17 00:00:00 2001 From: Maggie Date: Mon, 29 May 2023 19:29:26 +0200 Subject: [PATCH 46/91] Link Control test migration: Should contain a label when it should open in a new tab (#51001) * fixed utils * update snapshots, commented waitForURLFieldAutoFocus * remove appender function * removed snapshots * migrated test * removed old test * removed old snapshots * use proper locator instead of a class * use locators instead of tabbing around, remove appender util function * simplified test * removed snapshots and updated locator of save button * add comment --- .../various/__snapshots__/links.test.js.snap | 12 --- .../specs/editor/various/links.test.js | 77 ------------------- test/e2e/specs/editor/blocks/links.spec.js | 64 +++++++++++++++ 3 files changed, 64 insertions(+), 89 deletions(-) diff --git a/packages/e2e-tests/specs/editor/various/__snapshots__/links.test.js.snap b/packages/e2e-tests/specs/editor/various/__snapshots__/links.test.js.snap index 542f3ed2133db4..541a5456fd4d53 100644 --- a/packages/e2e-tests/specs/editor/various/__snapshots__/links.test.js.snap +++ b/packages/e2e-tests/specs/editor/various/__snapshots__/links.test.js.snap @@ -47,15 +47,3 @@ exports[`Links can be removed 1`] = `

This is Gutenberg

" `; - -exports[`Links should contain a label when it should open in a new tab 1`] = ` -" -

This is WordPress

-" -`; - -exports[`Links should contain a label when it should open in a new tab 2`] = ` -" -

This is WordPress

-" -`; diff --git a/packages/e2e-tests/specs/editor/various/links.test.js b/packages/e2e-tests/specs/editor/various/links.test.js index 5920f8463277bb..880f786bd4cbe7 100644 --- a/packages/e2e-tests/specs/editor/various/links.test.js +++ b/packages/e2e-tests/specs/editor/various/links.test.js @@ -478,83 +478,6 @@ describe( 'Links', () => { ); } ); - it( 'should contain a label when it should open in a new tab', async () => { - await clickBlockAppender(); - await page.keyboard.type( 'This is WordPress' ); - // Select "WordPress". - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - await pressKeyWithModifier( 'primary', 'k' ); - await waitForURLFieldAutoFocus(); - await page.keyboard.type( 'w.org' ); - - // Link settings open - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Space' ); - - // Navigate to and toggle the "Open in new tab" checkbox. - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Space' ); - - // Confirm that focus was not prematurely returned to the paragraph on - // a changing value of the setting. - await page.waitForSelector( - ':focus.components-checkbox-control__input' - ); - - // Submit link. Expect that "Open in new tab" would have been applied - // immediately. - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Enter' ); - - // Wait for Gutenberg to finish the job. - await page.waitForXPath( - '//a[contains(@href,"w.org") and @target="_blank"]' - ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - - // Regression Test: This verifies that the UI is updated according to - // the expected changed values, where previously the value could have - // fallen out of sync with how the UI is displayed (specifically for - // collapsed selections). - // - // See: https://github.com/WordPress/gutenberg/pull/15573 - - // Move caret back into the link. - await page.keyboard.press( 'ArrowLeft' ); - await page.keyboard.press( 'ArrowLeft' ); - - // Edit link. - await pressKeyWithModifier( 'primary', 'k' ); - await waitForURLFieldAutoFocus(); - await pressKeyWithModifier( 'primary', 'a' ); - await page.keyboard.type( 'wordpress.org' ); - - // Update the link. - await page.keyboard.press( 'Enter' ); - - // Navigate back to the popover. - await page.keyboard.press( 'ArrowLeft' ); - await page.keyboard.press( 'ArrowLeft' ); - - // Navigate back to inputs to verify appears as changed. - await pressKeyWithModifier( 'primary', 'k' ); - await waitForURLFieldAutoFocus(); - - // Navigate to the "Open in new tab" checkbox. - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Tab' ); - // Uncheck the checkbox. - await page.keyboard.press( 'Space' ); - - // Wait for Gutenberg to finish the job. - await page.waitForXPath( - '//a[contains(@href,"wordpress.org") and not(@target)]' - ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - describe( 'Editing link text', () => { it( 'should not display text input when initially creating the link', async () => { // Create a block with some text. diff --git a/test/e2e/specs/editor/blocks/links.spec.js b/test/e2e/specs/editor/blocks/links.spec.js index 1e03da549c2347..208aca3b6e5b37 100644 --- a/test/e2e/specs/editor/blocks/links.spec.js +++ b/test/e2e/specs/editor/blocks/links.spec.js @@ -66,4 +66,68 @@ test.describe( 'Links', () => { }, ] ); } ); + + test( 'can update the url of an existing link', async ( { + page, + editor, + pageUtils, + } ) => { + // Create a block with some text. + await editor.insertBlock( { + name: 'core/paragraph', + } ); + await page.keyboard.type( 'This is WordPress' ); + // Select "WordPress". + await pageUtils.pressKeys( 'shiftAlt+ArrowLeft' ); + await pageUtils.pressKeys( 'primary+k' ); + await page.keyboard.type( 'w.org' ); + + await page + //TODO: change to a better selector when https://github.com/WordPress/gutenberg/issues/51060 is resolved. + .locator( '.block-editor-link-control' ) + .getByRole( 'button', { name: 'Save' } ) + .click(); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: 'This is WordPress', + }, + }, + ] ); + + // Move caret back into the link. + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + + // Edit link. + await pageUtils.pressKeys( 'primary+k' ); + await pageUtils.pressKeys( 'primary+a' ); + await page.keyboard.type( 'wordpress.org' ); + + // Update the link. + await page.keyboard.press( 'Enter' ); + + // Navigate back to the popover. + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + + // Navigate back to inputs to verify appears as changed. + await pageUtils.pressKeys( 'primary+k' ); + const urlInputValue = await page + .getByPlaceholder( 'Search or type url' ) + .inputValue(); + expect( urlInputValue ).toContain( 'wordpress.org' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: + 'This is WordPress', + }, + }, + ] ); + } ); } ); From 1ac78ccfb13fc478f44378c1bb096dbdaf90e016 Mon Sep 17 00:00:00 2001 From: Juan Aldasoro Date: Mon, 29 May 2023 19:40:59 +0200 Subject: [PATCH 47/91] Improve image block lightbox escaping (#51061) * Improve the image block lightbox escaping. --- packages/block-library/src/image/index.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index 3eb84f86af7ed2..ab990bc7a0d27a 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -68,12 +68,12 @@ function render_block_core_image( $attributes, $content ) { ''; $body_content = preg_replace( '/]+>/', $button, $content ); - $background_color = wp_get_global_styles( array( 'color', 'background' ) ); + $background_color = esc_attr( wp_get_global_styles( array( 'color', 'background' ) ) ); $close_button_icon = ''; - $dialog_label = $alt_attribute ? $alt_attribute : __( 'Image' ); + $dialog_label = $alt_attribute ? esc_attr( $alt_attribute ) : esc_attr__( 'Image' ); - $close_button_label = __( 'Close' ); + $close_button_label = esc_attr__( 'Close' ); return << Date: Mon, 29 May 2023 13:11:56 -0500 Subject: [PATCH 48/91] Remove roadmap doc. (#51062) --- docs/contributors/roadmap.md | 31 ------------------------------- docs/manifest.json | 6 ------ docs/toc.json | 3 +-- 3 files changed, 1 insertion(+), 39 deletions(-) delete mode 100644 docs/contributors/roadmap.md diff --git a/docs/contributors/roadmap.md b/docs/contributors/roadmap.md deleted file mode 100644 index ae7aa5ea797731..00000000000000 --- a/docs/contributors/roadmap.md +++ /dev/null @@ -1,31 +0,0 @@ -# Upcoming Projects & Roadmap - -_Complementary to [Phase 2 Scope](https://github.com/WordPress/gutenberg/issues/13113)._ - -This document outlines some of the features currently in development or being considered for the project. It should not be confused with the product roadmap for WordPress itself, even if some areas naturally overlap. The main purpose of it is to give visibility to some of the key problems remaining to be solved and as an invitation for those wanting to collaborate on some of the more complex issues to learn about what needs help. - -Gutenberg is already in use by millions of sites through WordPress, so in order to make substantial changes to the design or updating specifications it is advisable to consider a discussion process ("Request for Comments") showing both an understanding of the impact, both positives and negatives, trade offs and opportunities. - -## Projects - -- **Block Registry** — define an entry point for block identification. ([See active RFC](https://github.com/WordPress/gutenberg/pull/13693).) -- **Live Component Library** — a place to visualize and interact with the UI components and block tools included in the packages. -- **Modular Editor** — allow loading the block editor in several contexts without a dependency to a post object. (Ongoing [pending tasks](https://github.com/WordPress/gutenberg/issues/14043).) -- **Better Validation** — continue to refine the mechanisms used in validating editor content. (See in depth overview at [#11440](https://github.com/WordPress/gutenberg/issues/11440) and [#7604](https://github.com/WordPress/gutenberg/issues/7604).) -- **Block Areas** — build support for areas of blocks that fall outside the content (including relationship with templates, registration, storage, and so on). ([See overview](https://github.com/WordPress/gutenberg/issues/13489).) -- **Multi-Block Editing** — allow modifying attributes of multiple blocks of the same kind at once. -- **Rich Text Roadmap** — continue to develop the capabilities of the rich text package. ([See overview](https://github.com/WordPress/gutenberg/issues/13778).) -- **Common Block Functionality** — coalesce into a preferred mechanism for creating and sharing chunks of functionality (block alignment, color tools, etc) across blocks with a simple and intuitive code interface. (Suggested exploration: React Hooks, [see overview](https://github.com/WordPress/gutenberg/issues/15450).) -- **Responsive Images** — propose mechanisms for handling flexible image sources that can be optimized for loading and takes into account their placement on a page (within main content, a column, sidebar, etc). -- **Async Loading** — propose a strategy for loading block code only when necessary in the editor without overhead for the developer or disrupting the user experience. -- **Styles** — continue to develop the mechanisms for managing block styles and other styling solutions. (See overview at [#7551](https://github.com/WordPress/gutenberg/issues/7551) and [#9534](https://github.com/WordPress/gutenberg/issues/9534).) -- **Bundling Front-end Assets** — explore ways in which front-end styles for blocks could be assembled based on which blocks are used in a given page response. ([See overview](https://github.com/WordPress/gutenberg/issues/5445).) -- **Transforms API** — improve the transform API to allow advanced use-cases: support for async-transforms, access to the block editor settings and bring consistency between the different types of transforms. ([See related issue](https://github.com/WordPress/gutenberg/issues/14755).) - -## Timeline - -The projects outlined above indicate areas of interest but not necessarily development priorities. Sometimes, a product need will accelerate a resolution (as is the case of the block registry), other times community interest might be the driving force. - -- 2019 Q1: Block Registry — First phase. Required for plugin directory "meta" project. -- 2019 Q2: Modular Editor — Requirement for most of phase 2. -- 2019 Q3: Block Areas. diff --git a/docs/manifest.json b/docs/manifest.json index f10b4dbe06a171..8e00a3dcd2b0d4 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -2314,11 +2314,5 @@ "slug": "versions-in-wordpress", "markdown_source": "../docs/contributors/versions-in-wordpress.md", "parent": "contributors" - }, - { - "title": "Upcoming Projects & Roadmap", - "slug": "roadmap", - "markdown_source": "../docs/contributors/roadmap.md", - "parent": "contributors" } ] diff --git a/docs/toc.json b/docs/toc.json index d9acd5fbb38aee..7512bcea80ecbd 100644 --- a/docs/toc.json +++ b/docs/toc.json @@ -400,8 +400,7 @@ { "docs/contributors/accessibility-testing.md": [] }, { "docs/contributors/repository-management.md": [] }, { "docs/contributors/folder-structure.md": [] }, - { "docs/contributors/versions-in-wordpress.md": [] }, - { "docs/contributors/roadmap.md": [] } + { "docs/contributors/versions-in-wordpress.md": [] } ] } ] From 0fa988f5de478cb01ea6ab698f443147cde1b2b8 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 30 May 2023 10:22:36 +1000 Subject: [PATCH 49/91] Try adding dynamic page templates to pages section (#50630) * Try adding dynamic page templates to pages section * remove recent heading in pages * Static homepage case * Back button should go back to Pages * Fix order of home and blog pages * Try fetching all the pages * Stick templates to bottom * Stick item positioning * Revert custom back button behaviour * Move manage link to dynamic pages group * Remove home/posts page suffixes * Address feedback * Add back truncation * Copy array for reorder * Consolidate sticky styles --------- Co-authored-by: Saxon Fletcher Co-authored-by: James Koster --- .../sidebar-navigation-screen-page/style.scss | 5 - .../sidebar-navigation-screen-pages/index.js | 139 +++++++++++++++--- .../style.scss | 4 - .../sidebar-navigation-screen/style.scss | 6 +- packages/edit-site/src/style.scss | 1 - 5 files changed, 119 insertions(+), 36 deletions(-) delete mode 100644 packages/edit-site/src/components/sidebar-navigation-screen-pages/style.scss diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-page/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen-page/style.scss index 94dd7aa67096b1..7f7e6d79b5029d 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-page/style.scss +++ b/packages/edit-site/src/components/sidebar-navigation-screen-page/style.scss @@ -1,8 +1,3 @@ -.edit-site-sidebar-navigation-screen__sticky-section { - padding: $grid-unit-40 0; - margin: $grid-unit-40 $grid-unit-20; -} - .edit-site-sidebar-navigation-screen-page__featured-image-wrapper { background-color: $gray-800; margin-bottom: $grid-unit-20; diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js index ebb38d9478fc4e..6a3e02c64825f1 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js @@ -5,10 +5,13 @@ import { __experimentalItemGroup as ItemGroup, __experimentalItem as Item, __experimentalTruncate as Truncate, + __experimentalVStack as VStack, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { useEntityRecords } from '@wordpress/core-data'; +import { useEntityRecords, store as coreStore } from '@wordpress/core-data'; import { decodeEntities } from '@wordpress/html-entities'; +import { layout, page, home, loop } from '@wordpress/icons'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -16,22 +19,71 @@ import { decodeEntities } from '@wordpress/html-entities'; import SidebarNavigationScreen from '../sidebar-navigation-screen'; import { useLink } from '../routes/link'; import SidebarNavigationItem from '../sidebar-navigation-item'; -import SidebarNavigationSubtitle from '../sidebar-navigation-subtitle'; -const PageItem = ( { postId, ...props } ) => { +const PageItem = ( { postType = 'page', postId, ...props } ) => { const linkInfo = useLink( { - postType: 'page', + postType, postId, } ); return ; }; export default function SidebarNavigationScreenPages() { - const { records: pages, isResolving: isLoading } = useEntityRecords( + const { records: pages, isResolving: isLoadingPages } = useEntityRecords( 'postType', 'page', - { status: 'any' } + { + status: 'any', + per_page: -1, + } ); + const { records: templates, isResolving: isLoadingTemplates } = + useEntityRecords( 'postType', 'wp_template', { + per_page: -1, + } ); + + const dynamicPageTemplates = templates?.filter( ( { slug } ) => + [ '404', 'search' ].includes( slug ) + ); + + const homeTemplate = + templates?.find( ( template ) => template.slug === 'front-page' ) || + templates?.find( ( template ) => template.slug === 'home' ) || + templates?.find( ( template ) => template.slug === 'index' ); + + const pagesAndTemplates = pages?.concat( dynamicPageTemplates, [ + homeTemplate, + ] ); + + const { frontPage, postsPage } = useSelect( ( select ) => { + const { getEntityRecord } = select( coreStore ); + + const siteSettings = getEntityRecord( 'root', 'site' ); + return { + frontPage: siteSettings?.page_on_front, + postsPage: siteSettings?.page_for_posts, + }; + }, [] ); + + const isHomePageBlog = frontPage === postsPage; + + const reorderedPages = pages && [ ...pages ]; + + if ( ! isHomePageBlog && reorderedPages?.length ) { + const homePageIndex = reorderedPages.findIndex( + ( item ) => item.id === frontPage + ); + const homePage = reorderedPages.splice( homePageIndex, 1 ); + reorderedPages?.splice( 0, 0, ...homePage ); + + const postsPageIndex = reorderedPages.findIndex( + ( item ) => item.id === postsPage + ); + + const blogPage = reorderedPages.splice( postsPageIndex, 1 ); + + reorderedPages.splice( 1, 0, ...blogPage ); + } return ( - { isLoading && ( + { ( isLoadingPages || isLoadingTemplates ) && ( { __( 'Loading pages' ) } ) } - { ! isLoading && ( - <> - - { __( 'Recent' ) } - - - { ! pages?.length && ( - { __( 'No page found' ) } - ) } - { pages?.map( ( page ) => ( + { ! ( isLoadingPages || isLoadingTemplates ) && ( + + { ! pagesAndTemplates?.length && ( + { __( 'No page found' ) } + ) } + { isHomePageBlog && homeTemplate && ( + + + { decodeEntities( + homeTemplate.title?.rendered + ) ?? __( '(no title)' ) } + + + ) } + { reorderedPages?.map( ( item ) => { + let itemIcon; + switch ( item.id ) { + case frontPage: + itemIcon = home; + break; + case postsPage: + itemIcon = loop; + break; + default: + itemIcon = page; + } + return ( { decodeEntities( - page.title?.rendered - ) ?? __( 'Untitled' ) } + item.title?.rendered + ) ?? __( '(no title)' ) } + + + ); + } ) } + + { dynamicPageTemplates?.map( ( item ) => ( + + + { decodeEntities( + item.title?.rendered + ) ?? __( '(no title)' ) } ) ) } @@ -76,8 +169,8 @@ export default function SidebarNavigationScreenPages() { > { __( 'Manage all pages' ) } - - + + ) } } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-pages/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen-pages/style.scss deleted file mode 100644 index 7bbdd103b6bcea..00000000000000 --- a/packages/edit-site/src/components/sidebar-navigation-screen-pages/style.scss +++ /dev/null @@ -1,4 +0,0 @@ -.edit-site-sidebar-navigation-screen-pages__see-all { - /* Overrides the margin that comes from the Item component */ - margin-top: $grid-unit-20 !important; -} 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 f4b284ec92b580..81508a7709d942 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen/style.scss +++ b/packages/edit-site/src/components/sidebar-navigation-screen/style.scss @@ -73,12 +73,12 @@ } } -.edit-site-sidebar-navigation-screen__sticky-section { +.edit-site-sidebar-navigation-screen__sticky-section.edit-site-sidebar-navigation-screen__sticky-section { position: sticky; bottom: 0; background-color: $gray-900; gap: 0; - padding: $grid-unit-20 0; - margin: $grid-unit-20 0 #{- $grid-unit-20} 0; + padding: $grid-unit-40 0; + margin: $grid-unit-40 $grid-unit-20; border-top: 1px solid $gray-800; } diff --git a/packages/edit-site/src/style.scss b/packages/edit-site/src/style.scss index 8eff0fe22e0382..3aebc258cd5107 100644 --- a/packages/edit-site/src/style.scss +++ b/packages/edit-site/src/style.scss @@ -25,7 +25,6 @@ @import "./components/sidebar-button/style.scss"; @import "./components/sidebar-navigation-item/style.scss"; @import "./components/sidebar-navigation-screen/style.scss"; -@import "./components/sidebar-navigation-screen-pages/style.scss"; @import "./components/sidebar-navigation-screen-page/style.scss"; @import "./components/sidebar-navigation-screen-template/style.scss"; @import "./components/sidebar-navigation-screen-templates/style.scss"; From 24f78b5b67ff10b4c64b9197a05e563da0accfcf Mon Sep 17 00:00:00 2001 From: Nick Diego Date: Mon, 29 May 2023 20:24:30 -0500 Subject: [PATCH 50/91] Fix formatting and use sentence case for titles. (#51069) --- docs/contributors/code/scripts.md | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/docs/contributors/code/scripts.md b/docs/contributors/code/scripts.md index d4ce263c80ad14..5cd7efd2fffdad 100644 --- a/docs/contributors/code/scripts.md +++ b/docs/contributors/code/scripts.md @@ -2,7 +2,7 @@ The editor provides several vendor and internal scripts to plugin developers. Script names, handles, and descriptions are documented in the table below. -## WP Scripts +## WordPress scripts The editor includes a number of packages to enable various pieces of functionality. Plugin developers can utilize them to create blocks, editor plugins, or generic plugins. @@ -40,7 +40,7 @@ The editor includes a number of packages to enable various pieces of functionali | [Viewport](/packages/viewport/README.md) | wp-viewport | Module for responding to changes in the browser viewport size | | [Wordcount](/packages/wordcount/README.md) | wp-wordcount | WordPress word count utility | -## Vendor Scripts +## Vendor scripts The editor also uses some popular third-party packages and scripts. Plugin developers can use these scripts as well without bundling them in their code (and increasing file sizes). @@ -51,9 +51,10 @@ The editor also uses some popular third-party packages and scripts. Plugin devel | [Moment](https://momentjs.com/) | moment | Parse, validate, manipulate, and display dates and times in JavaScript | | [Lodash](https://lodash.com) | lodash | Lodash is a JavaScript library which provides utility functions for common programming tasks | -## Polyfill Scripts +## Polyfill scripts The editor also provides polyfills for certain features that may not be available in all modern browsers. + It is recommended to use the main `wp-polyfill` script handle which takes care of loading all the below mentioned polyfills. | Script Name | Handle | Description | @@ -67,12 +68,6 @@ It is recommended to use the main `wp-polyfill` script handle which takes care o ## Bundling and code sharing -When using a JavaScript bundler like [webpack](https://webpack.js.org/), the scripts mentioned here -can be excluded from the bundle and provided by WordPress in the form of script dependencies [see -`wp_enqueue_script`](https://developer.wordpress.org/reference/functions/wp_enqueue_script/#default-scripts-included-and-registered-by-wordpress). +When using a JavaScript bundler like [webpack](https://webpack.js.org/), the scripts mentioned here can be excluded from the bundle and provided by WordPress in the form of script dependencies see [`wp_enqueue_script`](https://developer.wordpress.org/reference/functions/wp_enqueue_script/#default-scripts-included-and-registered-by-wordpress). -The -[`@wordpress/dependency-extraction-webpack-plugin`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/dependency-extraction-webpack-plugin) -provides a webpack plugin to help extract WordPress dependencies from bundles. `@wordpress/scripts` -[`build`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/scripts#build) script includes -the plugin by default. +The [`@wordpress/dependency-extraction-webpack-plugin`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/dependency-extraction-webpack-plugin) provides a webpack plugin to help extract WordPress dependencies from bundles. The `@wordpress/scripts` [`build`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/scripts#build) script includes the plugin by default. From e318682f4fa00a8d3e9fb58741ea0707e770bc44 Mon Sep 17 00:00:00 2001 From: Nick Diego Date: Mon, 29 May 2023 20:25:14 -0500 Subject: [PATCH 51/91] Fix error in code example. (#51070) --- docs/how-to-guides/curating-the-editor-experience.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/how-to-guides/curating-the-editor-experience.md b/docs/how-to-guides/curating-the-editor-experience.md index 795f0db59eaf7f..51054655f51aac 100644 --- a/docs/how-to-guides/curating-the-editor-experience.md +++ b/docs/how-to-guides/curating-the-editor-experience.md @@ -343,7 +343,7 @@ addFilter( 'blockEditor.useSetting.before', 'example/useSetting.before', ( settingValue, settingName, clientId, blockName ) => { - if ( blockName === Media & Text block'core/column' && settingName === 'spacing.units' ) { + if ( blockName === 'core/column' && settingName === 'spacing.units' ) { return [ 'px' ]; } return settingValue; From a9e19c0a139fd60f357497628d9c8047cff15bd5 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Tue, 30 May 2023 10:56:19 +0800 Subject: [PATCH 52/91] Fix screen readers not announcing updated `aria-describedby` in Firefox (#51035) * Fix aria-describedby not updating content * Try aria-description. * Try a different approach * Fix missing prop --------- Co-authored-by: Alex Stine --- .../src/components/list-view/appender.js | 8 ++--- .../list-view/aria-referenced-text.js | 30 +++++++++++++++++++ .../src/components/list-view/block.js | 8 ++--- .../src/components/list-view/style.scss | 5 ---- 4 files changed, 36 insertions(+), 15 deletions(-) create mode 100644 packages/block-editor/src/components/list-view/aria-referenced-text.js diff --git a/packages/block-editor/src/components/list-view/appender.js b/packages/block-editor/src/components/list-view/appender.js index cb731bbf227a8b..2d2f633637293a 100644 --- a/packages/block-editor/src/components/list-view/appender.js +++ b/packages/block-editor/src/components/list-view/appender.js @@ -14,6 +14,7 @@ import { store as blockEditorStore } from '../../store'; import useBlockDisplayTitle from '../block-title/use-block-display-title'; import { useListViewContext } from './context'; import Inserter from '../inserter'; +import AriaReferencedText from './aria-referenced-text'; export const Appender = forwardRef( ( { nestingLevel, blockCount, clientId, ...props }, ref ) => { @@ -90,12 +91,9 @@ export const Appender = forwardRef( } } } /> -
+ { description } -
+ ); } diff --git a/packages/block-editor/src/components/list-view/aria-referenced-text.js b/packages/block-editor/src/components/list-view/aria-referenced-text.js new file mode 100644 index 00000000000000..b5d7a73e8bcf52 --- /dev/null +++ b/packages/block-editor/src/components/list-view/aria-referenced-text.js @@ -0,0 +1,30 @@ +/** + * WordPress dependencies + */ +import { useRef, useEffect } from '@wordpress/element'; + +/** + * A component specifically designed to be used as an element referenced + * by ARIA attributes such as `aria-labelledby` or `aria-describedby`. + * + * @param {Object} props Props. + * @param {import('react').ReactNode} props.children + */ +export default function AriaReferencedText( { children, ...props } ) { + const ref = useRef(); + + useEffect( () => { + if ( ref.current ) { + // This seems like a no-op, but it fixes a bug in Firefox where + // it fails to recompute the text when only the text node changes. + // @see https://github.com/WordPress/gutenberg/pull/51035 + ref.current.textContent = ref.current.textContent; + } + }, [ children ] ); + + return ( + + ); +} diff --git a/packages/block-editor/src/components/list-view/block.js b/packages/block-editor/src/components/list-view/block.js index 20a385537f9b8e..e9eb4a78276861 100644 --- a/packages/block-editor/src/components/list-view/block.js +++ b/packages/block-editor/src/components/list-view/block.js @@ -40,6 +40,7 @@ import { store as blockEditorStore } from '../../store'; import useBlockDisplayInformation from '../use-block-display-information'; import { useBlockLock } from '../block-lock'; import { unlock } from '../../lock-unlock'; +import AriaReferencedText from './aria-referenced-text'; function ListViewBlock( { block: { clientId }, @@ -297,12 +298,9 @@ function ListViewBlock( { ariaDescribedBy={ descriptionId } updateFocusAndSelection={ updateFocusAndSelection } /> -
+ { blockPositionDescription } -
+ ) } diff --git a/packages/block-editor/src/components/list-view/style.scss b/packages/block-editor/src/components/list-view/style.scss index 5a4e80fa926fa2..082389f71d4a0e 100644 --- a/packages/block-editor/src/components/list-view/style.scss +++ b/packages/block-editor/src/components/list-view/style.scss @@ -340,11 +340,6 @@ } } -.block-editor-list-view-block-select-button__description, -.block-editor-list-view-appender__description { - display: none; -} - .block-editor-list-view-block__contents-cell, .block-editor-list-view-appender__cell { .block-editor-list-view-block__contents-container, From 805d2f1d1a793bd673059317c07d3ea7d59ba646 Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 30 May 2023 07:39:49 +0200 Subject: [PATCH 53/91] Change label and description for the `gutenberg-interactivity-api-core-blocks` experiments setting. (#51059) --- lib/experiments-page.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/experiments-page.php b/lib/experiments-page.php index eb6a1ea8a7336c..3b9dd9437b372f 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -91,12 +91,12 @@ function gutenberg_initialize_experiments_settings() { add_settings_field( 'gutenberg-interactivity-api-core-blocks', - __( 'Core blocks', 'gutenberg' ), + __( 'Interactivity API and Behaviors UI', 'gutenberg' ), 'gutenberg_display_experiment_field', 'gutenberg-experiments', 'gutenberg_experiments_section', array( - 'label' => __( 'Test the core blocks using the Interactivity API', 'gutenberg' ), + 'label' => __( 'Use the Interactivity API in File, Navigation and Image core blocks. It also enables the Behaviors UI in the Image block.', 'gutenberg' ), 'id' => 'gutenberg-interactivity-api-core-blocks', ) ); From f24d3526585e46fe8556546e16907acf8bc60a56 Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 30 May 2023 16:34:10 +1000 Subject: [PATCH 54/91] We can't perform a null ?? check on decodeEntities to verify falsy values because decodeEntities will return an empty string if it encounters one. (#51074) Updated Untitled copy to be consistent with pages view. --- .../sidebar-navigation-screen-page/index.js | 15 +++++++++------ .../sidebar-navigation-screen-pages/index.js | 15 +++++++++------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-page/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-page/index.js index 332c6ba62bfd4b..fa4baeea631cad 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-page/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-page/index.js @@ -145,9 +145,9 @@ export default function SidebarNavigationScreenPage() { : null; if ( _parentTitle?.title ) { - _parentTitle = _parentTitle.title?.rendered - ? decodeEntities( _parentTitle.title.rendered ) - : __( 'Untitled' ); + _parentTitle = decodeEntities( + _parentTitle.title?.rendered || __( '(no title)' ) + ); } else { _parentTitle = __( 'Top level' ); } @@ -181,7 +181,9 @@ export default function SidebarNavigationScreenPage() { return record ? ( setCanvasMode( 'edit' ) } @@ -218,8 +220,9 @@ export default function SidebarNavigationScreenPage() { altText ? decodeEntities( altText ) : decodeEntities( - record.title?.rendered - ) || __( 'Featured image' ) + record.title?.rendered || + __( 'Featured image' ) + ) } src={ mediaSourceUrl } /> diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js index 6a3e02c64825f1..0ad7df02020d15 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js @@ -111,8 +111,9 @@ export default function SidebarNavigationScreenPages() { > { decodeEntities( - homeTemplate.title?.rendered - ) ?? __( '(no title)' ) } + homeTemplate.title?.rendered || + __( '(no title)' ) + ) } ) } @@ -137,8 +138,9 @@ export default function SidebarNavigationScreenPages() { > { decodeEntities( - item.title?.rendered - ) ?? __( '(no title)' ) } + item?.title?.rendered || + __( '(no title)' ) + ) } ); @@ -154,8 +156,9 @@ export default function SidebarNavigationScreenPages() { > { decodeEntities( - item.title?.rendered - ) ?? __( '(no title)' ) } + item.title?.rendered || + __( '(no title)' ) + ) } ) ) } From a3a63c111aad8cc7be2cf77c6cb525fe6e0d7baa Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Tue, 30 May 2023 08:37:02 +0100 Subject: [PATCH 55/91] E2e tests: Add an assertion to confirm that the URL changed (#50835) --- test/e2e/specs/site-editor/command-center.spec.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/e2e/specs/site-editor/command-center.spec.js b/test/e2e/specs/site-editor/command-center.spec.js index 9661a91a6abc78..57b07faf4d9b48 100644 --- a/test/e2e/specs/site-editor/command-center.spec.js +++ b/test/e2e/specs/site-editor/command-center.spec.js @@ -28,6 +28,9 @@ test.describe( 'Site editor command center', () => { await page.getByRole( 'option', { name: 'Add new page' } ).click(); await page.waitForSelector( 'iframe[name="editor-canvas"]' ); const frame = page.frame( 'editor-canvas' ); + await expect( page ).toHaveURL( + '/wp-admin/post-new.php?post_type=page' + ); await expect( frame.getByRole( 'textbox', { name: 'Add title' } ) ).toBeVisible(); From 642b1c9f86d11ae518f5247b76b4cf3340e42bc6 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Tue, 30 May 2023 12:40:40 +0400 Subject: [PATCH 56/91] Link Control: Add missing translation (#51081) --- packages/block-editor/src/components/link-control/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/block-editor/src/components/link-control/index.js b/packages/block-editor/src/components/link-control/index.js index 8d5860cd97a990..b90f6d245d9539 100644 --- a/packages/block-editor/src/components/link-control/index.js +++ b/packages/block-editor/src/components/link-control/index.js @@ -383,7 +383,7 @@ function LinkControl( { __nextHasNoMarginBottom ref={ textInputRef } className="block-editor-link-control__setting block-editor-link-control__text-content" - label="Text" + label={ __( 'Text' ) } value={ internalControlValue?.title } onChange={ setInternalTextInputValue } onKeyDown={ handleSubmitWithEnter } From cad59c4534f904d9ffa73eb36c28f17d6e7b4b20 Mon Sep 17 00:00:00 2001 From: Miguel Fonseca Date: Tue, 30 May 2023 11:12:32 +0100 Subject: [PATCH 57/91] Docs: Undo/Redo: Minor edits (#51085) --- docs/explanations/architecture/entities.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/explanations/architecture/entities.md b/docs/explanations/architecture/entities.md index 368d0a73f87b65..1e037ed5b2824f 100644 --- a/docs/explanations/architecture/entities.md +++ b/docs/explanations/architecture/entities.md @@ -24,7 +24,7 @@ Once the entity is loaded, you can edit it. For example, the following code sets The package also exposes a set of actions to manipulate the fetched entity records. -To fetch an entity record, you can call `editEntityRecord`, which takes the entity type, the entity ID and the new entity record as parameters. The following example sets the title of the post with ID 1 to "Hello World". +To edit an entity record, you can call `editEntityRecord`, which takes the entity type, the entity ID and the new entity record as parameters. The following example sets the title of the post with ID 1 to "Hello World". ````js wp.data.dispatch( 'core' ).editEntityRecord( 'postType', 'post', 1, { title: 'Hello World' } ); @@ -42,13 +42,13 @@ Since the WordPress editors allow multiple entity records to be edited at the sa And to be able to perform both undo and redo operations propertly, each modification in the list of edits contains the following information: - - Entity kind and name: Each entity in core-data is identified by a tuple kind, name. This corresponds to the identifier of the modified entity. + - Entity kind and name: Each entity in core-data is identified by the pair _(kind, name)_. This corresponds to the identifier of the modified entity. - Entity Record ID: The ID of the modified record. - Property: The name of the modified property. - From: The previous value of the property (needed to apply the undo operation). - To: The new value of the property (needed to apply the redo operation). -For example, let's say a user edits the title of a post, followed by a modification to the post slug, and then a modification of the title of a reusable block used with the post for instance. The following information is stored in the undo/redo stack: +For example, let's say a user edits the title of a post, followed by a modification to the post slug, and then a modification of the title of a reusable block used with the post. The following information is stored in the undo/redo stack: - `[ { kind: 'postType', name: 'post', id: 1, property: 'title', from: '', to: 'Hello World' } ]` - `[ { kind: 'postType', name: 'post', id: 1, property: 'slug', from: 'Previous slug', to: 'This is the slug of the hello world post' } ]` @@ -60,4 +60,4 @@ The store also keep tracks of a "pointer" to the current "undo/redo" step. By de The undo/redo core behavior also supports what we call "transient modifications". These are modifications that are not stored in the undo/redo stack right away. For instance, when a user starts typing in a text field, the value of the field is modified in the store, but this modification is not stored in the undo/redo stack until after the user moves to the next word or after a few milliseconds. This is done to avoid creating a new undo/redo step for each character typed by the user. -So by default, `core-data` store considers all modifications to properties that are marked as "transient" (like the `blocks` property in the post entity) as transient modifications. It keeps these modifications outside the undo/redo stack in what is called a "cache" of modifications and these modifications are only stored in the undo/redo stack when we explicitely call `__unstableCreateUndoLevel` or when the next non transient modification is performed. +So by default, `core-data` store considers all modifications to properties that are marked as "transient" (like the `blocks` property in the post entity) as transient modifications. It keeps these modifications outside the undo/redo stack in what is called a "cache" of modifications and these modifications are only stored in the undo/redo stack when we explicitely call `__unstableCreateUndoLevel` or when the next non-transient modification is performed. From a072b66626139d25091613f1ca28d580a1a8efbd Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Tue, 30 May 2023 11:26:38 +0100 Subject: [PATCH 58/91] Update the document title in the template mode of the post editor (#50864) --- .../components/header/document-title/index.js | 89 +++++++++ .../header/document-title/style.scss | 61 ++++++ .../header/header-toolbar/style.scss | 1 - .../edit-post/src/components/header/index.js | 6 +- .../src/components/header/style.scss | 6 + .../header/template-title/delete-template.js | 106 ---------- .../template-title/edit-template-title.js | 78 -------- .../components/header/template-title/index.js | 115 ----------- .../header/template-title/style.scss | 94 --------- .../template-title/template-description.js | 42 ---- .../src/components/visual-editor/index.js | 20 +- .../src/components/visual-editor/style.scss | 13 -- packages/edit-post/src/style.scss | 2 +- .../various/post-editor-template-mode.spec.js | 186 +----------------- 14 files changed, 166 insertions(+), 653 deletions(-) create mode 100644 packages/edit-post/src/components/header/document-title/index.js create mode 100644 packages/edit-post/src/components/header/document-title/style.scss delete mode 100644 packages/edit-post/src/components/header/template-title/delete-template.js delete mode 100644 packages/edit-post/src/components/header/template-title/edit-template-title.js delete mode 100644 packages/edit-post/src/components/header/template-title/index.js delete mode 100644 packages/edit-post/src/components/header/template-title/style.scss delete mode 100644 packages/edit-post/src/components/header/template-title/template-description.js diff --git a/packages/edit-post/src/components/header/document-title/index.js b/packages/edit-post/src/components/header/document-title/index.js new file mode 100644 index 00000000000000..1b27a0bacf014b --- /dev/null +++ b/packages/edit-post/src/components/header/document-title/index.js @@ -0,0 +1,89 @@ +/** + * WordPress dependencies + */ +import { __, isRTL } from '@wordpress/i18n'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { BlockIcon, store as blockEditorStore } from '@wordpress/block-editor'; +import { + Button, + VisuallyHidden, + __experimentalHStack as HStack, + __experimentalText as Text, +} from '@wordpress/components'; +import { layout, chevronLeftSmall, chevronRightSmall } from '@wordpress/icons'; +import { privateApis as commandsPrivateApis } from '@wordpress/commands'; +import { displayShortcut } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import { unlock } from '../../../private-apis'; +import { store as editPostStore } from '../../../store'; + +const { store: commandsStore } = unlock( commandsPrivateApis ); + +function DocumentTitle() { + const { template, isEditing } = useSelect( ( select ) => { + const { isEditingTemplate, getEditedPostTemplate } = + select( editPostStore ); + const _isEditing = isEditingTemplate(); + + return { + template: _isEditing ? getEditedPostTemplate() : null, + isEditing: _isEditing, + }; + }, [] ); + const { clearSelectedBlock } = useDispatch( blockEditorStore ); + const { setIsEditingTemplate } = useDispatch( editPostStore ); + const { open: openCommandCenter } = useDispatch( commandsStore ); + + if ( ! isEditing || ! template ) { + return null; + } + + let templateTitle = __( 'Default' ); + if ( template?.title ) { + templateTitle = template.title; + } else if ( !! template ) { + templateTitle = template.slug; + } + + return ( +
+ + + + + + +
+ ); +} + +export default DocumentTitle; diff --git a/packages/edit-post/src/components/header/document-title/style.scss b/packages/edit-post/src/components/header/document-title/style.scss new file mode 100644 index 00000000000000..e39ecf607e4306 --- /dev/null +++ b/packages/edit-post/src/components/header/document-title/style.scss @@ -0,0 +1,61 @@ +.edit-post-document-title { + display: flex; + align-items: center; + gap: $grid-unit; + height: $button-size; + justify-content: space-between; + // Flex items will, by default, refuse to shrink below a minimum + // intrinsic width. In order to shrink this flexbox item, and + // subsequently truncate child text, we set an explicit min-width. + // See https://dev.w3.org/csswg/css-flexbox/#min-size-auto + min-width: 0; + background: $gray-100; + border-radius: 4px; + width: min(100%, 450px); + + &:hover { + color: currentColor; + background: $gray-200; + } +} + +.edit-post-document-title__title.components-button { + flex-grow: 1; + color: var(--wp-block-synced-color); + overflow: hidden; + + &:hover { + color: var(--wp-block-synced-color); + } + + h1 { + color: var(--wp-block-synced-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.edit-post-document-title__shortcut { + flex-shrink: 0; + color: $gray-700; + padding: 0 $grid-unit-15; + + &:hover { + color: $gray-700; + } +} + +.edit-post-document-title__left { + min-width: $button-size; + flex-shrink: 0; + + .components-button.has-icon.has-text { + color: $gray-700; + gap: 0; + + &:hover { + color: currentColor; + } + } +} diff --git a/packages/edit-post/src/components/header/header-toolbar/style.scss b/packages/edit-post/src/components/header/header-toolbar/style.scss index 87aec00004c02b..694dcb5a2d678a 100644 --- a/packages/edit-post/src/components/header/header-toolbar/style.scss +++ b/packages/edit-post/src/components/header/header-toolbar/style.scss @@ -1,6 +1,5 @@ .edit-post-header-toolbar { display: inline-flex; - flex-grow: 1; align-items: center; border: none; diff --git a/packages/edit-post/src/components/header/index.js b/packages/edit-post/src/components/header/index.js index 09a93424f6903d..3306a0fdf1606a 100644 --- a/packages/edit-post/src/components/header/index.js +++ b/packages/edit-post/src/components/header/index.js @@ -18,7 +18,7 @@ import { default as DevicePreview } from '../device-preview'; import ViewLink from '../view-link'; import MainDashboardButton from './main-dashboard-button'; import { store as editPostStore } from '../../store'; -import TemplateTitle from './template-title'; +import DocumentTitle from './document-title'; function Header( { setEntitiesSavedStatesCallback } ) { const isLargeViewport = useViewportMatch( 'large' ); @@ -70,7 +70,9 @@ function Header( { setEntitiesSavedStatesCallback } ) { className="edit-post-header__toolbar" > - +
+ +
{ - const { isEditingTemplate, getEditedPostTemplate } = - select( editPostStore ); - const _isEditing = isEditingTemplate(); - return { - template: _isEditing ? getEditedPostTemplate() : null, - }; - }, [] ); - const [ showConfirmDialog, setShowConfirmDialog ] = useState( false ); - - if ( ! template || ! template.wp_id ) { - return null; - } - let templateTitle = template.slug; - if ( template?.title ) { - templateTitle = template.title; - } - - const isRevertable = template?.has_theme_file; - - const onDelete = () => { - clearSelectedBlock(); - setIsEditingTemplate( false ); - setShowConfirmDialog( false ); - - editPost( { - template: '', - } ); - const settings = getEditorSettings(); - const newAvailableTemplates = Object.fromEntries( - Object.entries( settings.availableTemplates ?? {} ).filter( - ( [ id ] ) => id !== template.slug - ) - ); - updateEditorSettings( { - availableTemplates: newAvailableTemplates, - } ); - deleteEntityRecord( 'postType', 'wp_template', template.id, { - throwOnError: true, - } ); - }; - - return ( - - <> - { - setShowConfirmDialog( true ); - } } - info={ - isRevertable - ? __( 'Use the template as supplied by the theme.' ) - : undefined - } - > - { isRevertable - ? __( 'Clear customizations' ) - : __( 'Delete template' ) } - - { - setShowConfirmDialog( false ); - } } - > - { sprintf( - /* translators: %s: template name */ - __( - 'Are you sure you want to delete the %s template? It may be used by other pages or posts.' - ), - templateTitle - ) } - - - - ); -} diff --git a/packages/edit-post/src/components/header/template-title/edit-template-title.js b/packages/edit-post/src/components/header/template-title/edit-template-title.js deleted file mode 100644 index 447ea5e4e02d72..00000000000000 --- a/packages/edit-post/src/components/header/template-title/edit-template-title.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; -import { TextControl } from '@wordpress/components'; -import { useDispatch, useSelect } from '@wordpress/data'; -import { useState } from '@wordpress/element'; -import { store as editorStore } from '@wordpress/editor'; -import { store as coreStore } from '@wordpress/core-data'; - -/** - * Internal dependencies - */ -import { store as editPostStore } from '../../../store'; - -export default function EditTemplateTitle() { - const [ forceEmpty, setForceEmpty ] = useState( false ); - const { template } = useSelect( ( select ) => { - const { getEditedPostTemplate } = select( editPostStore ); - return { - template: getEditedPostTemplate(), - }; - }, [] ); - - const { editEntityRecord } = useDispatch( coreStore ); - const { getEditorSettings } = useSelect( editorStore ); - const { updateEditorSettings } = useDispatch( editorStore ); - - // Only user-created and non-default templates can change the name. - if ( ! template.is_custom || template.has_theme_file ) { - return null; - } - - let templateTitle = __( 'Default' ); - if ( template?.title ) { - templateTitle = template.title; - } else if ( !! template ) { - templateTitle = template.slug; - } - - return ( -
- { - // Allow having the field temporarily empty while typing. - if ( ! newTitle && ! forceEmpty ) { - setForceEmpty( true ); - return; - } - setForceEmpty( false ); - - const settings = getEditorSettings(); - const newAvailableTemplates = Object.fromEntries( - Object.entries( settings.availableTemplates ?? {} ).map( - ( [ id, existingTitle ] ) => [ - id, - id !== template.slug ? existingTitle : newTitle, - ] - ) - ); - updateEditorSettings( { - availableTemplates: newAvailableTemplates, - } ); - editEntityRecord( 'postType', 'wp_template', template.id, { - title: newTitle, - } ); - } } - onBlur={ () => setForceEmpty( false ) } - /> -
- ); -} diff --git a/packages/edit-post/src/components/header/template-title/index.js b/packages/edit-post/src/components/header/template-title/index.js deleted file mode 100644 index c0745dc0451b74..00000000000000 --- a/packages/edit-post/src/components/header/template-title/index.js +++ /dev/null @@ -1,115 +0,0 @@ -/** - * WordPress dependencies - */ -import { __, sprintf } from '@wordpress/i18n'; -import { useSelect, useDispatch } from '@wordpress/data'; -import { - Dropdown, - Button, - __experimentalText as Text, -} from '@wordpress/components'; -import { chevronDown } from '@wordpress/icons'; - -/** - * Internal dependencies - */ -import { store as editPostStore } from '../../../store'; -import { store as blockEditorStore } from '@wordpress/block-editor'; -import { store as editorStore } from '@wordpress/editor'; -import DeleteTemplate from './delete-template'; -import EditTemplateTitle from './edit-template-title'; -import TemplateDescription from './template-description'; - -function TemplateTitle() { - const { template, isEditing, title } = useSelect( ( select ) => { - const { isEditingTemplate, getEditedPostTemplate } = - select( editPostStore ); - const { getEditedPostAttribute } = select( editorStore ); - - const _isEditing = isEditingTemplate(); - - return { - template: _isEditing ? getEditedPostTemplate() : null, - isEditing: _isEditing, - title: getEditedPostAttribute( 'title' ) - ? getEditedPostAttribute( 'title' ) - : __( 'Untitled' ), - }; - }, [] ); - - const { clearSelectedBlock } = useDispatch( blockEditorStore ); - const { setIsEditingTemplate } = useDispatch( editPostStore ); - - if ( ! isEditing || ! template ) { - return null; - } - - let templateTitle = __( 'Default' ); - if ( template?.title ) { - templateTitle = template.title; - } else if ( !! template ) { - templateTitle = template.slug; - } - - const hasOptions = !! ( - template.custom || - template.wp_id || - template.description - ); - - return ( -
- - { hasOptions ? ( - ( - - ) } - renderContent={ () => ( - <> - - - - - ) } - /> - ) : ( - - { templateTitle } - - ) } -
- ); -} - -export default TemplateTitle; diff --git a/packages/edit-post/src/components/header/template-title/style.scss b/packages/edit-post/src/components/header/template-title/style.scss deleted file mode 100644 index b5fe5120bfb64c..00000000000000 --- a/packages/edit-post/src/components/header/template-title/style.scss +++ /dev/null @@ -1,94 +0,0 @@ -.edit-post-template-top-area { - display: flex; - flex-direction: column; - align-content: space-between; - width: 100%; - align-items: center; - - .edit-post-template-title, - .edit-post-template-post-title { - padding: 0; - text-decoration: none; - height: auto; - - &::before { - height: 100%; - } - - &.has-icon { - svg { - order: 1; - margin-right: 0; - } - } - } - - .edit-post-template-title { - color: $gray-900; - } - - .edit-post-template-post-title { - margin-top: $grid-unit-05; - max-width: 160px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: block; - - &::before { - left: 0; - right: 0; - } - - @include break-xlarge() { - max-width: 400px; - } - } -} - -.edit-post-template-top-area__popover { - .components-popover__content { - min-width: 280px; - padding: 0; - } - - .edit-site-template-details__group { - padding: $grid-unit-20; - - .components-base-control__help { - margin-bottom: 0; - } - } - - .edit-post-template-details__description { - color: $gray-700; - } -} - -.edit-post-template-top-area__second-menu-group { - border-top: $border-width solid $gray-300; - padding: $grid-unit-20 $grid-unit-10; - - .edit-post-template-top-area__delete-template-button { - display: flex; - justify-content: center; - padding: $grid-unit-05 $grid-unit; - - &.is-destructive { - padding: inherit; - margin-left: $grid-unit-10; - margin-right: $grid-unit-10; - width: calc(100% - #{($grid-unit * 2)}); - - .components-menu-item__item { - width: auto; - } - } - - .components-menu-item__item { - margin-right: 0; - min-width: 0; - width: 100%; - } - } -} diff --git a/packages/edit-post/src/components/header/template-title/template-description.js b/packages/edit-post/src/components/header/template-title/template-description.js deleted file mode 100644 index 3513496852c339..00000000000000 --- a/packages/edit-post/src/components/header/template-title/template-description.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * WordPress dependencies - */ -import { useSelect } from '@wordpress/data'; -import { - __experimentalHeading as Heading, - __experimentalText as Text, -} from '@wordpress/components'; - -/** - * Internal dependencies - */ -import { store as editPostStore } from '../../../store'; - -export default function TemplateDescription() { - const { description, title } = useSelect( ( select ) => { - const { getEditedPostTemplate } = select( editPostStore ); - return { - title: getEditedPostTemplate().title, - description: getEditedPostTemplate().description, - }; - }, [] ); - if ( ! description ) { - return null; - } - - return ( -
- - { title } - - - { description } - -
- ); -} diff --git a/packages/edit-post/src/components/visual-editor/index.js b/packages/edit-post/src/components/visual-editor/index.js index ac8902f6a5f7a1..638a869aa8350c 100644 --- a/packages/edit-post/src/components/visual-editor/index.js +++ b/packages/edit-post/src/components/visual-editor/index.js @@ -31,11 +31,9 @@ import { __experimentaluseLayoutStyles as useLayoutStyles, } from '@wordpress/block-editor'; import { useEffect, useRef, useMemo } from '@wordpress/element'; -import { Button, __unstableMotion as motion } from '@wordpress/components'; -import { useSelect, useDispatch } from '@wordpress/data'; +import { __unstableMotion as motion } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; import { useMergeRefs } from '@wordpress/compose'; -import { arrowLeft } from '@wordpress/icons'; -import { __ } from '@wordpress/i18n'; import { parse } from '@wordpress/blocks'; import { store as coreStore } from '@wordpress/core-data'; @@ -175,8 +173,6 @@ export default function VisualEditor( { styles } ) { _settings.__experimentalFeatures?.useRootPaddingAwareAlignments, }; }, [] ); - const { clearSelectedBlock } = useDispatch( blockEditorStore ); - const { setIsEditingTemplate } = useDispatch( editPostStore ); const desktopCanvasStyles = { height: '100%', width: '100%', @@ -349,18 +345,6 @@ export default function VisualEditor( { styles } ) { } } ref={ blockSelectionClearerRef } > - { isTemplateMode && ( - - ) } { ) ).toBeVisible(); } ); - - test( 'Allow editing the title of a new custom template', async ( { - page, - postEditorTemplateMode, - } ) => { - async function editTemplateTitle( newTitle ) { - await page - .getByRole( 'button', { name: 'Template Options' } ) - .click(); - - await page - .getByRole( 'textbox', { name: 'Title' } ) - .fill( newTitle ); - - const editorContent = page.getByLabel( 'Editor Content' ); - await editorContent.click(); - } - - await postEditorTemplateMode.createPostAndSaveDraft(); - await postEditorTemplateMode.createNewTemplate( 'Foobar' ); - await editTemplateTitle( 'Barfoo' ); - - await expect( - page.getByRole( 'button', { name: 'Template Options' } ) - ).toHaveText( 'Barfoo' ); - } ); - - test.describe( 'Delete Post Template Confirmation Dialog', () => { - test.beforeAll( async ( { requestUtils } ) => { - await requestUtils.activateTheme( 'twentytwentyone' ); - } ); - - test.beforeEach( async ( { postEditorTemplateMode } ) => { - await postEditorTemplateMode.createPostAndSaveDraft(); - } ); - - [ 'large', 'small' ].forEach( ( viewport ) => { - test( `should retain template if deletion is canceled when the viewport is ${ viewport }`, async ( { - editor, - page, - pageUtils, - postEditorTemplateMode, - } ) => { - await pageUtils.setBrowserViewport( viewport ); - - await postEditorTemplateMode.disableTemplateWelcomeGuide(); - - const templateTitle = `${ viewport } Viewport Deletion Test`; - - await postEditorTemplateMode.createNewTemplate( templateTitle ); - - // Close the settings in small viewport. - if ( viewport === 'small' ) { - await page.click( 'role=button[name="Close settings"i]' ); - } - - // Edit the template. - await editor.insertBlock( { name: 'core/paragraph' } ); - await page.keyboard.type( - 'Just a random paragraph added to the template' - ); - - await postEditorTemplateMode.saveTemplateWithoutPublishing(); - - // Test deletion dialog. - { - const templateDropdown = - postEditorTemplateMode.editorTopBar.locator( - 'role=button[name="Template Options"i]' - ); - await templateDropdown.click(); - await page.click( - 'role=menuitem[name="Delete template"i]' - ); - - const confirmDeletionDialog = page.locator( 'role=dialog' ); - await expect( confirmDeletionDialog ).toBeFocused(); - await expect( - confirmDeletionDialog.locator( - `text=Are you sure you want to delete the ${ templateTitle } template? It may be used by other pages or posts.` - ) - ).toBeVisible(); - - await confirmDeletionDialog - .locator( 'role=button[name="Cancel"i]' ) - .click(); - } - - // Exit template mode. - await page.click( 'role=button[name="Back"i]' ); - - await editor.openDocumentSettingsSidebar(); - - // Move focus to the "Post" panel in the editor sidebar. - const postPanel = - postEditorTemplateMode.editorSettingsSidebar.locator( - 'role=button[name="Post"i]' - ); - await postPanel.click(); - - await postEditorTemplateMode.openTemplatePopover(); - - const templateSelect = page.locator( - 'role=combobox[name="Template"i]' - ); - await expect( templateSelect ).toHaveValue( - `${ viewport }-viewport-deletion-test` - ); - } ); - - test( `should delete template if deletion is confirmed when the viewport is ${ viewport }`, async ( { - editor, - page, - pageUtils, - postEditorTemplateMode, - } ) => { - const templateTitle = `${ viewport } Viewport Deletion Test`; - - await pageUtils.setBrowserViewport( viewport ); - - await postEditorTemplateMode.createNewTemplate( templateTitle ); - - // Close the settings in small viewport. - if ( viewport === 'small' ) { - await page.click( 'role=button[name="Close settings"i]' ); - } - - // Edit the template. - await editor.insertBlock( { name: 'core/paragraph' } ); - await page.keyboard.type( - 'Just a random paragraph added to the template' - ); - - await postEditorTemplateMode.saveTemplateWithoutPublishing(); - - { - const templateDropdown = - postEditorTemplateMode.editorTopBar.locator( - 'role=button[name="Template Options"i]' - ); - await templateDropdown.click(); - await page.click( - 'role=menuitem[name="Delete template"i]' - ); - - const confirmDeletionDialog = page.locator( 'role=dialog' ); - await expect( confirmDeletionDialog ).toBeFocused(); - await expect( - confirmDeletionDialog.locator( - `text=Are you sure you want to delete the ${ templateTitle } template? It may be used by other pages or posts.` - ) - ).toBeVisible(); - - await confirmDeletionDialog - .locator( 'role=button[name="OK"i]' ) - .click(); - } - - // Saving isn't technically necessary, but for themes without any specified templates, - // the removal of the Templates dropdown is delayed. A save and reload allows for this - // delay and prevents flakiness - { - await page.click( 'role=button[name="Save draft"i]' ); - await page.waitForSelector( - 'role=button[name="Dismiss this notice"] >> text=Draft saved' - ); - await page.reload(); - } - - const templateOptions = - postEditorTemplateMode.editorSettingsSidebar.locator( - 'role=combobox[name="Template:"i] >> role=menuitem' - ); - const availableTemplates = - await templateOptions.allTextContents(); - - expect( availableTemplates ).not.toContain( - `${ viewport } Viewport Deletion Test` - ); - } ); - } ); - } ); } ); class PostEditorTemplateMode { @@ -331,7 +149,9 @@ class PostEditorTemplateMode { 'role=button[name="Dismiss this notice"] >> text=Editing template. Changes made here affect all posts and pages that use the template.' ); - await expect( this.editorTopBar ).toHaveText( /Just an FSE Post/ ); + await expect( + this.editorTopBar.getByRole( 'heading[level=1]' ) + ).toHaveText( 'Editing template: Singular' ); } async createPostAndSaveDraft() { From ecd550ba6ce66ea37c203d3438a2eeb70f315d36 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Tue, 30 May 2023 13:58:04 +0200 Subject: [PATCH 59/91] Navigation block: Set correct aria-expanded on hover (#50953) * Set correct aria-expanded on hover * Store both `click` and `hover` in `isMenuOpen` * Fix nav menu * Remove menuOpenedOn debugger * Add comments and fix example HTML * Fix PHP lint * Just in case openSubmenusOnClick exists but it's false * Switch to isMenuOpen selector in roleAttribute --- lib/experimental/interactivity-api/blocks.php | 62 +++++---- .../src/navigation/interactivity.js | 130 ++++++++++-------- 2 files changed, 109 insertions(+), 83 deletions(-) diff --git a/lib/experimental/interactivity-api/blocks.php b/lib/experimental/interactivity-api/blocks.php index 3ad6d13d660fb1..d9a2b1b2718a8c 100644 --- a/lib/experimental/interactivity-api/blocks.php +++ b/lib/experimental/interactivity-api/blocks.php @@ -35,18 +35,18 @@ function gutenberg_block_core_file_add_directives_to_content( $block_content, $b * * * * @param string $block_content Markup of the navigation block. + * @param array $block Block object. * * @return string Navigation block markup with the proper directives */ -function gutenberg_block_core_navigation_add_directives_to_markup( $block_content ) { +function gutenberg_block_core_navigation_add_directives_to_markup( $block_content, $block ) { $w = new WP_HTML_Tag_Processor( $block_content ); // Add directives to the `