diff --git a/README.md b/README.md index 5ba112319b405c..d5b299baadc7f8 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Get hands on: check out the [block editor live demo](https://wordpress.org/guten Extending and customizing is at the heart of the WordPress platform, this is no different for the Gutenberg project. The editor and future products can be extended by third-party developers using plugins. -Review the [Create a Block tutorial](/docs/getting-started/create-block/README.md) for the fastest way to get started extending the block editor. See the [Developer Documentation](https://developer.wordpress.org/block-editor/#develop-for-the-block-editor) for extensive tutorials, documentation, and API references. +Review the [Create a Block tutorial](/docs/getting-started/devenv/get-started-with-create-block.md) for the fastest way to get started extending the block editor. See the [Developer Documentation](https://developer.wordpress.org/block-editor/#develop-for-the-block-editor) for extensive tutorials, documentation, and API references. ### Contribute to Gutenberg diff --git a/changelog.txt b/changelog.txt index a66e3c00073d37..de2e8d243ff740 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,36 @@ == Changelog == += 17.4.1 = + +## Changelog + +### Bug Fixes + +- Rich text: add HTML string methods to RichTextData ([57322](https://github.com/WordPress/gutenberg/pull/57322)) + +## Contributors + +The following contributors merged PRs in this release: + +@ellatrix + + += 17.3.1 = + +## Changelog + +### Bug Fixes + +- Rich text: add HTML string methods to RichTextData ([57322](https://github.com/WordPress/gutenberg/pull/57322)) + +## Contributors + +The following contributors merged PRs in this release: + +@ellatrix + + + = 17.4.0 = diff --git a/docs/contributors/documentation/README.md b/docs/contributors/documentation/README.md index 7089bf57141687..454f455ba891db 100644 --- a/docs/contributors/documentation/README.md +++ b/docs/contributors/documentation/README.md @@ -31,7 +31,7 @@ See [the Git Workflow](/docs/contributors/code/git-workflow.md) documentation fo The handbook is organized into four sections based on the functional types of documents. [The Documentation System](https://documentation.divio.com/) does a great job explaining the needs and functions of each type, but in short they are: -- **Getting started tutorials** - full lessons that take learners step by step to complete an objective, for example the [create a block tutorial](/docs/getting-started/create-block/README.md). +- **Getting started tutorials** - full lessons that take learners step by step to complete an objective, for example the [create a block tutorial](/docs/getting-started/devenv/get-started-with-create-block.md). - **How to guides** - short lessons specific to completing a small specific task, for example [how to add a button to the block toolbar](/docs/how-to-guides/format-api.md). - **Reference guides** - API documentation, purely functional descriptions, - **Explanations** - longer documentation focused on learning, not a specific task. @@ -164,7 +164,7 @@ This is a **warning** callout. Note: In callout notices, links also need to be HTML `<a href></a>` notations. The usual link transformation is not applied to links in callouts. For instance, to reach the Getting started > Create Block page the URL in GitHub is -https://developer.wordpress.org/docs/getting-started/create-block/README.md +https://developer.wordpress.org/docs/getting-started/devenv/get-started-with-create-block.md and will have to be hardcoded for the endpoint in the Block Editor Handbook as https://developer.wordpress.org/block-editor/getting-started/create-block/ to link correctly in the handbook. diff --git a/docs/explanations/architecture/key-concepts.md b/docs/explanations/architecture/key-concepts.md index a041b86effdc3b..30318d29422d37 100644 --- a/docs/explanations/architecture/key-concepts.md +++ b/docs/explanations/architecture/key-concepts.md @@ -39,7 +39,7 @@ Given a block type, a block variation is a predefined set of its initial attribu **More on blocks** - **[Block API](/docs/reference-guides/block-api/README.md)** -- **[Tutorial: Building A Custom Block](/docs/getting-started/create-block/README.md)** +- **[Tutorial: Building A Custom Block](/docs/getting-started/devenv/get-started-with-create-block.md)** ## Reusable blocks diff --git a/docs/explanations/user-interface/block-design.md b/docs/explanations/user-interface/block-design.md index e3a7b84bfa583e..66411744fa5663 100644 --- a/docs/explanations/user-interface/block-design.md +++ b/docs/explanations/user-interface/block-design.md @@ -74,7 +74,7 @@ When referring to a block in documentation or UI, use title case for the block t - Latest Posts block - Media & Text block -Blocks should have an identifying icon, ideally using a single color. Try to avoid using the same icon used by an existing block. The core block icons are based on [Material Design Icons](https://material.io/tools/icons/). Look to that icon set, or to [Dashicons](https://developer.wordpress.org/resource/dashicons/) for style inspiration. +Blocks should have an identifying icon, ideally using a single color. Try to avoid using the same icon used by an existing block. The core block icons are based on [Material Design Icons](https://fonts.google.com/icons). Look to that icon set, or to [Dashicons](https://developer.wordpress.org/resource/dashicons/) for style inspiration. ![A screenshot of the block library with concise block names](https://developer.wordpress.org/files/2022/01/blocks-do.png) **Do:** diff --git a/docs/getting-started/quick-start-guide.md b/docs/getting-started/quick-start-guide.md index e978b250ab8aff..c6f22ce219136c 100644 --- a/docs/getting-started/quick-start-guide.md +++ b/docs/getting-started/quick-start-guide.md @@ -25,7 +25,7 @@ Navigate to the Plugins page of your local WordPress installation and activate t ## Basic usage -With the plugin activated, you can explore how the block works. Use the following command to move into the newly created plugin folder and start the development process. +With the plugin activated, you can explore how the block works. Use the following command to move into the newly created plugin folder and start the development process. ```sh cd copyright-date-block && npm start @@ -37,6 +37,14 @@ The `npm start` command will start a development server and watch for changes in When you are finished making changes, run the `npm run build` command. This optimizes the block code and makes it production-ready. +## View the block in action + +You can use any local WordPress development environment to test your new block, but the scaffolded plugin includes configuration for `wp-env`. You must have [Docker](https://www.docker.com/products/docker-desktop) already installed and running on your machine, but if you do, run the `npx wp-env start` command. + +Once the script finishes running, you can access the local environment at: `http://localhost:8888`. Log into the WordPress dashboard using username `admin` and password `password`. The plugin will already be installed and activated. Open the Editor or Site Editor, and insert the Copyright Date Block as you would any other block. + +Visit the [Getting started](https://developer.wordpress.org/block-editor/getting-started/devenv/get-started-with-wp-env/) guide to learn more about `wp-env`. + ## Additional resources - [Get started with create-block](https://developer.wordpress.org/block-editor/getting-started/devenv/get-started-with-create-block/) diff --git a/docs/how-to-guides/README.md b/docs/how-to-guides/README.md index 152f8ce6184ae2..c57dcad7a35289 100644 --- a/docs/how-to-guides/README.md +++ b/docs/how-to-guides/README.md @@ -6,7 +6,7 @@ The new editor is highly flexible, like most of WordPress. You can build custom The editor is about blocks, and the main extensibility API is the Block API. It allows you to create your own static blocks, [Dynamic Blocks](/docs/how-to-guides/block-tutorial/creating-dynamic-blocks.md) ( rendered on the server ) and also blocks capable of saving data to Post Meta for more structured content. -If you want to learn more about block creation, see the [Create a Block tutorial](/docs/getting-started/create-block/README.md) for the best place to start. +If you want to learn more about block creation, see the [Create a Block tutorial](/docs/getting-started/devenv/get-started-with-create-block.md) for the best place to start. ## Extending blocks diff --git a/docs/how-to-guides/block-tutorial/applying-styles-with-stylesheets.md b/docs/how-to-guides/block-tutorial/applying-styles-with-stylesheets.md index 697984c9456e02..41646bbe527cfb 100644 --- a/docs/how-to-guides/block-tutorial/applying-styles-with-stylesheets.md +++ b/docs/how-to-guides/block-tutorial/applying-styles-with-stylesheets.md @@ -6,7 +6,7 @@ A block typically inserts markup (HTML) into post content that you want to style ## Before you start -You will need a basic block and WordPress development environment to implement the examples shown in this guide. See the [create a basic block](/docs/how-to-guides/block-tutorial/writing-your-first-block-type.md) or [block tutorial](/docs/getting-started/create-block/README.md) to get setup. +You will need a basic block and WordPress development environment to implement the examples shown in this guide. See the [create a basic block](/docs/how-to-guides/block-tutorial/writing-your-first-block-type.md) or [block tutorial](/docs/getting-started/devenv/get-started-with-create-block.md) to get setup. ## Methods to add style diff --git a/docs/how-to-guides/metabox.md b/docs/how-to-guides/metabox.md index b1baac1f255855..9db89f51c748d8 100644 --- a/docs/how-to-guides/metabox.md +++ b/docs/how-to-guides/metabox.md @@ -18,7 +18,7 @@ This guide shows how to create a block that prompts a user for a single value, a This guide assumes you are already familiar with WordPress plugins, post meta, and basic JavaScript. Review the [Getting started with JavaScript tutorial](/docs/how-to-guides/javascript/README.md) for an introduction. -The guide will walk through creating a basic block, but recommended to go through the [Create Block tutorial](/docs/getting-started/create-block/README.md) for a deeper understanding of creating custom blocks. +The guide will walk through creating a basic block, but recommended to go through the [Create Block tutorial](/docs/getting-started/devenv/get-started-with-create-block.md) for a deeper understanding of creating custom blocks. You will need: diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index b091f9d143b7ad..c05cdd3eb009b1 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -630,7 +630,7 @@ Display a post's featured image. ([Source](https://github.com/WordPress/gutenber - **Name:** core/post-featured-image - **Category:** theme - **Supports:** align (center, full, left, right, wide), color (~~background~~, ~~text~~), spacing (margin, padding), ~~html~~ -- **Attributes:** aspectRatio, customGradient, customOverlayColor, dimRatio, gradient, height, isLink, linkTarget, overlayColor, rel, scale, sizeSlug, width +- **Attributes:** aspectRatio, customGradient, customOverlayColor, dimRatio, gradient, height, isLink, linkTarget, overlayColor, rel, scale, sizeSlug, useFirstImageFromPost, width ## Post Navigation Link @@ -799,7 +799,7 @@ Help visitors find your content. ([Source](https://github.com/WordPress/gutenber - **Name:** core/search - **Category:** widgets - **Supports:** align (center, left, right), color (background, gradients, text), interactivity, typography (fontSize, lineHeight), ~~html~~ -- **Attributes:** buttonBehavior, buttonPosition, buttonText, buttonUseIcon, isSearchFieldHidden, label, placeholder, query, showLabel, width, widthUnit +- **Attributes:** buttonPosition, buttonText, buttonUseIcon, isSearchFieldHidden, label, placeholder, query, showLabel, width, widthUnit ## Separator diff --git a/lib/block-supports/background.php b/lib/block-supports/background.php index c43603046c0c08..4b5f5614d64c9f 100644 --- a/lib/block-supports/background.php +++ b/lib/block-supports/background.php @@ -96,6 +96,7 @@ function gutenberg_render_background_support( $block_content, $block ) { $updated_style .= $styles['css']; $tags->set_attribute( 'style', $updated_style ); + $tags->add_class( 'has-background' ); } return $tags->get_updated_html(); diff --git a/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php b/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php index ea94128e1dde29..9c270f59fa220e 100644 --- a/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php +++ b/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php @@ -361,16 +361,25 @@ private static function get_classes( $attributes ) { $text_decoration = $attributes['style']['typography']['textDecoration'] ?? null; $text_decoration_class = sprintf( 'has-text-decoration-%s', $text_decoration ); + // Sets the is-collapsed class when the navigation is set to always use the overlay. + // This saves us from needing to do this check in the view.js file (see the collapseNav function). + $is_collapsed_class = static::is_always_overlay( $attributes ) ? array( 'is-collapsed' ) : array(); + $classes = array_merge( $colors['css_classes'], $font_sizes['css_classes'], $is_responsive_menu ? array( 'is-responsive' ) : array(), $layout_class ? array( $layout_class ) : array(), - $text_decoration ? array( $text_decoration_class ) : array() + $text_decoration ? array( $text_decoration_class ) : array(), + $is_collapsed_class ); return implode( ' ', $classes ); } + private static function is_always_overlay( $attributes ) { + return isset( $attributes['overlayMenu'] ) && 'always' === $attributes['overlayMenu']; + } + /** * Get styles for the navigation block. * @@ -397,16 +406,12 @@ private static function get_responsive_container_markup( $attributes, $inner_blo $colors = gutenberg_block_core_navigation_build_css_colors( $attributes ); $modal_unique_id = wp_unique_id( 'modal-' ); - $is_hidden_by_default = isset( $attributes['overlayMenu'] ) && 'always' === $attributes['overlayMenu']; - $responsive_container_classes = array( 'wp-block-navigation__responsive-container', - $is_hidden_by_default ? 'hidden-by-default' : '', implode( ' ', $colors['overlay_css_classes'] ), ); $open_button_classes = array( 'wp-block-navigation__responsive-container-open', - $is_hidden_by_default ? 'always-shown' : '', ); $should_display_icon_label = isset( $attributes['hasIcon'] ) && true === $attributes['hasIcon']; @@ -504,7 +509,7 @@ private static function get_nav_wrapper_attributes( $attributes, $inner_blocks ) ); if ( $is_responsive_menu ) { - $nav_element_directives = static::get_nav_element_directives( $should_load_view_script ); + $nav_element_directives = static::get_nav_element_directives( $should_load_view_script, $attributes ); $wrapper_attributes .= ' ' . $nav_element_directives; } @@ -517,12 +522,12 @@ private static function get_nav_wrapper_attributes( $attributes, $inner_blocks ) * @param bool $should_load_view_script Whether or not the view script should be loaded. * @return string the directives for the navigation element. */ - private static function get_nav_element_directives( $should_load_view_script ) { + private static function get_nav_element_directives( $should_load_view_script, $attributes ) { if ( ! $should_load_view_script ) { return ''; } // When adding to this array be mindful of security concerns. - $nav_element_context = wp_json_encode( + $nav_element_context = wp_json_encode( array( 'overlayOpenedBy' => array(), 'type' => 'overlay', @@ -531,10 +536,20 @@ private static function get_nav_element_directives( $should_load_view_script ) { ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP ); - return ' + $nav_element_directives = ' data-wp-interactive=\'{"namespace":"core/navigation"}\' data-wp-context=\'' . $nav_element_context . '\' '; + + // When the navigation overlayMenu attribute is set to "always" + // we don't need to use JavaScript to collapse the menu as we set the class manually. + if ( ! static::is_always_overlay( $attributes ) ) { + $nav_element_directives .= 'data-wp-init="callbacks.initNav"'; + $nav_element_directives .= ' '; // space separator + $nav_element_directives .= 'data-wp-class--is-collapsed="context.isCollapsed"'; + } + + return $nav_element_directives; } /** diff --git a/package-lock.json b/package-lock.json index d2af06262aec26..326c7ac7bf80c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gutenberg", - "version": "17.4.0", + "version": "17.4.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "gutenberg", - "version": "17.4.0", + "version": "17.4.1", "hasInstallScript": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -55615,7 +55615,7 @@ }, "packages/react-native-aztec": { "name": "@wordpress/react-native-aztec", - "version": "1.109.3", + "version": "1.110.0", "license": "GPL-2.0-or-later", "dependencies": { "@wordpress/element": "file:../element", @@ -55628,7 +55628,7 @@ }, "packages/react-native-bridge": { "name": "@wordpress/react-native-bridge", - "version": "1.109.3", + "version": "1.110.0", "license": "GPL-2.0-or-later", "dependencies": { "@wordpress/react-native-aztec": "file:../react-native-aztec" @@ -55639,7 +55639,7 @@ }, "packages/react-native-editor": { "name": "@wordpress/react-native-editor", - "version": "1.109.3", + "version": "1.110.0", "hasInstallScript": true, "license": "GPL-2.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 5753cbfea0851e..5f99726146658e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "17.4.0", + "version": "17.4.1", "private": true, "description": "A new WordPress editor experience.", "author": "The WordPress Contributors", diff --git a/packages/block-editor/src/components/block-lock/toolbar.js b/packages/block-editor/src/components/block-lock/toolbar.js index 14a941a9011b6d..ccf04c5e5262d7 100644 --- a/packages/block-editor/src/components/block-lock/toolbar.js +++ b/packages/block-editor/src/components/block-lock/toolbar.js @@ -3,9 +3,8 @@ */ import { __ } from '@wordpress/i18n'; import { ToolbarButton, ToolbarGroup } from '@wordpress/components'; -import { focus } from '@wordpress/dom'; import { useReducer, useRef, useEffect } from '@wordpress/element'; -import { lock } from '@wordpress/icons'; +import { lock, unlock } from '@wordpress/icons'; /** * Internal dependencies @@ -13,58 +12,28 @@ import { lock } from '@wordpress/icons'; import BlockLockModal from './modal'; import useBlockLock from './use-block-lock'; -export default function BlockLockToolbar( { clientId, wrapperRef } ) { - const { canEdit, canMove, canRemove, canLock } = useBlockLock( clientId ); +export default function BlockLockToolbar( { clientId } ) { + const { canLock, isLocked } = useBlockLock( clientId ); const [ isModalOpen, toggleModal ] = useReducer( ( isActive ) => ! isActive, false ); - const lockButtonRef = useRef( null ); - const isFirstRender = useRef( true ); - const hasModalOpened = useRef( false ); + const hasLockButtonShown = useRef( false ); - const shouldHideBlockLockUI = - ! canLock || ( canEdit && canMove && canRemove ); - - // Restore focus manually on the first focusable element in the toolbar - // when the block lock modal is closed and the block is not locked anymore. - // See https://github.com/WordPress/gutenberg/issues/51447 + // If the block lock button has been shown, we don't want to remove it + // from the toolbar until the toolbar is rendered again without it. + // Removing it beforehand can cause focus loss issues, such as when + // unlocking the block from the modal. It needs to return focus from + // whence it came, and to do that, we need to leave the button in the toolbar. useEffect( () => { - if ( isFirstRender.current ) { - isFirstRender.current = false; - return; - } - - if ( isModalOpen && ! hasModalOpened.current ) { - hasModalOpened.current = true; - } - - // We only want to allow this effect to happen if the modal has been opened. - // The issue is when we're returning focus from the block lock modal to a toolbar, - // so it can only happen after a modal has been opened. Without this, the toolbar - // will steal focus on rerenders. - if ( - hasModalOpened.current && - ! isModalOpen && - shouldHideBlockLockUI - ) { - focus.focusable - .find( wrapperRef.current, { - sequential: false, - } ) - .find( - ( element ) => - element.tagName === 'BUTTON' && - element !== lockButtonRef.current - ) - ?.focus(); + if ( isLocked ) { + hasLockButtonShown.current = true; } - // wrapperRef is a reference object and should be stable - }, [ isModalOpen, shouldHideBlockLockUI, wrapperRef ] ); + }, [ isLocked ] ); - if ( shouldHideBlockLockUI ) { + if ( ! canLock || ( ! isLocked && ! hasLockButtonShown.current ) ) { return null; } @@ -72,9 +41,8 @@ export default function BlockLockToolbar( { clientId, wrapperRef } ) { <> { ! isMultiToolbar && ( ) } { const blocks = pasteHandler( { HTML, mode: 'BLOCKS' } ); @@ -309,17 +301,12 @@ export default function useOnBlockDrop( ); const _onFilesDrop = onFilesDrop( targetRootClientId, - targetBlockIndex, getSettings, updateBlockAttributes, canInsertBlockType, insertOrReplaceBlocks ); - const _onHTMLDrop = onHTMLDrop( - targetRootClientId, - targetBlockIndex, - insertOrReplaceBlocks - ); + const _onHTMLDrop = onHTMLDrop( insertOrReplaceBlocks ); return ( event ) => { const files = getFilesFromDataTransfer( event.dataTransfer ); diff --git a/packages/block-editor/src/components/use-on-block-drop/test/index.js b/packages/block-editor/src/components/use-on-block-drop/test/index.js index 03c873bf6a8fd7..cd4ef2ad4a8894 100644 --- a/packages/block-editor/src/components/use-on-block-drop/test/index.js +++ b/packages/block-editor/src/components/use-on-block-drop/test/index.js @@ -306,12 +306,10 @@ describe( 'onFilesDrop', () => { const canInsertBlockType = noop; const insertOrReplaceBlocks = jest.fn(); const targetRootClientId = '1'; - const targetBlockIndex = 0; const getSettings = jest.fn( () => ( {} ) ); const onFileDropHandler = onFilesDrop( targetRootClientId, - targetBlockIndex, getSettings, updateBlockAttributes, canInsertBlockType, @@ -331,14 +329,12 @@ describe( 'onFilesDrop', () => { const insertOrReplaceBlocks = jest.fn(); const canInsertBlockType = noop; const targetRootClientId = '1'; - const targetBlockIndex = 0; const getSettings = jest.fn( () => ( { mediaUpload: true, } ) ); const onFileDropHandler = onFilesDrop( targetRootClientId, - targetBlockIndex, getSettings, updateBlockAttributes, canInsertBlockType, @@ -361,14 +357,12 @@ describe( 'onFilesDrop', () => { const canInsertBlockType = noop; const insertOrReplaceBlocks = jest.fn(); const targetRootClientId = '1'; - const targetBlockIndex = 0; const getSettings = jest.fn( () => ( { mediaUpload: true, } ) ); const onFileDropHandler = onFilesDrop( targetRootClientId, - targetBlockIndex, getSettings, updateBlockAttributes, canInsertBlockType, @@ -389,15 +383,9 @@ describe( 'onFilesDrop', () => { describe( 'onHTMLDrop', () => { it( 'does nothing if the HTML cannot be converted into blocks', () => { pasteHandler.mockImplementation( () => [] ); - const targetRootClientId = '1'; - const targetBlockIndex = 0; const insertOrReplaceBlocks = jest.fn(); - const eventHandler = onHTMLDrop( - targetRootClientId, - targetBlockIndex, - insertOrReplaceBlocks - ); + const eventHandler = onHTMLDrop( insertOrReplaceBlocks ); eventHandler(); expect( insertOrReplaceBlocks ).not.toHaveBeenCalled(); @@ -406,15 +394,9 @@ describe( 'onHTMLDrop', () => { it( 'inserts blocks if the HTML can be converted into blocks', () => { const blocks = [ 'blocks' ]; pasteHandler.mockImplementation( () => blocks ); - const targetRootClientId = '1'; - const targetBlockIndex = 0; const insertOrReplaceBlocks = jest.fn(); - const eventHandler = onHTMLDrop( - targetRootClientId, - targetBlockIndex, - insertOrReplaceBlocks - ); + const eventHandler = onHTMLDrop( insertOrReplaceBlocks ); eventHandler(); expect( insertOrReplaceBlocks ).toHaveBeenCalledWith( blocks ); diff --git a/packages/block-editor/src/hooks/background.js b/packages/block-editor/src/hooks/background.js index 67373ecd0516ca..d093d3da55c8d6 100644 --- a/packages/block-editor/src/hooks/background.js +++ b/packages/block-editor/src/hooks/background.js @@ -137,6 +137,17 @@ function resetBackgroundSize( style = {}, setAttributes ) { } ); } +/** + * Generates a CSS class name if an background image is set. + * + * @param {Object} style A block's style attribute. + * + * @return {string} CSS class name. + */ +export function getBackgroundImageClasses( style ) { + return hasBackgroundImageValue( style ) ? 'has-background' : ''; +} + function InspectorImagePreview( { label, filename, url: imgUrl } ) { const imgLabel = label || getFilename( imgUrl ); return ( diff --git a/packages/block-editor/src/hooks/color.js b/packages/block-editor/src/hooks/color.js index 5767db829d1b37..0995f877309cc2 100644 --- a/packages/block-editor/src/hooks/color.js +++ b/packages/block-editor/src/hooks/color.js @@ -24,6 +24,7 @@ import { transformStyles, shouldSkipSerialization, } from './utils'; +import { getBackgroundImageClasses } from './background'; import { useSettings } from '../components/use-settings'; import InspectorControls from '../components/inspector-controls'; import { @@ -383,12 +384,27 @@ function useBlockProps( { )?.color; } - return addSaveProps( { style: extraStyles }, name, { + const saveProps = addSaveProps( { style: extraStyles }, name, { textColor, backgroundColor, gradient, style, } ); + + const hasBackgroundValue = + backgroundColor || + style?.color?.background || + gradient || + style?.color?.gradient; + + return { + ...saveProps, + className: classnames( + saveProps.className, + // Add background image classes in the editor, if not already handled by background color values. + ! hasBackgroundValue && getBackgroundImageClasses( style ) + ), + }; } export default { diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index 1892d614dba2a1..607f073323d996 100644 --- a/packages/block-library/src/block/edit.js +++ b/packages/block-library/src/block/edit.js @@ -27,7 +27,7 @@ import { store as blockEditorStore, BlockControls, } from '@wordpress/block-editor'; -import { getBlockSupport, parse } from '@wordpress/blocks'; +import { getBlockSupport, parse, cloneBlock } from '@wordpress/blocks'; /** * Internal dependencies @@ -183,8 +183,7 @@ export default function ReusableBlockEdit( { innerBlocks: blocks, userCanEdit: canEdit, getBlockEditingMode: editingMode, - getPostLinkProps: - getSettings().__experimentalGetPostLinkProps, + getPostLinkProps: getSettings().getPostLinkProps, }; }, [ patternClientId, ref ] @@ -206,7 +205,8 @@ export default function ReusableBlockEdit( { // Apply the initial overrides from the pattern block to the inner blocks. useEffect( () => { const initialBlocks = - editedRecord.blocks ?? + // Clone the blocks to generate new client IDs. + editedRecord.blocks?.map( ( block ) => cloneBlock( block ) ) ?? ( editedRecord.content && typeof editedRecord.content !== 'function' ? parse( editedRecord.content ) : [] ); diff --git a/packages/block-library/src/navigation/constants.js b/packages/block-library/src/navigation/constants.js index 21fc8bfdfb74d0..c712bc4000c36d 100644 --- a/packages/block-library/src/navigation/constants.js +++ b/packages/block-library/src/navigation/constants.js @@ -37,3 +37,5 @@ export const SELECT_NAVIGATION_MENUS_ARGS = [ 'wp_navigation', PRELOADED_NAVIGATION_MENUS_QUERY, ]; + +export const NAVIGATION_MOBILE_COLLAPSE = '600px'; diff --git a/packages/block-library/src/navigation/edit/index.js b/packages/block-library/src/navigation/edit/index.js index 2e94cddcc9bc24..5589e8ea9e60f0 100644 --- a/packages/block-library/src/navigation/edit/index.js +++ b/packages/block-library/src/navigation/edit/index.js @@ -42,7 +42,7 @@ import { import { __, sprintf } from '@wordpress/i18n'; import { speak } from '@wordpress/a11y'; import { close, Icon } from '@wordpress/icons'; -import { useInstanceId } from '@wordpress/compose'; +import { useInstanceId, useMediaQuery } from '@wordpress/compose'; /** * Internal dependencies @@ -71,6 +71,7 @@ import MenuInspectorControls from './menu-inspector-controls'; import DeletedNavigationWarning from './deleted-navigation-warning'; import AccessibleDescription from './accessible-description'; import AccessibleMenuDescription from './accessible-menu-description'; +import { NAVIGATION_MOBILE_COLLAPSE } from '../constants'; import { unlock } from '../../lock-unlock'; function Navigation( { @@ -297,6 +298,14 @@ function Navigation( { [ clientId ] ); const isResponsive = 'never' !== overlayMenu; + const isMobileBreakPoint = useMediaQuery( + `(max-width: ${ NAVIGATION_MOBILE_COLLAPSE })` + ); + + const isCollapsed = + ( 'mobile' === overlayMenu && isMobileBreakPoint ) || + 'always' === overlayMenu; + const blockProps = useBlockProps( { ref: navRef, className: classnames( @@ -310,6 +319,7 @@ function Navigation( { 'is-vertical': orientation === 'vertical', 'no-wrap': flexWrap === 'nowrap', 'is-responsive': isResponsive, + 'is-collapsed': isCollapsed, 'has-text-color': !! textColor.color || !! textColor?.class, [ getColorClassName( 'color', textColor?.slug ) ]: !! textColor?.slug, diff --git a/packages/block-library/src/navigation/editor.scss b/packages/block-library/src/navigation/editor.scss index 107fb6e6de5fd5..eb796ae6965412 100644 --- a/packages/block-library/src/navigation/editor.scss +++ b/packages/block-library/src/navigation/editor.scss @@ -429,7 +429,7 @@ $color-control-label-height: 20px; // These needs extra specificity in the editor. .wp-block-navigation__responsive-container:not(.is-menu-open) { .components-button.wp-block-navigation__responsive-container-close { - @include break-small { + .is-collapsed & { display: none; } } diff --git a/packages/block-library/src/navigation/style.scss b/packages/block-library/src/navigation/style.scss index 0b70ebb656cfa8..3f11c5564306cf 100644 --- a/packages/block-library/src/navigation/style.scss +++ b/packages/block-library/src/navigation/style.scss @@ -611,18 +611,19 @@ button.wp-block-navigation-item__content { } } - @include break-small() { - &:not(.hidden-by-default) { - &:not(.is-menu-open) { - display: block; - width: 100%; - position: relative; - z-index: auto; - background-color: inherit; - - .wp-block-navigation__responsive-container-close { - display: none; - } + // When the menu is collapsed, the menu button is visible. + // We are using the > selector combined with the :not(is-collapsed) selector + // as a way to target the class being added to the parent nav element. + :not(.is-collapsed) > & { + &:not(.is-menu-open) { + display: block; + width: 100%; + position: relative; + z-index: auto; + background-color: inherit; + + .wp-block-navigation__responsive-container-close { + display: none; } } @@ -686,10 +687,11 @@ button.wp-block-navigation-item__content { font-size: inherit; } - &:not(.always-shown) { - @include break-small { - display: none; - } + // When the menu is collapsed, the menu button is visible. + // We are using the > selector combined with the :not(is-collapsed) selector + // as a way to target the class being added to the parent nav element. + :not(.is-collapsed) > & { + display: none; } } diff --git a/packages/block-library/src/navigation/view.js b/packages/block-library/src/navigation/view.js index fb3919168a2677..d42832a1f8d02e 100644 --- a/packages/block-library/src/navigation/view.js +++ b/packages/block-library/src/navigation/view.js @@ -3,6 +3,11 @@ */ import { store, getContext, getElement } from '@wordpress/interactivity'; +/** + * Internal dependencies + */ +import { NAVIGATION_MOBILE_COLLAPSE } from './constants'; + const focusableSelectors = [ 'a[href]', 'input:not([disabled]):not([type="hidden"]):not([aria-hidden])', @@ -185,5 +190,26 @@ const { state, actions } = store( 'core/navigation', { focusableElements?.[ 0 ]?.focus(); } }, + initNav() { + const context = getContext(); + const mediaQuery = window.matchMedia( + `(max-width: ${ NAVIGATION_MOBILE_COLLAPSE })` + ); + + // Run once to set the initial state. + context.isCollapsed = mediaQuery.matches; + + function handleCollapse( event ) { + context.isCollapsed = event.matches; + } + + // Run on resize to update the state. + mediaQuery.addEventListener( 'change', handleCollapse ); + + // Remove the listener when the component is unmounted. + return () => { + mediaQuery.removeEventListener( 'change', handleCollapse ); + }; + }, }, } ); diff --git a/packages/block-library/src/post-featured-image/block.json b/packages/block-library/src/post-featured-image/block.json index 34e3bd6b2325fa..4c4ba6919eaff6 100644 --- a/packages/block-library/src/post-featured-image/block.json +++ b/packages/block-library/src/post-featured-image/block.json @@ -51,6 +51,10 @@ }, "customGradient": { "type": "string" + }, + "useFirstImageFromPost": { + "type": "boolean", + "default": false } }, "usesContext": [ "postId", "postType", "queryId" ], diff --git a/packages/block-library/src/post-featured-image/edit.js b/packages/block-library/src/post-featured-image/edit.js index 843f1cf66cdfcd..26f3439964f90e 100644 --- a/packages/block-library/src/post-featured-image/edit.js +++ b/packages/block-library/src/post-featured-image/edit.js @@ -25,6 +25,7 @@ import { store as blockEditorStore, __experimentalUseBorderProps as useBorderProps, } from '@wordpress/block-editor'; +import { useMemo } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { upload } from '@wordpress/icons'; import { store as noticesStore } from '@wordpress/notices'; @@ -64,14 +65,44 @@ export default function PostFeaturedImageEdit( { sizeSlug, rel, linkTarget, + useFirstImageFromPost, } = attributes; - const [ featuredImage, setFeaturedImage ] = useEntityProp( + + const [ storedFeaturedImage, setFeaturedImage ] = useEntityProp( 'postType', postTypeSlug, 'featured_media', postId ); + // Fallback to post content if no featured image is set. + // This is needed for the "Use first image from post" option. + const [ postContent ] = useEntityProp( + 'postType', + postTypeSlug, + 'content', + postId + ); + + const featuredImage = useMemo( () => { + if ( storedFeaturedImage ) { + return storedFeaturedImage; + } + + if ( ! useFirstImageFromPost ) { + return; + } + + const imageOpener = + /).)*)?}\s+)?-->/.exec( + postContent + ); + const imageId = + imageOpener?.groups?.attrs && + JSON.parse( imageOpener.groups.attrs )?.id; + return imageId; + }, [ storedFeaturedImage, useFirstImageFromPost, postContent ] ); + const { media, postType, postPermalink } = useSelect( ( select ) => { const { getMedia, getPostType, getEditedEntityRecord } = diff --git a/packages/block-library/src/post-featured-image/index.php b/packages/block-library/src/post-featured-image/index.php index 4a7aa2f3d8ab94..9a1fd315bb9524 100644 --- a/packages/block-library/src/post-featured-image/index.php +++ b/packages/block-library/src/post-featured-image/index.php @@ -54,9 +54,40 @@ function render_block_core_post_featured_image( $attributes, $content, $block ) } $featured_image = get_the_post_thumbnail( $post_ID, $size_slug, $attr ); + + // Get the first image from the post. + if ( $attributes['useFirstImageFromPost'] && ! $featured_image ) { + $content_post = get_post( $post_ID ); + $content = $content_post->post_content; + $processor = new WP_HTML_Tag_Processor( $content ); + + /* + * Transfer the image tag from the post into a new text snippet. + * Because the HTML API doesn't currently expose a way to extract + * HTML substrings this is necessary as a workaround. Of note, this + * is different than directly extracting the IMG tag: + * - If there are duplicate attributes in the source there will only be one in the output. + * - If there are single-quoted or unquoted attributes they will be double-quoted in the output. + * - If there are named character references in the attribute values they may be replaced with their direct code points. E.g. `…` becomes `…`. + * In the future there will likely be a mechanism to copy snippets of HTML from + * one document into another, via the HTML Processor's `get_outer_html()` or + * equivalent. When that happens it would be appropriate to replace this custom + * code with that canonical code. + */ + if ( $processor->next_tag( 'img' ) ) { + $tag_html = new WP_HTML_Tag_Processor( '' ); + $tag_html->next_tag(); + foreach ( $processor->get_attribute_names_with_prefix( '' ) as $name ) { + $tag_html->set_attribute( $name, $processor->get_attribute( $name ) ); + } + $featured_image = $tag_html->get_updated_html(); + } + } + if ( ! $featured_image ) { return ''; } + if ( $is_link ) { $link_target = $attributes['linkTarget']; $rel = ! empty( $attributes['rel'] ) ? 'rel="' . esc_attr( $attributes['rel'] ) . '"' : ''; diff --git a/packages/block-library/src/search/block.json b/packages/block-library/src/search/block.json index 5669a9089d0e03..15531475adc9ac 100644 --- a/packages/block-library/src/search/block.json +++ b/packages/block-library/src/search/block.json @@ -43,10 +43,6 @@ "type": "object", "default": {} }, - "buttonBehavior": { - "type": "string", - "default": "expand-searchfield" - }, "isSearchFieldHidden": { "type": "boolean", "default": false diff --git a/packages/block-library/src/search/edit.js b/packages/block-library/src/search/edit.js index 2d39494c282392..0123bdfd565698 100644 --- a/packages/block-library/src/search/edit.js +++ b/packages/block-library/src/search/edit.js @@ -59,8 +59,6 @@ import { // button is placed inside wrapper. const DEFAULT_INNER_PADDING = '4px'; -const BUTTON_BEHAVIOR_EXPAND = 'expand-searchfield'; - export default function SearchEdit( { className, attributes, @@ -79,7 +77,6 @@ export default function SearchEdit( { buttonText, buttonPosition, buttonUseIcon, - buttonBehavior, isSearchFieldHidden, style, } = attributes; @@ -187,9 +184,6 @@ export default function SearchEdit( { buttonUseIcon && ! hasNoButton ? 'wp-block-search__icon-button' : undefined, - hasOnlyButton && BUTTON_BEHAVIOR_EXPAND === buttonBehavior - ? 'wp-block-search__button-behavior-expand' - : undefined, hasOnlyButton && isSearchFieldHidden ? 'wp-block-search__searchfield-hidden' : undefined @@ -325,7 +319,7 @@ export default function SearchEdit( { : borderProps.style ), }; const handleButtonClick = () => { - if ( hasOnlyButton && BUTTON_BEHAVIOR_EXPAND === buttonBehavior ) { + if ( hasOnlyButton ) { setAttributes( { isSearchFieldHidden: ! isSearchFieldHidden, } ); diff --git a/packages/block-library/src/search/index.php b/packages/block-library/src/search/index.php index f5391eefc8caca..ae6ddb1c4fb372 100644 --- a/packages/block-library/src/search/index.php +++ b/packages/block-library/src/search/index.php @@ -36,7 +36,6 @@ function render_block_core_search( $attributes, $content, $block ) { $show_button = ( ! empty( $attributes['buttonPosition'] ) && 'no-button' === $attributes['buttonPosition'] ) ? false : true; $button_position = $show_button ? $attributes['buttonPosition'] : null; $query_params = ( ! empty( $attributes['query'] ) ) ? $attributes['query'] : array(); - $button_behavior = ( ! empty( $attributes['buttonBehavior'] ) ) ? $attributes['buttonBehavior'] : 'default'; $button = ''; $query_params_markup = ''; $inline_styles = styles_for_block_core_search( $attributes ); @@ -78,7 +77,7 @@ function render_block_core_search( $attributes, $content, $block ) { $input->set_attribute( 'value', get_search_query() ); $input->set_attribute( 'placeholder', $attributes['placeholder'] ); - $is_expandable_searchfield = 'button-only' === $button_position && 'expand-searchfield' === $button_behavior; + $is_expandable_searchfield = 'button-only' === $button_position; if ( $is_expandable_searchfield ) { $input->set_attribute( 'data-wp-bind--aria-hidden', '!context.isSearchInputVisible' ); $input->set_attribute( 'data-wp-bind--tabindex', 'state.tabindex' ); @@ -154,7 +153,7 @@ function render_block_core_search( $attributes, $content, $block ) { if ( $button->next_tag() ) { $button->add_class( implode( ' ', $button_classes ) ); - if ( 'expand-searchfield' === $attributes['buttonBehavior'] && 'button-only' === $attributes['buttonPosition'] ) { + if ( 'button-only' === $attributes['buttonPosition'] ) { $button->set_attribute( 'data-wp-bind--aria-label', 'state.ariaLabel' ); $button->set_attribute( 'data-wp-bind--aria-controls', 'state.ariaControls' ); $button->set_attribute( 'data-wp-bind--aria-expanded', 'context.isSearchInputVisible' ); @@ -249,10 +248,7 @@ function classnames_for_block_core_search( $attributes ) { } if ( 'button-only' === $attributes['buttonPosition'] ) { - $classnames[] = 'wp-block-search__button-only'; - if ( ! empty( $attributes['buttonBehavior'] ) && 'expand-searchfield' === $attributes['buttonBehavior'] ) { - $classnames[] = 'wp-block-search__button-behavior-expand wp-block-search__searchfield-hidden'; - } + $classnames[] = 'wp-block-search__button-only wp-block-search__searchfield-hidden'; } } diff --git a/packages/block-library/src/search/style.scss b/packages/block-library/src/search/style.scss index b8a446721241b8..4e283530a0e277 100644 --- a/packages/block-library/src/search/style.scss +++ b/packages/block-library/src/search/style.scss @@ -62,35 +62,7 @@ $button-spacing-y: math.div($grid-unit-15, 2); // 6px .wp-block-search__button[aria-expanded="true"] { max-width: calc(100% - 100px); } -} - -// We are lowering the specificity so that the button element can override the rule for the button inside the search block. -:where(.wp-block-search__button-inside .wp-block-search__inside-wrapper) { - padding: $grid-unit-05; - border: 1px solid $gray-600; - box-sizing: border-box; - - .wp-block-search__input { - border-radius: 0; - border: none; - padding: 0 $grid-unit-05; - - &:focus { - outline: none; - } - } - - // For lower specificity. - :where(.wp-block-search__button) { - padding: $grid-unit-05 $grid-unit-10; - } -} - -.wp-block-search.aligncenter .wp-block-search__inside-wrapper { - margin: auto; -} -.wp-block-search__button-behavior-expand { .wp-block-search__inside-wrapper { transition-property: width; min-width: 0 !important; @@ -123,7 +95,33 @@ $button-spacing-y: math.div($grid-unit-15, 2); // 6px } } -.wp-block[data-align="right"] .wp-block-search__button-behavior-expand { +// We are lowering the specificity so that the button element can override the rule for the button inside the search block. +:where(.wp-block-search__button-inside .wp-block-search__inside-wrapper) { + padding: $grid-unit-05; + border: 1px solid $gray-600; + box-sizing: border-box; + + .wp-block-search__input { + border-radius: 0; + border: none; + padding: 0 $grid-unit-05; + + &:focus { + outline: none; + } + } + + // For lower specificity. + :where(.wp-block-search__button) { + padding: $grid-unit-05 $grid-unit-10; + } +} + +.wp-block-search.aligncenter .wp-block-search__inside-wrapper { + margin: auto; +} + +.wp-block[data-align="right"] .wp-block-search.wp-block-search__button-only { .wp-block-search__inside-wrapper { float: right; } diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 09daedac2822f3..8805736c2e4409 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -27,8 +27,10 @@ - `DropdownMenuV2`: do not collapse suffix width ([#57238](https://github.com/WordPress/gutenberg/pull/57238)). - `DateTimePicker`: Adjustment of the dot position on DayButton and expansion of the button area. ([#55502](https://github.com/WordPress/gutenberg/pull/55502)). - `Modal`: Improve application of body class names ([#55430](https://github.com/WordPress/gutenberg/pull/55430)). +- `BaseControl`: Connect to context system ([#57408](https://github.com/WordPress/gutenberg/pull/57408)). - `InputControl`, `NumberControl`, `UnitControl`, `SelectControl`, `TreeSelect`: Add `compact` size variant ([#57398](https://github.com/WordPress/gutenberg/pull/57398)). - `ToggleGroupControl`: Update button size in large variant to be 32px ([#57338](https://github.com/WordPress/gutenberg/pull/57338)). +- `Tooltip`: improve unit tests ([#57345](https://github.com/WordPress/gutenberg/pull/57345)). ### Experimental diff --git a/packages/components/src/base-control/index.tsx b/packages/components/src/base-control/index.tsx index 29251a114e0343..344643f28cfd7c 100644 --- a/packages/components/src/base-control/index.tsx +++ b/packages/components/src/base-control/index.tsx @@ -16,6 +16,7 @@ import { StyledVisualLabel, } from './styles/base-control-styles'; import type { WordPressComponentProps } from '../context'; +import { contextConnectWithoutRef, useContextSystem } from '../context'; export { useBaseControlProps } from './hooks'; @@ -42,19 +43,21 @@ export { useBaseControlProps } from './hooks'; * ); * ``` */ -export const BaseControl = ( { - __nextHasNoMarginBottom = false, - id, - label, - hideLabelFromVision = false, - help, - className, - children, -}: BaseControlProps ) => { +const UnconnectedBaseControl = ( + props: WordPressComponentProps< BaseControlProps, null > +) => { + const { + __nextHasNoMarginBottom = false, + id, + label, + hideLabelFromVision = false, + help, + className, + children, + } = useContextSystem( props, 'BaseControl' ); + return ( - + { label } ) : ( - - { label } - + { label } ) ) } { children } @@ -132,6 +133,10 @@ export const VisualLabel = ( { ); }; -BaseControl.VisualLabel = VisualLabel; + +export const BaseControl = Object.assign( + contextConnectWithoutRef( UnconnectedBaseControl, 'BaseControl' ), + { VisualLabel } +); export default BaseControl; diff --git a/packages/components/src/context/wordpress-component.ts b/packages/components/src/context/wordpress-component.ts index 57e69abdc38093..03c796bbbc3e40 100644 --- a/packages/components/src/context/wordpress-component.ts +++ b/packages/components/src/context/wordpress-component.ts @@ -8,14 +8,19 @@ export type WordPressComponentProps< /** Prop types. */ P, /** The HTML element to inherit props from. */ - T extends React.ElementType, + T extends React.ElementType | null, /** Supports polymorphism through the `as` prop. */ IsPolymorphic extends boolean = true, > = P & - // The `children` prop is being explicitly omitted since it is otherwise implicitly added - // by `ComponentPropsWithRef`. The context is that components should require the `children` - // prop explicitly when needed (see https://github.com/WordPress/gutenberg/pull/31817). - Omit< React.ComponentPropsWithoutRef< T >, 'as' | keyof P | 'children' > & + ( T extends React.ElementType + ? // The `children` prop is being explicitly omitted since it is otherwise implicitly added + // by `ComponentPropsWithRef`. The context is that components should require the `children` + // prop explicitly when needed (see https://github.com/WordPress/gutenberg/pull/31817). + Omit< + React.ComponentPropsWithoutRef< T >, + 'as' | keyof P | 'children' + > + : {} ) & ( IsPolymorphic extends true ? { /** The HTML element or React component to render the component as. */ @@ -24,7 +29,7 @@ export type WordPressComponentProps< : {} ); export type WordPressComponent< - T extends React.ElementType, + T extends React.ElementType | null, O, IsPolymorphic extends boolean, > = { diff --git a/packages/components/src/modal/index.tsx b/packages/components/src/modal/index.tsx index b1bee51805f782..616539ed9b636f 100644 --- a/packages/components/src/modal/index.tsx +++ b/packages/components/src/modal/index.tsx @@ -209,7 +209,7 @@ function UnforwardedModal( if ( shouldCloseOnEsc && - event.code === 'Escape' && + ( event.code === 'Escape' || event.key === 'Escape' ) && ! event.defaultPrevented ) { event.preventDefault(); diff --git a/packages/components/src/tooltip/index.tsx b/packages/components/src/tooltip/index.tsx index 80407def54cd45..817d6d18812ee4 100644 --- a/packages/components/src/tooltip/index.tsx +++ b/packages/components/src/tooltip/index.tsx @@ -66,7 +66,7 @@ function Tooltip( props: TooltipProps ) { const tooltipStore = Ariakit.useTooltipStore( { placement: computedPlacement, - timeout: delay, + showTimeout: delay, } ); return ( diff --git a/packages/components/src/tooltip/test/index.tsx b/packages/components/src/tooltip/test/index.tsx index 4d58498e278d36..cbe144cfa53d4d 100644 --- a/packages/components/src/tooltip/test/index.tsx +++ b/packages/components/src/tooltip/test/index.tsx @@ -2,7 +2,7 @@ * External dependencies */ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { press, hover, click, sleep } from '@ariakit/test'; /** * WordPress dependencies @@ -15,321 +15,425 @@ import { shortcutAriaLabel } from '@wordpress/keycodes'; import Button from '../../button'; import Modal from '../../modal'; import Tooltip, { TOOLTIP_DELAY } from '..'; -import cleanupTooltip from './utils/'; const props = { - children: , + children: , text: 'tooltip text', }; -describe( 'Tooltip', () => { - it( 'should not render the tooltip if multiple children are passed', async () => { - render( - // expected TS error since Tooltip cannot have more than one child element - // @ts-expect-error - - - - - ); - - expect( - screen.queryByRole( 'tooltip', { name: /tooltip text/i } ) - ).not.toBeInTheDocument(); - } ); +const expectTooltipToBeVisible = () => + expect( + screen.getByRole( 'tooltip', { name: 'tooltip text' } ) + ).toBeVisible(); - it( 'should not render the tooltip if there is no focus', () => { - render( ); +const expectTooltipToBeHidden = () => + expect( + screen.queryByRole( 'tooltip', { name: 'tooltip text' } ) + ).not.toBeInTheDocument(); - expect( - screen.getByRole( 'button', { name: /Button/i } ) - ).toBeVisible(); +const waitExpectTooltipToShow = async ( timeout = TOOLTIP_DELAY ) => + await waitFor( expectTooltipToBeVisible, { timeout } ); - expect( - screen.queryByRole( 'tooltip', { name: /tooltip text/i } ) - ).not.toBeInTheDocument(); - } ); +const waitExpectTooltipToHide = async () => + await waitFor( expectTooltipToBeHidden ); - it( 'should render the tooltip when focusing on the tooltip anchor via tab', async () => { - const user = userEvent.setup(); +const hoverOutside = async () => { + await hover( document.body ); + await hover( document.body, { clientX: 10, clientY: 10 } ); +}; - render( ); +describe( 'Tooltip', () => { + // Wait enough time to make sure that tooltips don't show immediately, ignoring + // the showTimeout delay. For more context, see: + // - https://github.com/WordPress/gutenberg/pull/57345#discussion_r1435167187 + // - https://ariakit.org/reference/tooltip-provider#skiptimeout + afterEach( async () => { + await sleep( 300 ); + } ); - await user.tab(); + describe( 'basic behavior', () => { + it( 'should not render the tooltip if multiple children are passed', async () => { + render( + // @ts-expect-error Tooltip cannot have more than one child element + + + + + ); - expect( - screen.getByRole( 'button', { name: /Button/i } ) - ).toHaveFocus(); + expect( + screen.getByRole( 'button', { name: 'First button' } ) + ).toBeVisible(); + expect( + screen.getByRole( 'button', { name: 'Second button' } ) + ).toBeVisible(); - expect( - await screen.findByRole( 'tooltip', { name: /tooltip text/i } ) - ).toBeVisible(); + await press.Tab(); - await cleanupTooltip( user ); - } ); + expectTooltipToBeHidden(); + } ); - it( 'should render the tooltip when the tooltip anchor is hovered', async () => { - const user = userEvent.setup(); + it( 'should associate the tooltip text with its anchor via the accessible description when visible', async () => { + render( ); - render( ); + // The anchor can not be found by querying for its description, + // since that is present only when the tooltip is visible + expect( + screen.queryByRole( 'button', { description: 'tooltip text' } ) + ).not.toBeInTheDocument(); + + // Hover the anchor. The tooltip shows and its text is used to describe + // the tooltip anchor + await hover( + screen.getByRole( 'button', { + name: 'Tooltip anchor', + } ) + ); + expect( + await screen.findByRole( 'button', { + description: 'tooltip text', + } ) + ).toBeInTheDocument(); + + // Hover outside of the anchor, tooltip should hide + await hoverOutside(); + await waitExpectTooltipToHide(); + expect( + screen.queryByRole( 'button', { description: 'tooltip text' } ) + ).not.toBeInTheDocument(); + } ); + } ); - await user.hover( screen.getByRole( 'button', { name: /Button/i } ) ); + describe( 'keyboard focus', () => { + it( 'should not render the tooltip if there is no focus', () => { + render( ); - expect( - await screen.findByRole( 'tooltip', { name: /tooltip text/i } ) - ).toBeVisible(); + expect( + screen.getByRole( 'button', { name: 'Tooltip anchor' } ) + ).toBeVisible(); + + expectTooltipToBeHidden(); + } ); + + it( 'should show the tooltip when focusing on the tooltip anchor and hide it the anchor loses focus', async () => { + render( + <> + + + + ); + + // Focus the anchor, tooltip should show + await press.Tab(); + expect( + screen.getByRole( 'button', { name: 'Tooltip anchor' } ) + ).toHaveFocus(); + await waitExpectTooltipToShow(); - await cleanupTooltip( user ); + // Focus the other button, tooltip should hide + await press.Tab(); + expect( + screen.getByRole( 'button', { name: 'Focus me' } ) + ).toHaveFocus(); + await waitExpectTooltipToHide(); + } ); + + it( 'should show tooltip when focussing a disabled (but focussable) anchor button', async () => { + render( + <> + + + + + + ); + + const anchor = screen.getByRole( 'button', { + name: 'Tooltip anchor', + } ); + + expect( anchor ).toBeVisible(); + expect( anchor ).toHaveAttribute( 'aria-disabled', 'true' ); + + // Focus anchor, tooltip should show + await press.Tab(); + expect( anchor ).toHaveFocus(); + await waitExpectTooltipToShow(); + + // Focus another button, tooltip should hide + await press.Tab(); + expect( + screen.getByRole( 'button', { + name: 'Focus me', + } ) + ).toHaveFocus(); + await waitExpectTooltipToHide(); + } ); } ); - it( 'should not show tooltip on focus as result of mouse click', async () => { - const user = userEvent.setup(); + describe( 'mouse hover', () => { + it( 'should show the tooltip when the tooltip anchor is hovered and hide it when the cursor stops hovering the anchor', async () => { + render( ); + + const anchor = screen.getByRole( 'button', { + name: 'Tooltip anchor', + } ); + + expect( anchor ).toBeVisible(); + + // Hover over the anchor, tooltip should show + await hover( anchor ); + await waitExpectTooltipToShow(); + + // Hover outside of the anchor, tooltip should hide + await hoverOutside(); + await waitExpectTooltipToHide(); + } ); + + it( 'should show tooltip when hovering over a disabled (but focussable) anchor button', async () => { + render( + <> + + + + + + ); + + const anchor = screen.getByRole( 'button', { + name: 'Tooltip anchor', + } ); + + expect( anchor ).toBeVisible(); + expect( anchor ).toHaveAttribute( 'aria-disabled', 'true' ); + + // Hover over the anchor, tooltip should show + await hover( anchor ); + await waitExpectTooltipToShow(); + + // Hover outside of the anchor, tooltip should hide + await hoverOutside(); + await waitExpectTooltipToHide(); + } ); + } ); - render( ); + describe( 'mouse click', () => { + it( 'should hide tooltip when the tooltip anchor is clicked', async () => { + render( ); - await user.click( screen.getByRole( 'button', { name: /Button/i } ) ); + const anchor = screen.getByRole( 'button', { + name: 'Tooltip anchor', + } ); - expect( - screen.queryByRole( 'tooltip', { name: /tooltip text/i } ) - ).not.toBeInTheDocument(); + expect( anchor ).toBeVisible(); - await cleanupTooltip( user ); - } ); + // Hover over the anchor, tooltip should show + await hover( anchor ); + await waitExpectTooltipToShow(); - it( 'should respect custom delay prop when showing tooltip', async () => { - const user = userEvent.setup(); - const ADDITIONAL_DELAY = 100; + // Click the anchor, tooltip should hide + await click( anchor ); + await waitExpectTooltipToHide(); + } ); - render( - - ); + it( 'should not hide tooltip when the tooltip anchor is clicked and the `hideOnClick` prop is `false', async () => { + render( + <> + + + + ); - await user.hover( screen.getByRole( 'button', { name: /Button/i } ) ); + const anchor = screen.getByRole( 'button', { + name: 'Tooltip anchor', + } ); - // Advance time by default delay - await new Promise( ( resolve ) => - setTimeout( resolve, TOOLTIP_DELAY ) - ); + expect( anchor ).toBeVisible(); - // Tooltip hasn't appeared yet - expect( - screen.queryByRole( 'tooltip', { name: /tooltip text/i } ) - ).not.toBeInTheDocument(); + // Hover over the anchor, tooltip should show + await hover( anchor ); + await waitExpectTooltipToShow(); - // wait for additional delay for tooltip to appear - await waitFor( - () => - new Promise( ( resolve ) => - setTimeout( resolve, ADDITIONAL_DELAY ) - ) - ); + // Click the anchor, tooltip should not hide + await click( anchor ); + await waitExpectTooltipToShow(); - expect( - screen.getByRole( 'tooltip', { name: /tooltip text/i } ) - ).toBeVisible(); - - await cleanupTooltip( user ); + // Click another button, tooltip should hide + await click( screen.getByRole( 'button', { name: 'Click me' } ) ); + await waitExpectTooltipToHide(); + } ); } ); - it( 'should show tooltip when an element is disabled', async () => { - const user = userEvent.setup(); + describe( 'delay', () => { + it( 'should respect custom delay prop when showing tooltip', async () => { + const ADDITIONAL_DELAY = 100; - render( - - - - ); + render( + + ); - const button = screen.getByRole( 'button', { name: /Button/i } ); + const anchor = screen.getByRole( 'button', { + name: 'Tooltip anchor', + } ); + expect( anchor ).toBeVisible(); - expect( button ).toBeVisible(); - expect( button ).toHaveAttribute( 'aria-disabled' ); + // Hover over the anchor + await hover( anchor ); + expectTooltipToBeHidden(); - await user.hover( button ); + // Advance time by default delay + await sleep( TOOLTIP_DELAY ); - expect( - await screen.findByRole( 'tooltip', { name: /tooltip text/i } ) - ).toBeVisible(); + // Tooltip hasn't appeared yet + expectTooltipToBeHidden(); - await cleanupTooltip( user ); - } ); + // Wait for additional delay for tooltip to appear + await sleep( ADDITIONAL_DELAY ); + await waitExpectTooltipToShow(); - it( 'should not show tooltip if the mouse leaves the tooltip anchor before set delay', async () => { - const user = userEvent.setup(); - const onMouseEnterMock = jest.fn(); - const onMouseLeaveMock = jest.fn(); - const MOUSE_LEAVE_DELAY = TOOLTIP_DELAY - 200; + // Hover outside of the anchor, tooltip should hide + await hoverOutside(); + await waitExpectTooltipToHide(); + } ); - render( - <> + it( 'should not show tooltip if the mouse leaves the tooltip anchor before set delay', async () => { + const onMouseEnterMock = jest.fn(); + const onMouseLeaveMock = jest.fn(); + const HOVER_OUTSIDE_ANTICIPATION = 200; + + render( - - - ); - - await user.hover( - screen.getByRole( 'button', { - name: 'Button 1', - } ) - ); - - // Tooltip hasn't appeared yet - expect( - screen.queryByRole( 'tooltip', { name: /tooltip text/i } ) - ).not.toBeInTheDocument(); - expect( onMouseEnterMock ).toHaveBeenCalledTimes( 1 ); - - // Advance time by MOUSE_LEAVE_DELAY time - await new Promise( ( resolve ) => - setTimeout( resolve, MOUSE_LEAVE_DELAY ) - ); - - expect( - screen.queryByRole( 'tooltip', { name: /tooltip text/i } ) - ).not.toBeInTheDocument(); - - // Hover the other button, meaning that the mouse will leave the tooltip anchor - await user.hover( - screen.getByRole( 'button', { - name: 'Button 2', - } ) - ); - - // Tooltip still hasn't appeared yet - expect( - screen.queryByRole( 'tooltip', { name: /tooltip text/i } ) - ).not.toBeInTheDocument(); - expect( onMouseEnterMock ).toHaveBeenCalledTimes( 1 ); - expect( onMouseLeaveMock ).toHaveBeenCalledTimes( 1 ); - - // Advance time again, so that we reach the full TOOLTIP_DELAY time - await new Promise( ( resolve ) => - setTimeout( resolve, TOOLTIP_DELAY ) - ); - - // Tooltip won't show, since the mouse has left the tooltip anchor - expect( - screen.queryByRole( 'tooltip', { name: /tooltip text/i } ) - ).not.toBeInTheDocument(); - - await cleanupTooltip( user ); - } ); - - it( 'should render the shortcut display text when a string is passed as the shortcut', async () => { - const user = userEvent.setup(); + ); - render( ); + const anchor = screen.getByRole( 'button', { + name: 'Tooltip anchor', + } ); + expect( anchor ).toBeVisible(); - await user.hover( screen.getByRole( 'button', { name: /Button/i } ) ); - - await waitFor( () => - expect( screen.getByText( 'shortcut text' ) ).toBeVisible() - ); - - await cleanupTooltip( user ); - } ); + // Hover over the anchor, tooltip hasn't appeared yet + await hover( anchor ); + expect( onMouseEnterMock ).toHaveBeenCalledTimes( 1 ); + expectTooltipToBeHidden(); - it( 'should render the keyboard shortcut display text and aria-label when an object is passed as the shortcut', async () => { - const user = userEvent.setup(); + // Advance time, tooltip hasn't appeared yet because TOOLTIP_DELAY time + // hasn't passed yet + await sleep( TOOLTIP_DELAY - HOVER_OUTSIDE_ANTICIPATION ); + expectTooltipToBeHidden(); - render( - - ); + // Hover outside of the anchor, tooltip still hasn't appeared yet + await hoverOutside(); + expectTooltipToBeHidden(); - await user.hover( screen.getByRole( 'button', { name: /Button/i } ) ); + expect( onMouseEnterMock ).toHaveBeenCalledTimes( 1 ); + expect( onMouseLeaveMock ).toHaveBeenCalledTimes( 1 ); - await waitFor( () => - expect( screen.getByText( '⇧⌘,' ) ).toBeVisible() - ); + // Advance time again, so that we reach the full TOOLTIP_DELAY time + await sleep( HOVER_OUTSIDE_ANTICIPATION ); - expect( screen.getByText( '⇧⌘,' ) ).toHaveAttribute( - 'aria-label', - 'Control + Shift + Comma' - ); - - await cleanupTooltip( user ); + // Tooltip won't show, since the mouse has left the tooltip anchor + expectTooltipToBeHidden(); + } ); } ); - it( 'esc should close modal even when tooltip is visible', async () => { - const user = userEvent.setup(); - const onRequestClose = jest.fn(); - render( - -

Modal content

-
- ); - - expect( - screen.queryByRole( 'tooltip', { name: /close/i } ) - ).not.toBeInTheDocument(); - - await user.hover( - screen.getByRole( 'button', { - name: /Close/i, - } ) - ); - - await waitFor( () => + describe( 'shortcut', () => { + it( 'should show the shortcut in the tooltip when a string is passed as the shortcut', async () => { + render( ); + + // Hover over the anchor, tooltip should show + await hover( + screen.getByRole( 'button', { name: 'Tooltip anchor' } ) + ); + await waitFor( () => + expect( + screen.getByRole( 'tooltip', { + name: 'tooltip text shortcut text', + } ) + ).toBeVisible() + ); + + // Hover outside of the anchor, tooltip should hide + await hoverOutside(); + await waitExpectTooltipToHide(); + } ); + + it( 'should show the shortcut in the tooltip when an object is passed as the shortcut', async () => { + render( + + ); + + // Hover over the anchor, tooltip should show + await hover( + screen.getByRole( 'button', { name: 'Tooltip anchor' } ) + ); + await waitFor( () => + expect( + screen.getByRole( 'tooltip', { + name: 'tooltip text Control + Shift + Comma', + } ) + ).toBeVisible() + ); expect( - screen.getByRole( 'tooltip', { name: /close/i } ) - ).toBeVisible() - ); - - await user.keyboard( '[Escape]' ); - - expect( onRequestClose ).toHaveBeenCalled(); - - await cleanupTooltip( user ); - } ); - - it( 'should associate the tooltip text with its anchor via the accessible description when visible', async () => { - const user = userEvent.setup(); - - render( ); - - await user.hover( - screen.getByRole( 'button', { - name: /Button/i, - } ) - ); - - expect( - await screen.findByRole( 'button', { description: 'tooltip text' } ) - ).toBeInTheDocument(); + screen.getByRole( 'tooltip', { + name: 'tooltip text Control + Shift + Comma', + } ) + ).toHaveTextContent( /⇧⌘,/i ); + + // Hover outside of the anchor, tooltip should hide + await hoverOutside(); + await waitExpectTooltipToHide(); + } ); } ); - it( 'should not hide tooltip when the anchor is clicked if hideOnClick is false', async () => { - const user = userEvent.setup(); - - render( ); - - const button = screen.getByRole( 'button', { name: /Button/i } ); - - await user.hover( button ); - - expect( - await screen.findByRole( 'tooltip', { name: /tooltip text/i } ) - ).toBeVisible(); - - await user.click( button ); - - expect( - screen.getByRole( 'tooltip', { name: /tooltip text/i } ) - ).toBeVisible(); - - await cleanupTooltip( user ); + describe( 'event propagation', () => { + it( 'should close the parent dialog component when pressing the Escape key while the tooltip is visible', async () => { + const onRequestClose = jest.fn(); + render( + +

Modal content

+
+ ); + + expectTooltipToBeHidden(); + + const closeButton = screen.getByRole( 'button', { + name: /close/i, + } ); + + // Hover over the anchor, tooltip should show + await hover( closeButton ); + await waitFor( () => + expect( + screen.getByRole( 'tooltip', { name: /close/i } ) + ).toBeVisible() + ); + + // Press the Escape key, Modal should request to be closed + await press.Escape(); + expect( onRequestClose ).toHaveBeenCalled(); + + // Hover outside of the anchor, tooltip should hide + await hoverOutside(); + await waitExpectTooltipToHide(); + } ); } ); } ); diff --git a/packages/dataviews/src/add-filter.js b/packages/dataviews/src/add-filter.js index 0ebaa373c3ef9c..4f77a35c52d2ea 100644 --- a/packages/dataviews/src/add-filter.js +++ b/packages/dataviews/src/add-filter.js @@ -23,6 +23,7 @@ const { DropdownMenuRadioItemV2: DropdownMenuRadioItem, DropdownMenuSeparatorV2: DropdownMenuSeparator, DropdownMenuItemLabelV2: DropdownMenuItemLabel, + DropdownMenuItemHelpTextV2: DropdownMenuItemHelpText, } = unlock( componentsPrivateApis ); function WithSeparators( { children } ) { @@ -143,6 +144,13 @@ export default function AddFilter( { filters, view, onChangeView } ) { { element.label } + { !! element.description && ( + + { + element.description + } + + ) } ); } ) } diff --git a/packages/dataviews/src/filter-summary.js b/packages/dataviews/src/filter-summary.js index f09c15c8eb1374..723fee5b3ee52e 100644 --- a/packages/dataviews/src/filter-summary.js +++ b/packages/dataviews/src/filter-summary.js @@ -23,6 +23,7 @@ const { DropdownMenuItemV2: DropdownMenuItem, DropdownMenuSeparatorV2: DropdownMenuSeparator, DropdownMenuItemLabelV2: DropdownMenuItemLabel, + DropdownMenuItemHelpTextV2: DropdownMenuItemHelpText, } = unlock( componentsPrivateApis ); const FilterText = ( { activeElement, filterInView, filter } ) => { @@ -126,6 +127,11 @@ export default function FilterSummary( { filter, view, onChangeView } ) { { element.label } + { !! element.description && ( + + { element.description } + + ) } ); } ) } diff --git a/packages/dataviews/src/filters.js b/packages/dataviews/src/filters.js index bcc9df9ae327ab..a101a077539900 100644 --- a/packages/dataviews/src/filters.js +++ b/packages/dataviews/src/filters.js @@ -40,10 +40,13 @@ const Filters = memo( function Filters( { fields, view, onChangeView } ) { switch ( field.type ) { case ENUMERATION_TYPE: + if ( ! field.elements?.length ) { + return; + } filters.push( { field: field.id, name: field.header, - elements: field.elements || [], + elements: field.elements, operators, isVisible: view.filters.some( ( f ) => diff --git a/packages/dataviews/src/view-grid.js b/packages/dataviews/src/view-grid.js index e43c9744c560f8..5ac4d1d42d5739 100644 --- a/packages/dataviews/src/view-grid.js +++ b/packages/dataviews/src/view-grid.js @@ -87,7 +87,7 @@ export default function ViewGrid( { { field.header }
- { field.render( { item } ) } + { renderedValue }
); diff --git a/packages/dataviews/src/view-table.js b/packages/dataviews/src/view-table.js index 0a7e29cd1068c1..5bfcba5def4aa3 100644 --- a/packages/dataviews/src/view-table.js +++ b/packages/dataviews/src/view-table.js @@ -26,6 +26,7 @@ const { DropdownMenuRadioItemV2: DropdownMenuRadioItem, DropdownMenuSeparatorV2: DropdownMenuSeparator, DropdownMenuItemLabelV2: DropdownMenuItemLabel, + DropdownMenuItemHelpTextV2: DropdownMenuItemHelpText, } = unlock( componentsPrivateApis ); const sortArrows = { asc: '↑', desc: '↓' }; @@ -200,6 +201,11 @@ function HeaderMenu( { field, view, onChangeView } ) { { element.label } + { !! element.description && ( + + { element.description } + + ) } ); } ) } diff --git a/packages/dependency-extraction-webpack-plugin/CHANGELOG.md b/packages/dependency-extraction-webpack-plugin/CHANGELOG.md index 78de3e4142c87e..414a5229117cee 100644 --- a/packages/dependency-extraction-webpack-plugin/CHANGELOG.md +++ b/packages/dependency-extraction-webpack-plugin/CHANGELOG.md @@ -4,8 +4,12 @@ ### Breaking Changes -- Drop support for webpack 4. -- Drop support for Node.js versions < 18. +- Drop support for webpack 4. +- Drop support for Node.js versions < 18. + +### New Features + +- Add support for producing module-compatible asset files ([#57199](https://github.com/WordPress/gutenberg/pull/57199)). ## 4.31.0 (2023-12-13) @@ -145,6 +149,6 @@ ## 1.0.0 (2019-05-21) -### New Feature +### New Features - Introduce the `@wordpress/dependency-extraction-webpack-plugin` package. diff --git a/packages/dependency-extraction-webpack-plugin/README.md b/packages/dependency-extraction-webpack-plugin/README.md index eac1e8e27ffc5c..91fb36e8ad09d3 100644 --- a/packages/dependency-extraction-webpack-plugin/README.md +++ b/packages/dependency-extraction-webpack-plugin/README.md @@ -2,11 +2,15 @@ This webpack plugin serves two purposes: -- Externalize dependencies that are available as script dependencies on modern WordPress sites. -- Add an asset file for each entry point that declares an object with the list of WordPress script dependencies for the entry point. The asset file also contains the current version calculated for the current source code. +- Externalize dependencies that are available as shared scripts or modules on WordPress sites. +- Add an asset file for each entry point that declares an object with the list of WordPress script or module dependencies for the entry point. The asset file also contains the current version calculated for the current source code. This allows JavaScript bundles produced by webpack to leverage WordPress style dependency sharing without an error-prone process of manually maintaining a dependency list. +Version 5 of this plugin adds support for module bundling. [Webpack's `output.module` option](https://webpack.js.org/configuration/output/#outputmodule) should +be used to opt-in to this behavior. This plugin will adapt it's behavior based on the +`output.module` option, producing an asset file suitable for use with the WordPress Module API. + Consult the [webpack website](https://webpack.js.org) for additional information on webpack concepts. ## Installation @@ -17,7 +21,7 @@ Install the module npm install @wordpress/dependency-extraction-webpack-plugin --save-dev ``` -**Note**: This package requires Node.js 14.0.0 or later. It also requires webpack 4.8.3 and newer. It is not compatible with older versions. +**Note**: This package requires Node.js 18.0.0 or later. It also requires webpack 5.0.0 or newer. It is not compatible with older versions. ## Usage @@ -39,7 +43,7 @@ module.exports = { ```js const defaultConfig = require( '@wordpress/scripts/config/webpack.config' ); -const config = { +const webpackConfig = { ...defaultConfig, plugins: [ ...defaultConfig.plugins.filter( @@ -56,7 +60,9 @@ const config = { }; ``` -Each entry point in the webpack bundle will include an asset file that declares the WordPress script dependencies that should be enqueued. Such file also contains the unique version hash calculated based on the file content. +### Behavior with scripts + +Each entry point in the webpack bundle will include an asset file that declares the WordPress script dependencies that should be enqueued. This file also contains the unique version hash calculated based on the file content. For example: @@ -88,6 +94,68 @@ By default, the following module requests are handled: This plugin is compatible with `externals`, but they may conflict. For example, adding `{ externals: { '@wordpress/blob': 'wp.blob' } }` to webpack configuration will effectively hide the `@wordpress/blob` module from the plugin and it will not be included in dependency lists. +### Behavior with modules + +**Warning:** Modules support is considered experimental at this time. + +This section describes the behavior of this package to bundle ECMAScript modules and generate asset +files suitable for use with the WordPress Modules API. + +Some of this plugin's options change, and webpack requires configuration to output modules. Refer to +[webpack's documentation](https://webpack.js.org/configuration/output/#outputmodule) for up-to-date details. + +```js +const webpackConfig = { + ...defaultConfig, + + // These lines are necessary to enable module compilation at time-of-writing: + output: { module: true }, + experiments: { outputModule: true }, + + plugins: [ + ...defaultConfig.plugins.filter( + ( plugin ) => + plugin.constructor.name !== 'DependencyExtractionWebpackPlugin' + ), + new DependencyExtractionWebpackPlugin( { + // With modules, we use `requestToExternalModule`: + requestToExternalModule( request ) { + if ( request === 'my-registered-module' ) { + return request; + } + }, + } ), + ], +}; +``` + +Each entry point in the webpack bundle will include an asset file that declares the WordPress module dependencies that should be enqueued. This file also contains the unique version hash calculated based on the file content. + +For example: + +``` +// Source file entrypoint.js +import { store, getContext } from '@wordpress/interactivity'; + +// Webpack will produce the output output/entrypoint.js +/* bundled JavaScript output */ + +// Webpack will also produce output/entrypoint.asset.php declaring script dependencies + array('@wordpress/interactivity'), 'version' => 'dd4c2dc50d046ed9d4c063a7ca95702f'); +``` + +By default, the following module requests are handled: + +| Request | +| ---------------------------- | +| `@wordpress/interactivity ` | + +(`@wordpress/interactivity` is currently the only available WordPress module.) + +**Note:** This plugin overlaps with the functionality provided by [webpack `externals`](https://webpack.js.org/configuration/externals). This plugin is intended to extract module handles from bundle compilation so that a list of module dependencies does not need to be manually maintained. If you don't need to extract a list of module dependencies, use the `externals` option directly. + +This plugin is compatible with `externals`, but they may conflict. For example, adding `{ externals: { '@wordpress/blob': 'wp.blob' } }` to webpack configuration will effectively hide the `@wordpress/blob` module from the plugin and it will not be included in dependency lists. + #### Options An object can be passed to the constructor to customize the behavior, for example: @@ -142,6 +210,8 @@ Pass `useDefaults: false` to disable the default request handling. Force `wp-polyfill` to be included in each entry point's dependency list. This would be the same as adding `import '@wordpress/polyfill';` to each entry point. +**Note**: This option is not available with modules. + ##### `externalizedReport` - Type: boolean | string @@ -152,6 +222,8 @@ You can provide a filename, or set it to `true` to report to a default `external ##### `requestToExternal` +**Note**: This option is not available with modules. See [`requestToExternalModule`](#requestToExternalModule) for module usage. + - Type: function `requestToExternal` allows the module handling to be customized. The function should accept a module request string and may return a string representing the global variable to use. An array of strings may be used to access globals via an object path, e.g. `wp.i18n` may be represented as `[ 'wp', 'i18n' ]`. @@ -179,8 +251,43 @@ module.exports = { }; ``` +##### `requestToExternalModule` + +**Note**: This option is only available with modules. See [`requestToExternal`](#requestToExternal) for script usage. + +- Type: function + +`requestToExternalModule` allows the module handling to be customized. The function should accept a module request string and may return a string representing the module to use. Often, the module will have the same name. + +`requestToExternalModule` provided via configuration has precedence over default external handling. Unhandled requests will be handled by the default unless `useDefaults` is set to `false`. + +```js +/** + * Externalize 'my-module' + * + * @param {string} request Requested module + * + * @return {(string|undefined)} Script global + */ +function requestToExternalModule( request ) { + // Handle imports like `import myModule from 'my-module'` + if ( request === 'my-module' ) { + // Import should be ov the form `import { something } from "myModule";` in the final bundle. + return 'myModule'; + } +} + +module.exports = { + plugins: [ + new DependencyExtractionWebpackPlugin( { requestToExternalModule } ), + ], +}; +``` + ##### `requestToHandle` +**Note**: This option is not available with modules. It has no corresponding module configuration. + - Type: function All of the external modules handled by the plugin are expected to be WordPress script dependencies @@ -233,6 +340,19 @@ $script_url = plugins_url( $script_path, __FILE__ ); wp_enqueue_script( 'script', $script_url, $script_asset['dependencies'], $script_asset['version'] ); ``` +Or with modules (the Module API is not yet stable): + +```php +$module_path = 'path/to/module.js'; +$module_asset_path = 'path/to/module.asset.php'; +$module_asset = file_exists( $module_asset_path ) + ? require( $module_asset_path ) + : array( 'dependencies' => array(), 'version' => filemtime( $module_path ) ); +$module_url = plugins_url( $module_path, __FILE__ ); +wp_register_module( 'my-module', $module_url, $module_asset['dependencies'], $module_asset['version'] ); +wp_enqueue_module( 'my-module' ); +``` + ## Contributing to this package This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. diff --git a/packages/dependency-extraction-webpack-plugin/lib/.eslintrc.json b/packages/dependency-extraction-webpack-plugin/lib/.eslintrc.json index 305ba4347aedd3..5e1457134b16a7 100644 --- a/packages/dependency-extraction-webpack-plugin/lib/.eslintrc.json +++ b/packages/dependency-extraction-webpack-plugin/lib/.eslintrc.json @@ -1,6 +1,4 @@ { - // Use the default eslint parser. Prevent babel transforms. - "parser": "espree", "env": { "node": true } diff --git a/packages/dependency-extraction-webpack-plugin/lib/index.js b/packages/dependency-extraction-webpack-plugin/lib/index.js index 98b484672ef3f9..400ea39c022931 100644 --- a/packages/dependency-extraction-webpack-plugin/lib/index.js +++ b/packages/dependency-extraction-webpack-plugin/lib/index.js @@ -11,10 +11,13 @@ const { createHash } = webpack.util; */ const { defaultRequestToExternal, + defaultRequestToExternalModule, defaultRequestToHandle, } = require( './util' ); const { RawSource } = webpack.sources; +const { AsyncDependenciesBlock } = webpack; + const defaultExternalizedReportFileName = 'externalized-dependencies.json'; class DependencyExtractionWebpackPlugin { @@ -32,27 +35,40 @@ class DependencyExtractionWebpackPlugin { options ); - /* + /** * Track requests that are externalized. * * Because we don't have a closed set of dependencies, we need to track what has * been externalized so we can recognize them in a later phase when the dependency * lists are generated. + * + * @type {Set} */ this.externalizedDeps = new Set(); - // Offload externalization work to the ExternalsPlugin. - this.externalsPlugin = new webpack.ExternalsPlugin( - 'window', - this.externalizeWpDeps.bind( this ) - ); + /** + * Should we use modules. This will be set later to match webpack's + * output.module option. + * + * @type {boolean} + */ + this.useModules = false; } + /** + * @param {webpack.ExternalItemFunctionData} data + * @param { ( err?: null | Error, result?: string | string[] ) => void } callback + */ externalizeWpDeps( { request }, callback ) { let externalRequest; - // Handle via options.requestToExternal first. - if ( typeof this.options.requestToExternal === 'function' ) { + // Handle via options.requestToExternal(Module) first. + if ( this.useModules ) { + if ( typeof this.options.requestToExternalModule === 'function' ) { + externalRequest = + this.options.requestToExternalModule( request ); + } + } else if ( typeof this.options.requestToExternal === 'function' ) { externalRequest = this.options.requestToExternal( request ); } @@ -61,7 +77,9 @@ class DependencyExtractionWebpackPlugin { typeof externalRequest === 'undefined' && this.options.useDefaults ) { - externalRequest = defaultRequestToExternal( request ); + externalRequest = this.useModules + ? defaultRequestToExternalModule( request ) + : defaultRequestToExternal( request ); } if ( externalRequest ) { @@ -73,6 +91,10 @@ class DependencyExtractionWebpackPlugin { return callback(); } + /** + * @param {string} request + * @return {string} Mapped dependency name + */ mapRequestToDependency( request ) { // Handle via options.requestToHandle first. if ( typeof this.options.requestToHandle === 'function' ) { @@ -94,6 +116,10 @@ class DependencyExtractionWebpackPlugin { return request; } + /** + * @param {any} asset Asset Data + * @return {string} Stringified asset data suitable for output + */ stringify( asset ) { if ( this.options.outputFormat === 'php' ) { return ` /\.js$/i.test( f ) ); + const jsExtensionRegExp = this.useModules ? /\.m?js$/i : /\.js$/i; + + const chunkJSFile = chunkFiles.find( ( f ) => + jsExtensionRegExp.test( f ) + ); if ( ! chunkJSFile ) { // There's no JS file in this chunk, no work for us. Typically a `style.css` from cache group. continue; } - const chunkDeps = new Set(); + /** @type {Set} */ + const chunkStaticDeps = new Set(); + /** @type {Set} */ + const chunkDynamicDeps = new Set(); + if ( injectPolyfill ) { - chunkDeps.add( 'wp-polyfill' ); + chunkStaticDeps.add( 'wp-polyfill' ); } - const processModule = ( { userRequest } ) => { + /** + * @param {webpack.Module} m + */ + const processModule = ( m ) => { + const { userRequest } = m; if ( this.externalizedDeps.has( userRequest ) ) { - chunkDeps.add( this.mapRequestToDependency( userRequest ) ); + if ( this.useModules ) { + const isStatic = + DependencyExtractionWebpackPlugin.hasStaticDependencyPathToRoot( + compilation, + m + ); + + ( isStatic ? chunkStaticDeps : chunkDynamicDeps ).add( + m.request + ); + } else { + chunkStaticDeps.add( + this.mapRequestToDependency( userRequest ) + ); + } } }; // Search for externalized modules in all chunks. - const modulesIterable = - compilation.chunkGraph.getChunkModules( chunk ); - for ( const chunkModule of modulesIterable ) { + for ( const chunkModule of compilation.chunkGraph.getChunkModulesIterable( + chunk + ) ) { processModule( chunkModule ); // Loop through submodules of ConcatenatedModule. if ( chunkModule.modules ) { @@ -209,11 +274,20 @@ class DependencyExtractionWebpackPlugin { .slice( 0, hashDigestLength ); const assetData = { - // Get a sorted array so we can produce a stable, stringified representation. - dependencies: Array.from( chunkDeps ).sort(), + dependencies: [ + // Sort these so we can produce a stable, stringified representation. + ...Array.from( chunkStaticDeps ).sort(), + ...Array.from( chunkDynamicDeps ) + .sort() + .map( ( id ) => ( { id, type: 'dynamic' } ) ), + ], version: contentHash, }; + if ( this.useModules ) { + assetData.type = 'module'; + } + if ( combineAssets ) { combinedAssetsData[ chunkJSFile ] = assetData; continue; @@ -231,7 +305,7 @@ class DependencyExtractionWebpackPlugin { '.asset.' + ( outputFormat === 'php' ? 'php' : 'json' ); assetFilename = compilation .getPath( '[file]', { filename: chunkJSFile } ) - .replace( /\.js$/i, suffix ); + .replace( /\.m?js$/i, suffix ); } // Add source and file into compilation for webpack to output. @@ -260,6 +334,58 @@ class DependencyExtractionWebpackPlugin { ); } } + + /** + * Can we trace a line of static dependencies from an entry to a module + * + * @param {webpack.Compilation} compilation + * @param {webpack.DependenciesBlock} block + * + * @return {boolean} True if there is a static import path to the root + */ + static hasStaticDependencyPathToRoot( compilation, block ) { + const incomingConnections = [ + ...compilation.moduleGraph.getIncomingConnections( block ), + ].filter( + ( connection ) => + // Library connections don't have a dependency, this is a root + connection.dependency && + // Entry dependencies are another root + connection.dependency.constructor.name !== 'EntryDependency' + ); + + // If we don't have non-entry, non-library incoming connections, + // we've reached a root of + if ( ! incomingConnections.length ) { + return true; + } + + const staticDependentModules = incomingConnections.flatMap( + ( connection ) => { + const { dependency } = connection; + const parentBlock = + compilation.moduleGraph.getParentBlock( dependency ); + + return parentBlock.constructor.name !== + AsyncDependenciesBlock.name + ? [ compilation.moduleGraph.getParentModule( dependency ) ] + : []; + } + ); + + // All the dependencies were Async, the module was reached via a dynamic import + if ( ! staticDependentModules.length ) { + return false; + } + + // Continue to explore any static dependencies + return staticDependentModules.some( ( parentStaticDependentModule ) => + DependencyExtractionWebpackPlugin.hasStaticDependencyPathToRoot( + compilation, + parentStaticDependentModule + ) + ); + } } module.exports = DependencyExtractionWebpackPlugin; diff --git a/packages/dependency-extraction-webpack-plugin/lib/types.d.ts b/packages/dependency-extraction-webpack-plugin/lib/types.d.ts index 179b4dab593bd6..c4a4af52b1b2fc 100644 --- a/packages/dependency-extraction-webpack-plugin/lib/types.d.ts +++ b/packages/dependency-extraction-webpack-plugin/lib/types.d.ts @@ -13,6 +13,7 @@ declare interface DependencyExtractionWebpackPluginOptions { outputFormat?: 'php' | 'json'; outputFilename?: string | Function; requestToExternal?: ( request: string ) => string | string[] | undefined; + requestToExternalModule?: ( request: string ) => string | undefined; requestToHandle?: ( request: string ) => string | undefined; combinedOutputFile?: string | null; combineAssets?: boolean; diff --git a/packages/dependency-extraction-webpack-plugin/lib/util.js b/packages/dependency-extraction-webpack-plugin/lib/util.js index bd328430313ce9..bc2b2221e8fc9a 100644 --- a/packages/dependency-extraction-webpack-plugin/lib/util.js +++ b/packages/dependency-extraction-webpack-plugin/lib/util.js @@ -1,10 +1,10 @@ const WORDPRESS_NAMESPACE = '@wordpress/'; const BUNDLED_PACKAGES = [ + '@wordpress/dataviews', '@wordpress/icons', '@wordpress/interface', - '@wordpress/undo-manager', '@wordpress/sync', - '@wordpress/dataviews', + '@wordpress/undo-manager', ]; /** @@ -56,6 +56,21 @@ function defaultRequestToExternal( request ) { } } +/** + * Default request to external module transformation + * + * Currently only @wordpress/interactivity + * + * @param {string} request Module request (the module name in `import from`) to be transformed + * @return {string|undefined} The resulting external definition. Return `undefined` + * to ignore the request. Return `string` to map the request to an external. This may simply be returning the request, e.g. `@wordpress/interactivity` maps to the external `@wordpress/interactivity`. + */ +function defaultRequestToExternalModule( request ) { + if ( request === '@wordpress/interactivity' ) { + return request; + } +} + /** * Default request to WordPress script handle transformation * @@ -101,5 +116,6 @@ function camelCaseDash( string ) { module.exports = { camelCaseDash, defaultRequestToExternal, + defaultRequestToExternalModule, defaultRequestToHandle, }; diff --git a/packages/dependency-extraction-webpack-plugin/test/__snapshots__/build.js.snap b/packages/dependency-extraction-webpack-plugin/test/__snapshots__/build.js.snap index 4240c2f2ea3782..3c8f89fc14ee92 100644 --- a/packages/dependency-extraction-webpack-plugin/test/__snapshots__/build.js.snap +++ b/packages/dependency-extraction-webpack-plugin/test/__snapshots__/build.js.snap @@ -1,5 +1,283 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`DependencyExtractionWebpackPlugin modules Webpack \`combine-assets\` should produce expected output: Asset file 'assets.php' should match snapshot 1`] = ` +" array('dependencies' => array('@wordpress/blob'), 'version' => '8652d2bf4a1ea1969a6e', 'type' => 'module'), 'fileB.mjs' => array('dependencies' => array('@wordpress/token-list'), 'version' => '17d7d5b2c152592ff3a0', 'type' => 'module')); +" +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`combine-assets\` should produce expected output: External modules should match snapshot 1`] = ` +[ + { + "externalType": "module", + "request": "@wordpress/blob", + "userRequest": "@wordpress/blob", + }, + { + "externalType": "module", + "request": "@wordpress/token-list", + "userRequest": "@wordpress/token-list", + }, +] +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`cyclic-dependency-graph\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` +" array('@wordpress/interactivity'), 'version' => '58fadee5eca3ad30aff6', 'type' => 'module'); +" +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`cyclic-dependency-graph\` should produce expected output: External modules should match snapshot 1`] = ` +[ + { + "externalType": "module", + "request": "@wordpress/interactivity", + "userRequest": "@wordpress/interactivity", + }, +] +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`cyclic-dynamic-dependency-graph\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` +" array(array('id' => '@wordpress/interactivity', 'type' => 'dynamic')), 'version' => '293aebad4ca761cf396f', 'type' => 'module'); +" +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`cyclic-dynamic-dependency-graph\` should produce expected output: External modules should match snapshot 1`] = ` +[ + { + "externalType": "module", + "request": "@wordpress/interactivity", + "userRequest": "@wordpress/interactivity", + }, +] +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`dynamic-import\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` +" array(array('id' => '@wordpress/blob', 'type' => 'dynamic')), 'version' => '092c2bce8c247ee11100', 'type' => 'module'); +" +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`dynamic-import\` should produce expected output: External modules should match snapshot 1`] = ` +[ + { + "externalType": "module", + "request": "@wordpress/blob", + "userRequest": "@wordpress/blob", + }, +] +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`function-output-filename\` should produce expected output: Asset file 'chunk--main--main.asset.php' should match snapshot 1`] = ` +" array('@wordpress/blob'), 'version' => '5207bcd3fdd29de25f37', 'type' => 'module'); +" +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`function-output-filename\` should produce expected output: External modules should match snapshot 1`] = ` +[ + { + "externalType": "module", + "request": "@wordpress/blob", + "userRequest": "@wordpress/blob", + }, +] +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`has-extension-suffix\` should produce expected output: Asset file 'index.min.asset.php' should match snapshot 1`] = ` +" array('@wordpress/blob'), 'version' => '9b89a3e6236b26559c4e', 'type' => 'module'); +" +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`has-extension-suffix\` should produce expected output: External modules should match snapshot 1`] = ` +[ + { + "externalType": "module", + "request": "@wordpress/blob", + "userRequest": "@wordpress/blob", + }, +] +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`module-renames\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` +" array('renamed--@my/module', 'renamed--other-module'), 'version' => '601cf94eb9a182fcc0ed', 'type' => 'module'); +" +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`module-renames\` should produce expected output: External modules should match snapshot 1`] = ` +[ + { + "externalType": "module", + "request": "renamed--@my/module", + "userRequest": "@my/module", + }, + { + "externalType": "module", + "request": "renamed--other-module", + "userRequest": "other-module", + }, +] +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`no-default\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` +" array(), 'version' => '34504aa793c63cd3d73a', 'type' => 'module'); +" +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`no-default\` should produce expected output: External modules should match snapshot 1`] = `[]`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`no-deps\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` +" array(), 'version' => 'e37fbd452a6188261d74', 'type' => 'module'); +" +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`no-deps\` should produce expected output: External modules should match snapshot 1`] = `[]`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`option-function-output-filename\` should produce expected output: Asset file 'chunk--main--main.asset.php' should match snapshot 1`] = ` +" array('@wordpress/blob'), 'version' => '5207bcd3fdd29de25f37', 'type' => 'module'); +" +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`option-function-output-filename\` should produce expected output: External modules should match snapshot 1`] = ` +[ + { + "externalType": "module", + "request": "@wordpress/blob", + "userRequest": "@wordpress/blob", + }, +] +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`option-output-filename\` should produce expected output: Asset file 'main-foo.asset.php' should match snapshot 1`] = ` +" array('@wordpress/blob'), 'version' => '5207bcd3fdd29de25f37', 'type' => 'module'); +" +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`option-output-filename\` should produce expected output: External modules should match snapshot 1`] = ` +[ + { + "externalType": "module", + "request": "@wordpress/blob", + "userRequest": "@wordpress/blob", + }, +] +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`output-format-json\` should produce expected output: Asset file 'main.asset.json' should match snapshot 1`] = `"{"dependencies":[],"version":"34504aa793c63cd3d73a","type":"module"}"`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`output-format-json\` should produce expected output: External modules should match snapshot 1`] = `[]`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`overrides\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` +" array('@wordpress/blob', '@wordpress/url', 'rxjs', 'rxjs/operators'), 'version' => '90f2e6327f4e8fb0264f', 'type' => 'module'); +" +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`overrides\` should produce expected output: External modules should match snapshot 1`] = ` +[ + { + "externalType": "module", + "request": "@wordpress/blob", + "userRequest": "@wordpress/blob", + }, + { + "externalType": "module", + "request": "@wordpress/url", + "userRequest": "@wordpress/url", + }, + { + "externalType": "module", + "request": "rxjs", + "userRequest": "rxjs", + }, + { + "externalType": "module", + "request": "rxjs/operators", + "userRequest": "rxjs/operators", + }, +] +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`runtime-chunk-single\` should produce expected output: Asset file 'a.asset.php' should match snapshot 1`] = ` +" array('@wordpress/blob'), 'version' => 'aeadada5bf49ae3b9dc2', 'type' => 'module'); +" +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`runtime-chunk-single\` should produce expected output: Asset file 'b.asset.php' should match snapshot 1`] = ` +" array('@wordpress/blob'), 'version' => '10df52cc859c01faa91d', 'type' => 'module'); +" +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`runtime-chunk-single\` should produce expected output: Asset file 'runtime.asset.php' should match snapshot 1`] = ` +" array(), 'version' => 'd081f44e5ece6763f943', 'type' => 'module'); +" +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`runtime-chunk-single\` should produce expected output: External modules should match snapshot 1`] = ` +[ + { + "externalType": "module", + "request": "@wordpress/blob", + "userRequest": "@wordpress/blob", + }, +] +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`style-imports\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` +" array('@wordpress/blob'), 'version' => '2d597a618aeebe7ab323', 'type' => 'module'); +" +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`style-imports\` should produce expected output: External modules should match snapshot 1`] = ` +[ + { + "externalType": "module", + "request": "@wordpress/blob", + "userRequest": "@wordpress/blob", + }, +] +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`wordpress\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` +" array('@wordpress/blob'), 'version' => '5207bcd3fdd29de25f37', 'type' => 'module'); +" +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`wordpress\` should produce expected output: External modules should match snapshot 1`] = ` +[ + { + "externalType": "module", + "request": "@wordpress/blob", + "userRequest": "@wordpress/blob", + }, +] +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`wordpress-interactivity\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` +" array(array('id' => '@wordpress/interactivity', 'type' => 'dynamic')), 'version' => 'f0242eb6da78af6ca4b8', 'type' => 'module'); +" +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`wordpress-interactivity\` should produce expected output: External modules should match snapshot 1`] = ` +[ + { + "externalType": "module", + "request": "@wordpress/interactivity", + "userRequest": "@wordpress/interactivity", + }, +] +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`wordpress-require\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` +" array('@wordpress/blob'), 'version' => '7a320492a2396d955292', 'type' => 'module'); +" +`; + +exports[`DependencyExtractionWebpackPlugin modules Webpack \`wordpress-require\` should produce expected output: External modules should match snapshot 1`] = ` +[ + { + "externalType": "module", + "request": "@wordpress/blob", + "userRequest": "@wordpress/blob", + }, +] +`; + exports[`DependencyExtractionWebpackPlugin scripts Webpack \`combine-assets\` should produce expected output: Asset file 'assets.php' should match snapshot 1`] = ` " array('dependencies' => array('lodash', 'wp-blob'), 'version' => 'cbe985cf6e1a25d848e5'), 'fileB.js' => array('dependencies' => array('wp-token-list'), 'version' => '7f3970305cf0aecb54ab')); " @@ -31,6 +309,42 @@ exports[`DependencyExtractionWebpackPlugin scripts Webpack \`combine-assets\` sh ] `; +exports[`DependencyExtractionWebpackPlugin scripts Webpack \`cyclic-dependency-graph\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` +" array('wp-interactivity'), 'version' => '79a1af3afac581f52492'); +" +`; + +exports[`DependencyExtractionWebpackPlugin scripts Webpack \`cyclic-dependency-graph\` should produce expected output: External modules should match snapshot 1`] = ` +[ + { + "externalType": "window", + "request": [ + "wp", + "interactivity", + ], + "userRequest": "@wordpress/interactivity", + }, +] +`; + +exports[`DependencyExtractionWebpackPlugin scripts Webpack \`cyclic-dynamic-dependency-graph\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` +" array('wp-interactivity'), 'version' => 'ac0e2f1bcd3a6a0e7aff'); +" +`; + +exports[`DependencyExtractionWebpackPlugin scripts Webpack \`cyclic-dynamic-dependency-graph\` should produce expected output: External modules should match snapshot 1`] = ` +[ + { + "externalType": "window", + "request": [ + "wp", + "interactivity", + ], + "userRequest": "@wordpress/interactivity", + }, +] +`; + exports[`DependencyExtractionWebpackPlugin scripts Webpack \`dynamic-import\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` " array('wp-blob'), 'version' => 'c0e8a6f22065ea096606'); " @@ -95,6 +409,32 @@ exports[`DependencyExtractionWebpackPlugin scripts Webpack \`has-extension-suffi ] `; +exports[`DependencyExtractionWebpackPlugin scripts Webpack \`module-renames\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` +" array('renamed--@my/module', 'renamed--other-module'), 'version' => '34854902f36ec8e176d6'); +" +`; + +exports[`DependencyExtractionWebpackPlugin scripts Webpack \`module-renames\` should produce expected output: External modules should match snapshot 1`] = ` +[ + { + "externalType": "window", + "request": [ + "my-namespace", + "renamed--@my/module", + ], + "userRequest": "@my/module", + }, + { + "externalType": "window", + "request": [ + "my-namespace", + "renamed--other-module", + ], + "userRequest": "other-module", + }, +] +`; + exports[`DependencyExtractionWebpackPlugin scripts Webpack \`no-default\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` " array(), 'version' => '43880e6c42e7c39fcdf1'); " @@ -285,6 +625,29 @@ exports[`DependencyExtractionWebpackPlugin scripts Webpack \`wordpress\` should ] `; +exports[`DependencyExtractionWebpackPlugin scripts Webpack \`wordpress-interactivity\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` +" array('lodash', 'wp-interactivity'), 'version' => 'b16015e38aea0509f75f'); +" +`; + +exports[`DependencyExtractionWebpackPlugin scripts Webpack \`wordpress-interactivity\` should produce expected output: External modules should match snapshot 1`] = ` +[ + { + "externalType": "window", + "request": "lodash", + "userRequest": "lodash", + }, + { + "externalType": "window", + "request": [ + "wp", + "interactivity", + ], + "userRequest": "@wordpress/interactivity", + }, +] +`; + exports[`DependencyExtractionWebpackPlugin scripts Webpack \`wordpress-require\` should produce expected output: Asset file 'main.asset.php' should match snapshot 1`] = ` " array('lodash', 'wp-blob'), 'version' => '40370eb4ce6428562da6'); " diff --git a/packages/dependency-extraction-webpack-plugin/test/build.js b/packages/dependency-extraction-webpack-plugin/test/build.js index 7c84e4b0bcc8b3..3b29d55caf2bb0 100644 --- a/packages/dependency-extraction-webpack-plugin/test/build.js +++ b/packages/dependency-extraction-webpack-plugin/test/build.js @@ -13,89 +13,109 @@ const configFixtures = fs.readdirSync( fixturesPath ).sort(); afterAll( () => rimraf( path.join( __dirname, 'build' ) ) ); -describe( 'DependencyExtractionWebpackPlugin scripts', () => { - describe.each( configFixtures )( 'Webpack `%s`', ( configCase ) => { - const testDirectory = path.join( fixturesPath, configCase ); - const outputDirectory = path.join( __dirname, 'build', configCase ); +describe.each( /** @type {const} */ ( [ 'scripts', 'modules' ] ) )( + 'DependencyExtractionWebpackPlugin %s', + ( moduleMode ) => { + describe.each( configFixtures )( 'Webpack `%s`', ( configCase ) => { + const testDirectory = path.join( fixturesPath, configCase ); + const outputDirectory = path.join( + __dirname, + 'build', + moduleMode, + configCase + ); - beforeEach( () => { - rimraf( outputDirectory ); - mkdirp( outputDirectory ); - } ); + beforeEach( () => { + rimraf( outputDirectory ); + mkdirp( outputDirectory ); + } ); - // This afterEach is necessary to prevent watched tests from retriggering on every run. - afterEach( () => rimraf( outputDirectory ) ); + // This afterEach is necessary to prevent watched tests from retriggering on every run. + afterEach( () => rimraf( outputDirectory ) ); - test( 'should produce expected output', async () => { - const options = Object.assign( - { - target: 'web', - context: testDirectory, - entry: './index.js', - mode: 'production', - optimization: { - minimize: false, - chunkIds: 'named', - moduleIds: 'named', + test( 'should produce expected output', async () => { + const options = Object.assign( + { + name: `${ configCase }-${ moduleMode }`, + target: 'web', + context: testDirectory, + entry: './index.js', + mode: 'production', + optimization: { + minimize: false, + chunkIds: 'named', + moduleIds: 'named', + }, + output: {}, + experiments: {}, }, - output: {}, - experiments: {}, - }, - require( path.join( testDirectory, 'webpack.config.js' ) ) - ); - options.output.path = outputDirectory; + require( path.join( testDirectory, 'webpack.config.js' ) ) + ); + options.output.path = outputDirectory; - /** @type {webpack.Stats} */ - const stats = await new Promise( ( resolve, reject ) => - webpack( options, ( err, _stats ) => { - if ( err ) { - return reject( err ); - } - resolve( _stats ); - } ) - ); + if ( moduleMode === 'modules' ) { + options.target = 'es2024'; + options.output.module = true; + options.output.chunkFormat = 'module'; + options.output.library = options.output.library || {}; + options.output.library.type = 'module'; + options.experiments.outputModule = true; + } - if ( stats.hasErrors() ) { - throw new Error( - stats.toString( { errors: true, all: false } ) + /** @type {webpack.Stats} */ + const stats = await new Promise( ( resolve, reject ) => + webpack( options, ( err, _stats ) => { + if ( err ) { + return reject( err ); + } + resolve( _stats ); + } ) ); - } - const assetFiles = glob( - `${ outputDirectory }/+(*.asset|assets).@(json|php)` - ); + if ( stats.hasErrors() ) { + throw new Error( + stats.toString( { errors: true, all: false } ) + ); + } - expect( assetFiles.length ).toBeGreaterThan( 0 ); + const assetFiles = glob( + `${ outputDirectory }/+(*.asset|assets).@(json|php)` + ); - // Asset files should match. - assetFiles.forEach( ( assetFile ) => { - const assetBasename = path.basename( assetFile ); + expect( assetFiles.length ).toBeGreaterThan( 0 ); - expect( fs.readFileSync( assetFile, 'utf-8' ) ).toMatchSnapshot( - `Asset file '${ assetBasename }' should match snapshot` - ); - } ); + // Asset files should match. + assetFiles.forEach( ( assetFile ) => { + const assetBasename = path.basename( assetFile ); - const compareByModuleIdentifier = ( m1, m2 ) => { - const i1 = m1.identifier(); - const i2 = m2.identifier(); - if ( i1 < i2 ) return -1; - if ( i1 > i2 ) return 1; - return 0; - }; + expect( + fs.readFileSync( assetFile, 'utf-8' ) + ).toMatchSnapshot( + `Asset file '${ assetBasename }' should match snapshot` + ); + } ); - // Webpack stats external modules should match. - const externalModules = Array.from( stats.compilation.modules ) - .filter( ( { externalType } ) => externalType ) - .sort( compareByModuleIdentifier ) - .map( ( module ) => ( { - externalType: module.externalType, - request: module.request, - userRequest: module.userRequest, - } ) ); - expect( externalModules ).toMatchSnapshot( - 'External modules should match snapshot' - ); + const compareByModuleIdentifier = ( m1, m2 ) => { + const i1 = m1.identifier(); + const i2 = m2.identifier(); + if ( i1 < i2 ) return -1; + if ( i1 > i2 ) return 1; + return 0; + }; + + // Webpack stats external modules should match. + const externalModules = Array.from( stats.compilation.modules ) + .filter( ( { externalType } ) => externalType ) + .sort( compareByModuleIdentifier ) + .map( ( module ) => ( { + externalType: module.externalType, + request: module.request, + userRequest: module.userRequest, + } ) ); + expect( externalModules ).toMatchSnapshot( + 'External modules should match snapshot' + ); + } ); } ); - } ); -} ); + } +); diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/combine-assets/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/combine-assets/webpack.config.js index 2ce7ba1be98e25..fb7ba94ca80998 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/combine-assets/webpack.config.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/combine-assets/webpack.config.js @@ -11,6 +11,11 @@ module.exports = { plugins: [ new DependencyExtractionWebpackPlugin( { combineAssets: true, + requestToExternalModule( request ) { + if ( request.startsWith( '@wordpress/' ) ) { + return request; + } + }, } ), ], }; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dependency-graph/a.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dependency-graph/a.js new file mode 100644 index 00000000000000..11dd7764ad5f92 --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dependency-graph/a.js @@ -0,0 +1,13 @@ +/** + * WordPress dependencies + */ +import { store } from '@wordpress/interactivity'; + +/** + * Internal dependencies + */ +import { identity } from './b.js'; + +identity( 1 ); + +export { identity, store }; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dependency-graph/b.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dependency-graph/b.js new file mode 100644 index 00000000000000..ce109acccbd370 --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dependency-graph/b.js @@ -0,0 +1,10 @@ +/** + * Internal dependencies + */ +import { store } from './a.js'; + +export function identity( x ) { + return x; +} + +export { store }; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dependency-graph/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dependency-graph/index.js new file mode 100644 index 00000000000000..13b17a73ad4af8 --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dependency-graph/index.js @@ -0,0 +1,8 @@ +/** + * Internal dependencies + */ +import { identity as aIdentity, store as aStore } from './a.js'; +import { identity as bIdentity, store as bStore } from './b.js'; + +aStore( aIdentity( 'a' ), { a: aIdentity( 'a' ) } ); +bStore( bIdentity( 'b' ), { b: bIdentity( 'b' ) } ); diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dependency-graph/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dependency-graph/webpack.config.js new file mode 100644 index 00000000000000..bfffff3ae78319 --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dependency-graph/webpack.config.js @@ -0,0 +1,8 @@ +/** + * Internal dependencies + */ +const DependencyExtractionWebpackPlugin = require( '../../..' ); + +module.exports = { + plugins: [ new DependencyExtractionWebpackPlugin() ], +}; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dynamic-dependency-graph/a.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dynamic-dependency-graph/a.js new file mode 100644 index 00000000000000..11dd7764ad5f92 --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dynamic-dependency-graph/a.js @@ -0,0 +1,13 @@ +/** + * WordPress dependencies + */ +import { store } from '@wordpress/interactivity'; + +/** + * Internal dependencies + */ +import { identity } from './b.js'; + +identity( 1 ); + +export { identity, store }; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dynamic-dependency-graph/b.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dynamic-dependency-graph/b.js new file mode 100644 index 00000000000000..25a6aa127d26fd --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dynamic-dependency-graph/b.js @@ -0,0 +1,10 @@ +/** + * Internal dependencies + */ +const { store } = import( './a.js' ); + +export function identity( x ) { + return x; +} + +export { store }; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dynamic-dependency-graph/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dynamic-dependency-graph/index.js new file mode 100644 index 00000000000000..073b4244dcea26 --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dynamic-dependency-graph/index.js @@ -0,0 +1,9 @@ +/** + * Internal dependencies + */ +import { identity as bIdentity, store as bStore } from './b.js'; + +const { identity: aIdentity, store: aStore } = await import( './a.js' ); + +aStore( aIdentity( 'a' ), { a: aIdentity( 'a' ) } ); +bStore( bIdentity( 'b' ), { b: bIdentity( 'b' ) } ); diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dynamic-dependency-graph/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dynamic-dependency-graph/webpack.config.js new file mode 100644 index 00000000000000..bfffff3ae78319 --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dynamic-dependency-graph/webpack.config.js @@ -0,0 +1,8 @@ +/** + * Internal dependencies + */ +const DependencyExtractionWebpackPlugin = require( '../../..' ); + +module.exports = { + plugins: [ new DependencyExtractionWebpackPlugin() ], +}; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/dynamic-import/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/dynamic-import/webpack.config.js index bfffff3ae78319..6856d328ab7c68 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/dynamic-import/webpack.config.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/dynamic-import/webpack.config.js @@ -4,5 +4,13 @@ const DependencyExtractionWebpackPlugin = require( '../../..' ); module.exports = { - plugins: [ new DependencyExtractionWebpackPlugin() ], + plugins: [ + new DependencyExtractionWebpackPlugin( { + requestToExternalModule( request ) { + if ( request.startsWith( '@wordpress/' ) ) { + return request; + } + }, + } ), + ], }; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/function-output-filename/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/function-output-filename/webpack.config.js index 2d5b2e43b735ec..f637a4087e3ca3 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/function-output-filename/webpack.config.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/function-output-filename/webpack.config.js @@ -9,5 +9,13 @@ module.exports = { return `chunk--${ chunkData.chunk.name }--[name].js`; }, }, - plugins: [ new DependencyExtractionWebpackPlugin() ], + plugins: [ + new DependencyExtractionWebpackPlugin( { + requestToExternalModule( request ) { + if ( request.startsWith( '@wordpress/' ) ) { + return request; + } + }, + } ), + ], }; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/has-extension-suffix/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/has-extension-suffix/webpack.config.js index d814beacdf4dc3..ada40c8bf8e54e 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/has-extension-suffix/webpack.config.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/has-extension-suffix/webpack.config.js @@ -7,5 +7,13 @@ module.exports = { output: { filename: 'index.min.js', }, - plugins: [ new DependencyExtractionWebpackPlugin() ], + plugins: [ + new DependencyExtractionWebpackPlugin( { + requestToExternalModule( request ) { + if ( request.startsWith( '@wordpress/' ) ) { + return request; + } + }, + } ), + ], }; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/module-renames/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/module-renames/index.js new file mode 100644 index 00000000000000..dc3702922c6ff4 --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/module-renames/index.js @@ -0,0 +1,7 @@ +/** + * External dependencies + */ +import * as m from '@my/module'; +import { other } from 'other-module'; + +m.load( other ); diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/module-renames/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/module-renames/webpack.config.js new file mode 100644 index 00000000000000..8b78e1fdea1505 --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/module-renames/webpack.config.js @@ -0,0 +1,32 @@ +/** + * Internal dependencies + */ +const DependencyExtractionWebpackPlugin = require( '../../..' ); + +module.exports = { + plugins: [ + new DependencyExtractionWebpackPlugin( { + requestToExternal( request ) { + switch ( request ) { + case '@my/module': + case 'other-module': + return [ 'my-namespace', `renamed--${ request }` ]; + } + }, + requestToHandle( request ) { + switch ( request ) { + case '@my/module': + case 'other-module': + return `renamed--${ request }`; + } + }, + requestToExternalModule( request ) { + switch ( request ) { + case '@my/module': + case 'other-module': + return `renamed--${ request }`; + } + }, + } ), + ], +}; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/option-function-output-filename/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/option-function-output-filename/webpack.config.js index e328c817851ce1..5056f312c39992 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/option-function-output-filename/webpack.config.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/option-function-output-filename/webpack.config.js @@ -9,6 +9,11 @@ module.exports = { outputFilename( chunkData ) { return `chunk--${ chunkData.chunk.name }--[name].asset.php`; }, + requestToExternalModule( request ) { + if ( request.startsWith( '@wordpress/' ) ) { + return request; + } + }, } ), ], }; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/option-output-filename/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/option-output-filename/webpack.config.js index 9ec78f6437a18b..be52e661653868 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/option-output-filename/webpack.config.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/option-output-filename/webpack.config.js @@ -7,6 +7,11 @@ module.exports = { plugins: [ new DependencyExtractionWebpackPlugin( { outputFilename: '[name]-foo.asset.php', + requestToExternalModule( request ) { + if ( request.startsWith( '@wordpress/' ) ) { + return request; + } + }, } ), ], }; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/overrides/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/overrides/webpack.config.js index 9885e5cade7e96..89eaf6ee4b2f53 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/overrides/webpack.config.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/overrides/webpack.config.js @@ -15,6 +15,18 @@ module.exports = { return [ 'rxjs', 'operators' ]; } }, + requestToExternalModule( request ) { + if ( request === 'rxjs' ) { + return request; + } + + if ( request === 'rxjs/operators' ) { + return request; + } + if ( request.startsWith( '@wordpress/' ) ) { + return request; + } + }, requestToHandle( request ) { if ( request === 'rxjs' || request === 'rxjs/operators' ) { return 'wp-script-handle-for-rxjs'; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/runtime-chunk-single/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/runtime-chunk-single/webpack.config.js index e16f6b6b0fe70a..1e0824563c52f0 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/runtime-chunk-single/webpack.config.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/runtime-chunk-single/webpack.config.js @@ -8,7 +8,15 @@ module.exports = { a: './a', b: './b', }, - plugins: [ new DependencyExtractionWebpackPlugin() ], + plugins: [ + new DependencyExtractionWebpackPlugin( { + requestToExternalModule( request ) { + if ( request.startsWith( '@wordpress/' ) ) { + return request; + } + }, + } ), + ], optimization: { runtimeChunk: 'single', }, diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/style-imports/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/style-imports/webpack.config.js index 52cb718a579de4..332e182e34b042 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/style-imports/webpack.config.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/style-imports/webpack.config.js @@ -10,7 +10,13 @@ const DependencyExtractionWebpackPlugin = require( '../../..' ); module.exports = { plugins: [ - new DependencyExtractionWebpackPlugin(), + new DependencyExtractionWebpackPlugin( { + requestToExternalModule( request ) { + if ( request.startsWith( '@wordpress/' ) ) { + return request; + } + }, + } ), new MiniCSSExtractPlugin(), ], module: { diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress-interactivity/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress-interactivity/index.js new file mode 100644 index 00000000000000..b4dd2f288661ea --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress-interactivity/index.js @@ -0,0 +1,12 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ + +import _ from 'lodash'; + +// This module should be externalized +const { store, getContext } = await import( '@wordpress/interactivity' ); + +store( _.identity( 'my-namespace' ), { state: 'is great' } ); + +getContext(); diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress-interactivity/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress-interactivity/webpack.config.js new file mode 100644 index 00000000000000..bfffff3ae78319 --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress-interactivity/webpack.config.js @@ -0,0 +1,8 @@ +/** + * Internal dependencies + */ +const DependencyExtractionWebpackPlugin = require( '../../..' ); + +module.exports = { + plugins: [ new DependencyExtractionWebpackPlugin() ], +}; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress-require/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress-require/webpack.config.js index bfffff3ae78319..6856d328ab7c68 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress-require/webpack.config.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress-require/webpack.config.js @@ -4,5 +4,13 @@ const DependencyExtractionWebpackPlugin = require( '../../..' ); module.exports = { - plugins: [ new DependencyExtractionWebpackPlugin() ], + plugins: [ + new DependencyExtractionWebpackPlugin( { + requestToExternalModule( request ) { + if ( request.startsWith( '@wordpress/' ) ) { + return request; + } + }, + } ), + ], }; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress/webpack.config.js index bfffff3ae78319..6856d328ab7c68 100644 --- a/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress/webpack.config.js +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress/webpack.config.js @@ -4,5 +4,13 @@ const DependencyExtractionWebpackPlugin = require( '../../..' ); module.exports = { - plugins: [ new DependencyExtractionWebpackPlugin() ], + plugins: [ + new DependencyExtractionWebpackPlugin( { + requestToExternalModule( request ) { + if ( request.startsWith( '@wordpress/' ) ) { + return request; + } + }, + } ), + ], }; diff --git a/packages/e2e-tests/specs/editor/various/datepicker.test.js b/packages/e2e-tests/specs/editor/various/datepicker.test.js deleted file mode 100644 index 6838fd56a2ba9a..00000000000000 --- a/packages/e2e-tests/specs/editor/various/datepicker.test.js +++ /dev/null @@ -1,148 +0,0 @@ -/** - * WordPress dependencies - */ -import { createNewPost, changeSiteTimezone } from '@wordpress/e2e-test-utils'; - -async function getInputValue( selector ) { - return page.$eval( selector, ( element ) => element.value ); -} - -async function getSelectedOptionLabel( selector ) { - return page.$eval( - selector, - ( element ) => element.options[ element.selectedIndex ].text - ); -} - -async function getDatePickerValues() { - const year = await getInputValue( - '.components-datetime__time-field-year input' - ); - const month = await getInputValue( - '.components-datetime__time-field-month select' - ); - const monthLabel = await getSelectedOptionLabel( - '.components-datetime__time-field-month select' - ); - const day = await getInputValue( - '.components-datetime__time-field-day input' - ); - const hours = await getInputValue( - '.components-datetime__time-field-hours-input input' - ); - const minutes = await getInputValue( - '.components-datetime__time-field-minutes-input input' - ); - const amOrPm = await page.$eval( - '.components-datetime__time-field-am-pm .is-primary', - ( element ) => element.innerText.toLowerCase() - ); - - return { year, month, monthLabel, day, hours, minutes, amOrPm }; -} - -function trimLeadingZero( str ) { - return str[ 0 ] === '0' ? str.slice( 1 ) : str; -} - -function formatDatePickerValues( - { year, monthLabel, day, hours, minutes, amOrPm }, - timezone -) { - const dayTrimmed = trimLeadingZero( day ); - const hoursTrimmed = trimLeadingZero( hours ); - return `${ monthLabel } ${ dayTrimmed }, ${ year } ${ hoursTrimmed }:${ minutes }\xa0${ amOrPm } ${ timezone }`; -} - -async function getPublishingDate() { - return page.$eval( - '.editor-post-schedule__dialog-toggle', - ( dateLabel ) => dateLabel.textContent - ); -} - -describe.each( [ [ 'UTC-10' ], [ 'UTC' ], [ 'UTC+10' ] ] )( - `Datepicker %s`, - ( timezone ) => { - let oldTimezone; - beforeEach( async () => { - await page.emulateTimezone( 'America/New_York' ); // Set browser to a timezone that's different to `timezone`. - oldTimezone = await changeSiteTimezone( timezone ); - await createNewPost(); - } ); - afterEach( async () => { - await changeSiteTimezone( oldTimezone ); - await page.emulateTimezone( null ); - } ); - - it( 'should show the publishing date as "Immediately" if the date is not altered', async () => { - const publishingDate = await getPublishingDate(); - - expect( publishingDate ).toEqual( 'Immediately' ); - } ); - - it( 'should show the publishing date if the date is in the past', async () => { - // Open the datepicker. - await page.click( '.editor-post-schedule__dialog-toggle' ); - - // Change the publishing date to a year in the past. - await page.click( '.components-datetime__time-field-year' ); - await page.keyboard.press( 'ArrowDown' ); - const datePickerValues = await getDatePickerValues(); - - // Close the datepicker. - await page.click( '.editor-post-schedule__dialog-toggle' ); - - const publishingDate = await getPublishingDate(); - - expect( publishingDate ).toBe( - formatDatePickerValues( datePickerValues, timezone ) - ); - } ); - - it( 'should show the publishing date if the date is in the future', async () => { - // Open the datepicker. - await page.click( '.editor-post-schedule__dialog-toggle' ); - - // Change the publishing date to a year in the future. - await page.click( '.components-datetime__time-field-year' ); - await page.keyboard.press( 'ArrowUp' ); - const datePickerValues = await getDatePickerValues(); - - // Close the datepicker. - await page.click( '.editor-post-schedule__dialog-toggle' ); - - const publishingDate = await getPublishingDate(); - - expect( publishingDate ).not.toEqual( 'Immediately' ); - // The expected date format will be "Sep 26, 2018 11:52 pm". - expect( publishingDate ).toBe( - formatDatePickerValues( datePickerValues, timezone ) - ); - } ); - - it( `should show the publishing date as "Immediately" if the date is cleared`, async () => { - // Open the datepicker. - await page.click( '.editor-post-schedule__dialog-toggle' ); - - // Change the publishing date to a year in the future. - await page.click( '.components-datetime__time-field-year' ); - await page.keyboard.press( 'ArrowUp' ); - - // Close the datepicker. - await page.click( '.editor-post-schedule__dialog-toggle' ); - - // Open the datepicker. - await page.click( '.editor-post-schedule__dialog-toggle' ); - - // Clear the date. - await page.click( - '.block-editor-publish-date-time-picker button[aria-label="Now"]' - ); - - const publishingDate = await getPublishingDate(); - - expect( publishingDate ).toEqual( 'Immediately' ); - } ); - } -); diff --git a/packages/e2e-tests/specs/editor/various/invalid-block.test.js b/packages/e2e-tests/specs/editor/various/invalid-block.test.js deleted file mode 100644 index 354c370434be92..00000000000000 --- a/packages/e2e-tests/specs/editor/various/invalid-block.test.js +++ /dev/null @@ -1,100 +0,0 @@ -/** - * WordPress dependencies - */ -import { - clickMenuItem, - createNewPost, - clickBlockAppender, - clickBlockToolbarButton, - setPostContent, - canvas, -} from '@wordpress/e2e-test-utils'; - -describe( 'invalid blocks', () => { - beforeEach( async () => { - await createNewPost(); - } ); - - it( 'Should show an invalid block message with clickable options', async () => { - // Create an empty paragraph with the focus in the block. - await clickBlockAppender(); - await page.keyboard.type( 'hello' ); - - await clickBlockToolbarButton( 'Options' ); - - // Change to HTML mode and close the options. - await clickMenuItem( 'Edit as HTML' ); - - // Focus on the textarea and enter an invalid paragraph - await canvas().click( - '.block-editor-block-list__layout .block-editor-block-list__block .block-editor-block-list__block-html-textarea' - ); - await page.keyboard.type( '

invalid paragraph' ); - - // Takes the focus away from the block so the invalid warning is triggered - await page.click( '.editor-post-save-draft' ); - - // Click on the 'three-dots' menu toggle. - await canvas().click( - '.block-editor-warning__actions button[aria-label="More options"]' - ); - - await clickMenuItem( 'Resolve' ); - - // Check we get the resolve modal with the appropriate contents. - const htmlBlockContent = await page.$eval( - '.block-editor-block-compare__html', - ( node ) => node.textContent - ); - expect( htmlBlockContent ).toEqual( - '

hello

invalid paragraph' - ); - } ); - - it( 'should strip potentially malicious on* attributes', async () => { - let hasAlert = false; - - page.on( 'dialog', () => { - hasAlert = true; - } ); - - // The paragraph block contains invalid HTML, which causes it to be an - // invalid block. - await setPostContent( - ` - -

aaaa 1 - - ` - ); - - // Give the browser time to show the alert. - await page.evaluate( () => new Promise( window.requestIdleCallback ) ); - - expect( console ).toHaveWarned(); - expect( console ).toHaveErrored(); - expect( hasAlert ).toBe( false ); - } ); - - it( 'should not trigger malicious script tags when using a shortcode block', async () => { - let hasAlert = false; - - page.on( 'dialog', () => { - hasAlert = true; - } ); - - // The shortcode block contains invalid HTML, which causes it to be an - // invalid block. - await setPostContent( - ` - - - - ` - ); - - // Give the browser time to show the alert. - await page.evaluate( () => new Promise( window.requestIdleCallback ) ); - expect( hasAlert ).toBe( false ); - } ); -} ); diff --git a/packages/e2e-tests/specs/editor/various/nux.test.js b/packages/e2e-tests/specs/editor/various/nux.test.js deleted file mode 100644 index f8b4e86628bfb3..00000000000000 --- a/packages/e2e-tests/specs/editor/various/nux.test.js +++ /dev/null @@ -1,162 +0,0 @@ -/** - * WordPress dependencies - */ -import { - createNewPost, - clickOnMoreMenuItem, - canvas, -} from '@wordpress/e2e-test-utils'; - -describe( 'New User Experience (NUX)', () => { - it( 'should show the guide to first-time users', async () => { - let welcomeGuideText, welcomeGuide; - - // Create a new post as a first-time user. - await createNewPost( { showWelcomeGuide: true } ); - - // Guide should be on page 1 of 4 - welcomeGuideText = await page.$eval( - '.edit-post-welcome-guide', - ( element ) => element.innerText - ); - expect( welcomeGuideText ).toContain( 'Welcome to the block editor' ); - - // Click on the 'Next' button. - const [ nextButton ] = await page.$x( - '//button[contains(text(), "Next")]' - ); - await nextButton.click(); - - // Guide should be on page 2 of 4 - welcomeGuideText = await page.$eval( - '.edit-post-welcome-guide', - ( element ) => element.innerText - ); - expect( welcomeGuideText ).toContain( 'Make each block your own' ); - - // Click on the 'Previous' button. - const [ previousButton ] = await page.$x( - '//button[contains(text(), "Previous")]' - ); - await previousButton.click(); - - // Guide should be on page 1 of 4 - welcomeGuideText = await page.$eval( - '.edit-post-welcome-guide', - ( element ) => element.innerText - ); - expect( welcomeGuideText ).toContain( 'Welcome to the block editor' ); - - // Press the button for Page 2. - await page.click( 'button[aria-label="Page 2 of 4"]' ); - await page.waitForXPath( - '//h1[contains(text(), "Make each block your own")]' - ); - // This shouldn't be necessary - // eslint-disable-next-line no-restricted-syntax - await page.waitForTimeout( 500 ); - - // Press the right arrow key for Page 3. - await page.keyboard.press( 'ArrowRight' ); - await page.waitForXPath( - '//h1[contains(text(), "Get to know the block library")]' - ); - - // Press the right arrow key for Page 4. - await page.keyboard.press( 'ArrowRight' ); - await page.waitForXPath( - '//h1[contains(text(), "Learn how to use the block editor")]' - ); - - // Click on the *visible* 'Get started' button. There are two in the DOM - // but only one is shown depending on viewport size. - let getStartedButton; - for ( const buttonHandle of await page.$x( - '//button[contains(text(), "Get started")]' - ) ) { - if ( - await page.evaluate( - ( button ) => button.style.display !== 'none', - buttonHandle - ) - ) { - getStartedButton = buttonHandle; - } - } - await getStartedButton.click(); - - // Guide should be closed - welcomeGuide = await page.$( '.edit-post-welcome-guide' ); - expect( welcomeGuide ).toBeNull(); - - // Reload the editor. - await page.reload(); - await page.waitForSelector( '.edit-post-layout' ); - - // Guide should be closed - welcomeGuide = await page.$( '.edit-post-welcome-guide' ); - expect( welcomeGuide ).toBeNull(); - } ); - - it( 'should not show the welcome guide again if it is dismissed', async () => { - let welcomeGuide; - - // Create a new post as a first-time user. - await createNewPost( { showWelcomeGuide: true } ); - - // Guide should be open - welcomeGuide = await page.$( '.edit-post-welcome-guide' ); - expect( welcomeGuide ).not.toBeNull(); - - // Close the guide - await page.click( '[role="dialog"] button[aria-label="Close"]' ); - - // Reload the editor. - await page.reload(); - await page.waitForSelector( '.edit-post-layout' ); - - // Guide should be closed - welcomeGuide = await page.$( '.edit-post-welcome-guide' ); - expect( welcomeGuide ).toBeNull(); - } ); - - it( 'should focus post title field after welcome guide is dismissed and post is empty', async () => { - // Create a new post as a first-time user. - await createNewPost( { showWelcomeGuide: true } ); - - // Guide should be open. - const welcomeGuide = await page.$( '.edit-post-welcome-guide' ); - expect( welcomeGuide ).not.toBeNull(); - - // Close the guide. - await page.click( '[role="dialog"] button[aria-label="Close"]' ); - - // Focus should be in post title field. - const postTitle = await canvas().waitForSelector( - 'h1[aria-label="Add title"' - ); - await expect( - postTitle.evaluate( - ( node ) => node === node.ownerDocument.activeElement - ) - ).resolves.toBe( true ); - } ); - - it( 'should show the welcome guide if it is manually opened', async () => { - let welcomeGuide; - - // Create a new post as a returning user. - await createNewPost(); - - // Guide should be closed - welcomeGuide = await page.$( '.edit-post-welcome-guide' ); - expect( welcomeGuide ).toBeNull(); - - // Manually open the guide - await clickOnMoreMenuItem( 'Welcome Guide' ); - - // Guide should be open - welcomeGuide = await page.$( '.edit-post-welcome-guide' ); - expect( welcomeGuide ).not.toBeNull(); - } ); -} ); diff --git a/packages/e2e-tests/specs/editor/various/publishing.test.js b/packages/e2e-tests/specs/editor/various/publishing.test.js deleted file mode 100644 index fbac8cf98638bb..00000000000000 --- a/packages/e2e-tests/specs/editor/various/publishing.test.js +++ /dev/null @@ -1,176 +0,0 @@ -/** - * WordPress dependencies - */ -import { - createNewPost, - publishPost, - publishPostWithPrePublishChecksDisabled, - enablePrePublishChecks, - disablePrePublishChecks, - arePrePublishChecksEnabled, - setBrowserViewport, - openPublishPanel, - pressKeyWithModifier, - canvas, -} from '@wordpress/e2e-test-utils'; - -describe( 'Publishing', () => { - describe.each( [ 'post', 'page' ] )( - '%s locking prevent saving', - ( postType ) => { - beforeEach( async () => { - await createNewPost( postType ); - } ); - - it( `disables the publish button when a ${ postType } is locked`, async () => { - await canvas().type( - '.editor-post-title__input', - 'E2E Test Post lock check publish button' - ); - await page.evaluate( () => - wp.data - .dispatch( 'core/editor' ) - .lockPostSaving( 'futurelock' ) - ); - - await openPublishPanel(); - - expect( - await page.$( - '.editor-post-publish-button[aria-disabled="true"]' - ) - ).not.toBeNull(); - } ); - - it( `disables the save shortcut when a ${ postType } is locked`, async () => { - await canvas().type( - '.editor-post-title__input', - 'E2E Test Post check save shortcut' - ); - await page.evaluate( () => - wp.data - .dispatch( 'core/editor' ) - .lockPostSaving( 'futurelock' ) - ); - await pressKeyWithModifier( 'primary', 'S' ); - - expect( await page.$( '.editor-post-saved-state' ) ).toBeNull(); - expect( - await page.$( '.editor-post-save-draft' ) - ).not.toBeNull(); - } ); - } - ); - - describe.each( [ 'post', 'page' ] )( 'a %s', ( postType ) => { - let werePrePublishChecksEnabled; - - beforeEach( async () => { - await createNewPost( postType ); - werePrePublishChecksEnabled = await arePrePublishChecksEnabled(); - if ( ! werePrePublishChecksEnabled ) { - await enablePrePublishChecks(); - } - } ); - - afterEach( async () => { - if ( ! werePrePublishChecksEnabled ) { - await disablePrePublishChecks(); - } - } ); - - it( `should publish the ${ postType } and close the panel once we start editing again.`, async () => { - await canvas().type( '.editor-post-title__input', 'E2E Test Post' ); - - await publishPost(); - - // The post-publishing panel is visible. - expect( - await page.$( '.editor-post-publish-panel' ) - ).not.toBeNull(); - - // Start editing again. - await canvas().type( '.editor-post-title__input', ' (Updated)' ); - - // The post-publishing panel is not visible anymore. - expect( await page.$( '.editor-post-publish-panel' ) ).toBeNull(); - } ); - } ); - - describe.each( [ 'post', 'page' ] )( - 'a %s with pre-publish checks disabled', - ( postType ) => { - let werePrePublishChecksEnabled; - - beforeEach( async () => { - await createNewPost( postType ); - werePrePublishChecksEnabled = - await arePrePublishChecksEnabled(); - if ( werePrePublishChecksEnabled ) { - await disablePrePublishChecks(); - } - } ); - - afterEach( async () => { - if ( werePrePublishChecksEnabled ) { - await enablePrePublishChecks(); - } - } ); - - it( `should publish the ${ postType } without opening the post-publish sidebar.`, async () => { - await canvas().type( - '.editor-post-title__input', - 'E2E Test Post' - ); - - // The "Publish" button should be shown instead of the "Publish..." toggle. - expect( - await page.$( '.editor-post-publish-panel__toggle' ) - ).toBeNull(); - expect( - await page.$( '.editor-post-publish-button' ) - ).not.toBeNull(); - - await publishPostWithPrePublishChecksDisabled(); - - // The post-publishing panel should have been not shown. - expect( - await page.$( '.editor-post-publish-panel' ) - ).toBeNull(); - } ); - } - ); - - describe.each( [ 'post', 'page' ] )( - 'a %s in small viewports', - ( postType ) => { - let werePrePublishChecksEnabled; - - beforeEach( async () => { - await createNewPost( postType ); - werePrePublishChecksEnabled = - await arePrePublishChecksEnabled(); - if ( werePrePublishChecksEnabled ) { - await disablePrePublishChecks(); - } - await setBrowserViewport( 'small' ); - } ); - - afterEach( async () => { - await setBrowserViewport( 'large' ); - if ( werePrePublishChecksEnabled ) { - await enablePrePublishChecks(); - } - } ); - - it( `should ignore the pre-publish checks and show the Publish... toggle instead of the Publish button`, async () => { - expect( - await page.$( '.editor-post-publish-panel__toggle' ) - ).not.toBeNull(); - expect( - await page.$( '.editor-post-publish-button' ) - ).toBeNull(); - } ); - } - ); -} ); diff --git a/packages/e2e-tests/specs/editor/various/scheduling.test.js b/packages/e2e-tests/specs/editor/various/scheduling.test.js deleted file mode 100644 index df75dcb92f2820..00000000000000 --- a/packages/e2e-tests/specs/editor/various/scheduling.test.js +++ /dev/null @@ -1,65 +0,0 @@ -/** - * WordPress dependencies - */ -import { createNewPost, changeSiteTimezone } from '@wordpress/e2e-test-utils'; - -async function getPublishButtonText() { - return page.$eval( - '.editor-post-publish-button__button', - ( element ) => element.textContent - ); -} - -describe( 'Scheduling', () => { - const isDateTimeComponentFocused = () => { - return page.evaluate( () => { - const dateTimeElement = document.querySelector( - '.components-datetime__date' - ); - if ( ! dateTimeElement || ! document.activeElement ) { - return false; - } - return dateTimeElement.contains( document.activeElement ); - } ); - }; - - describe.each( [ [ 'UTC-10' ], [ 'UTC' ], [ 'UTC+10' ] ] )( - `Timezone %s`, - ( timezone ) => { - let oldTimezone; - beforeEach( async () => { - oldTimezone = await changeSiteTimezone( timezone ); - await createNewPost(); - } ); - afterEach( async () => { - await changeSiteTimezone( oldTimezone ); - } ); - - it( `should change publishing button text from "Publish" to "Schedule"`, async () => { - expect( await getPublishButtonText() ).toBe( 'Publish' ); - - // Open the datepicker. - await page.click( '*[aria-label^="Change date"]' ); - - // Change the publishing date to a year in the future. - await page.click( '.components-datetime__time-field-year' ); - await page.keyboard.press( 'ArrowUp' ); - - // Close the datepicker. - await page.click( '.editor-post-schedule__dialog-toggle' ); - - expect( await getPublishButtonText() ).toBe( 'Schedule…' ); - } ); - } - ); - - it( 'Should keep date time UI focused when the previous and next month buttons are clicked', async () => { - await createNewPost(); - - await page.click( '*[aria-label^="Change date"]' ); - await page.click( '*[aria-label="View previous month"]' ); - expect( await isDateTimeComponentFocused() ).toBe( true ); - await page.click( '*[aria-label="View next month"]' ); - expect( await isDateTimeComponentFocused() ).toBe( true ); - } ); -} ); diff --git a/packages/edit-post/src/components/header/writing-menu/index.js b/packages/edit-post/src/components/header/writing-menu/index.js index 11d07e52ec590d..f0a4dc762ac5c0 100644 --- a/packages/edit-post/src/components/header/writing-menu/index.js +++ b/packages/edit-post/src/components/header/writing-menu/index.js @@ -67,7 +67,7 @@ function WritingMenu() { shortcut={ displayShortcut.primaryShift( '\\' ) } />

+ + + { tabs.map( ( { id, title } ) => ( + + { title } + + ) ) } + + { tabs.map( ( { id } ) => { + let contents; + switch ( id ) { + case 'upload-fonts': + contents = ; + break; + case 'installed-fonts': + contents = ; + break; + default: + contents = ; + } + return ( + + { contents } + + ); + } ) } + +
); } diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/installed-fonts.js b/packages/edit-site/src/components/global-styles/font-library-modal/installed-fonts.js index 2a8d1e591e084f..d493a2a297b18b 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/installed-fonts.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/installed-fonts.js @@ -16,7 +16,7 @@ import { /** * Internal dependencies */ -import TabLayout from './tab-layout'; +import TabPanelLayout from './tab-panel-layout'; import { FontLibraryContext } from './context'; import FontsGrid from './fonts-grid'; import LibraryFontDetails from './library-font-details'; @@ -92,7 +92,7 @@ function InstalledFonts() { }, [ notice ] ); return ( - ) } - + ); } diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/style.scss b/packages/edit-site/src/components/global-styles/font-library-modal/style.scss index 86cac4244dea93..cf7de98d6fbbb1 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/style.scss +++ b/packages/edit-site/src/components/global-styles/font-library-modal/style.scss @@ -24,7 +24,7 @@ } } -.font-library-modal__tab-layout { +.font-library-modal__tabpanel-layout { main { padding-bottom: 4rem; @@ -75,7 +75,7 @@ padding-bottom: 1rem; } -.font-library-modal__tab-panel { +.font-library-modal__tabs { [role="tablist"] { position: sticky; top: 0; diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/tab-layout.js b/packages/edit-site/src/components/global-styles/font-library-modal/tab-panel-layout.js similarity index 85% rename from packages/edit-site/src/components/global-styles/font-library-modal/tab-layout.js rename to packages/edit-site/src/components/global-styles/font-library-modal/tab-panel-layout.js index 07f27cd31ea79c..a7151c6e908d61 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/tab-layout.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/tab-panel-layout.js @@ -11,9 +11,15 @@ import { } from '@wordpress/components'; import { chevronLeft } from '@wordpress/icons'; -function TabLayout( { title, description, handleBack, children, footer } ) { +function TabPanelLayout( { + title, + description, + handleBack, + children, + footer, +} ) { return ( -
+
@@ -47,4 +53,4 @@ function TabLayout( { title, description, handleBack, children, footer } ) { ); } -export default TabLayout; +export default TabPanelLayout; diff --git a/packages/edit-site/src/components/header-edit-mode/more-menu/index.js b/packages/edit-site/src/components/header-edit-mode/more-menu/index.js index 4d892461a48043..4d56cf4670ab91 100644 --- a/packages/edit-site/src/components/header-edit-mode/more-menu/index.js +++ b/packages/edit-site/src/components/header-edit-mode/more-menu/index.js @@ -100,7 +100,7 @@ export default function MoreMenu( { showIconLabels } ) { ) } /> field === 'sync-status' + )?.value; const { patterns, isResolving } = usePatterns( type, isUncategorizedThemePatterns ? '' : categoryId, { search: view.search, - // syncStatus: - // deferredSyncedFilter === 'all' - // ? undefined - // : deferredSyncedFilter, + syncStatus: viewSyncStatus, } ); - const fields = useMemo( - () => [ + const fields = useMemo( () => { + const _fields = [ { header: __( 'Preview' ), id: 'preview', render: ( { item } ) => ( ), - minWidth: 120, - maxWidth: 120, enableSorting: false, enableHiding: false, }, @@ -235,12 +253,36 @@ export default function DataviewsPatterns() { render: ( { item } ) => ( ), - maxWidth: 400, enableHiding: false, }, - ], - [ view.type, categoryId ] - ); + ]; + if ( type === PATTERN_TYPES.theme ) { + _fields.push( { + header: __( 'Sync Status' ), + id: 'sync-status', + render: ( { item } ) => { + // User patterns can have their sync statuses checked directly. + // Non-user patterns are all unsynced for the time being. + return ( + SYNC_FILTERS.find( + ( { value } ) => value === item.syncStatus + )?.label || + SYNC_FILTERS.find( + ( { value } ) => + value === PATTERN_SYNC_TYPES.unsynced + ).label + ); + }, + type: ENUMERATION_TYPE, + elements: SYNC_FILTERS, + filterBy: { + operators: [ OPERATOR_IN ], + }, + enableSorting: false, + } ); + } + return _fields; + }, [ view.type, categoryId, type ] ); // Reset the page number when the category changes. useEffect( () => { if ( previousCategoryId !== categoryId ) { diff --git a/packages/edit-site/src/components/page-patterns/duplicate-menu-item.js b/packages/edit-site/src/components/page-patterns/duplicate-menu-item.js index 118c954a851f3f..e82666902ed16a 100644 --- a/packages/edit-site/src/components/page-patterns/duplicate-menu-item.js +++ b/packages/edit-site/src/components/page-patterns/duplicate-menu-item.js @@ -81,7 +81,7 @@ export default function DuplicateMenuItem( { <DuplicatePatternModal onClose={ closeModal } onSuccess={ onPatternSuccess } - pattern={ isThemePattern ? item : item.patternBlock } + pattern={ isThemePattern ? item : item.patternPost } /> ) } { isModalOpen && isTemplatePart && ( diff --git a/packages/edit-site/src/components/page-patterns/grid-item.js b/packages/edit-site/src/components/page-patterns/grid-item.js index bacb0f31908635..8d2cbaf7806b4d 100644 --- a/packages/edit-site/src/components/page-patterns/grid-item.js +++ b/packages/edit-site/src/components/page-patterns/grid-item.js @@ -114,8 +114,8 @@ function GridItem( { categoryId, item, ...props } ) { const json = { __file: item.type, title: item.title || item.name, - content: item.patternBlock.content.raw, - syncStatus: item.patternBlock.wp_pattern_sync_status, + content: item.patternPost.content.raw, + syncStatus: item.patternPost.wp_pattern_sync_status, }; return downloadBlob( diff --git a/packages/edit-site/src/components/page-patterns/use-patterns.js b/packages/edit-site/src/components/page-patterns/use-patterns.js index be5992bd9b4efe..a0b82247c85a6d 100644 --- a/packages/edit-site/src/components/page-patterns/use-patterns.js +++ b/packages/edit-site/src/components/page-patterns/use-patterns.js @@ -184,29 +184,38 @@ const selectPatterns = createSelector( ] ); -const patternBlockToPattern = ( patternBlock, categories ) => ( { - blocks: parse( patternBlock.content.raw, { +/** + * Converts a post of type `wp_block` to a 'pattern item' that more closely + * matches the structure of theme provided patterns. + * + * @param {Object} patternPost The `wp_block` record being normalized. + * @param {Map} categories A Map of user created categories. + * + * @return {Object} The normalized item. + */ +const convertPatternPostToItem = ( patternPost, categories ) => ( { + blocks: parse( patternPost.content.raw, { __unstableSkipMigrationLogs: true, } ), - ...( patternBlock.wp_pattern_category.length > 0 && { - categories: patternBlock.wp_pattern_category.map( + ...( patternPost.wp_pattern_category.length > 0 && { + categories: patternPost.wp_pattern_category.map( ( patternCategoryId ) => categories && categories.get( patternCategoryId ) ? categories.get( patternCategoryId ).slug : patternCategoryId ), } ), - termLabels: patternBlock.wp_pattern_category.map( ( patternCategoryId ) => + termLabels: patternPost.wp_pattern_category.map( ( patternCategoryId ) => categories?.get( patternCategoryId ) ? categories.get( patternCategoryId ).label : patternCategoryId ), - id: patternBlock.id, - name: patternBlock.slug, - syncStatus: patternBlock.wp_pattern_sync_status || PATTERN_SYNC_TYPES.full, - title: patternBlock.title.raw, - type: PATTERN_TYPES.user, - patternBlock, + id: patternPost.id, + name: patternPost.slug, + syncStatus: patternPost.wp_pattern_sync_status || PATTERN_SYNC_TYPES.full, + title: patternPost.title.raw, + type: patternPost.type, + patternPost, } ); const selectUserPatterns = createSelector( @@ -215,7 +224,7 @@ const selectUserPatterns = createSelector( select( coreStore ); const query = { per_page: -1 }; - const records = getEntityRecords( + const patternPosts = getEntityRecords( 'postType', PATTERN_TYPES.user, query @@ -225,9 +234,9 @@ const selectUserPatterns = createSelector( userPatternCategories.forEach( ( userCategory ) => categories.set( userCategory.id, userCategory ) ); - let patterns = records - ? records.map( ( record ) => - patternBlockToPattern( record, categories ) + let patterns = patternPosts + ? patternPosts.map( ( record ) => + convertPatternPostToItem( record, categories ) ) : EMPTY_PATTERN_LIST; diff --git a/packages/edit-site/src/components/preferences-modal/index.js b/packages/edit-site/src/components/preferences-modal/index.js index f83acb1af5ca90..8f6a8a5794cb9e 100644 --- a/packages/edit-site/src/components/preferences-modal/index.js +++ b/packages/edit-site/src/components/preferences-modal/index.js @@ -107,6 +107,7 @@ export default function EditSitePreferencesModal() { label={ __( 'Distraction free' ) } /> <EnableFeature + scope="core" featureName="focusMode" help={ __( 'Highlights the current block and fades other content.' @@ -128,7 +129,7 @@ export default function EditSitePreferencesModal() { ) } > <EnableFeature - namespace="core" + scope="core" featureName="keepCaretInsideBlock" help={ __( 'Keeps the text cursor within the block boundaries, aiding users with screen readers by preventing unintentional cursor movement outside the block.' @@ -138,7 +139,7 @@ export default function EditSitePreferencesModal() { </PreferencesModalSection> <PreferencesModalSection title={ __( 'Interface' ) }> <EnableFeature - namespace="core" + scope="core" featureName="showIconLabels" label={ __( 'Show button text labels' ) } help={ __( diff --git a/packages/edit-site/src/hooks/commands/use-edit-mode-commands.js b/packages/edit-site/src/hooks/commands/use-edit-mode-commands.js index ea95ab7df7685c..a855ea32e3b406 100644 --- a/packages/edit-site/src/hooks/commands/use-edit-mode-commands.js +++ b/packages/edit-site/src/hooks/commands/use-edit-mode-commands.js @@ -273,7 +273,7 @@ function useEditUICommands() { name: 'core/toggle-spotlight-mode', label: __( 'Toggle spotlight mode' ), callback: ( { close } ) => { - toggle( 'core/edit-site', 'focusMode' ); + toggle( 'core', 'focusMode' ); close(); }, } ); diff --git a/packages/edit-site/src/index.js b/packages/edit-site/src/index.js index 50b95976f778e6..58d5c84f15575e 100644 --- a/packages/edit-site/src/index.js +++ b/packages/edit-site/src/index.js @@ -54,7 +54,6 @@ export function initializeEditor( id, settings ) { dispatch( preferencesStore ).setDefaults( 'core/edit-site', { editorMode: 'visual', fixedToolbar: false, - focusMode: false, distractionFree: false, welcomeGuide: true, welcomeGuideStyles: true, @@ -64,6 +63,7 @@ export function initializeEditor( id, settings ) { dispatch( preferencesStore ).setDefaults( 'core', { allowRightClickOverrides: true, + focusMode: false, keepCaretInsideBlock: false, showBlockBreadcrumbs: true, showListViewByDefault: false, diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index 5ab4ef3edb1b17..e28ba9692dc289 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -46,11 +46,11 @@ const BLOCK_EDITOR_SETTINGS = [ 'enableCustomSpacing', 'enableCustomUnits', 'enableOpenverseMediaCategory', - 'focusMode', 'distractionFree', 'fontSizes', 'gradients', 'generateAnchors', + 'getPostLinkProps', 'hasFixedToolbar', 'hasInlineToolbar', 'isDistractionFree', @@ -75,7 +75,6 @@ const BLOCK_EDITOR_SETTINGS = [ '__unstableIsBlockBasedTheme', '__experimentalArchiveTitleTypeLabel', '__experimentalArchiveTitleNameLabel', - '__experimentalGetPostLinkProps', ]; /** @@ -90,6 +89,7 @@ const BLOCK_EDITOR_SETTINGS = [ function useBlockEditorSettings( settings, postType, postId ) { const { allowRightClickOverrides, + focusMode, keepCaretInsideBlock, reusableBlocks, hasUploadPermissions, @@ -100,7 +100,6 @@ function useBlockEditorSettings( settings, postType, postId ) { userPatternCategories, restBlockPatterns, restBlockPatternCategories, - getPostLinkProps, } = useSelect( ( select ) => { const isWeb = Platform.OS === 'web'; @@ -113,8 +112,6 @@ function useBlockEditorSettings( settings, postType, postId ) { getBlockPatterns, getBlockPatternCategories, } = select( coreStore ); - const { getPostLinkProps: postLinkProps } = - select( editorStore ).getEditorSettings(); const { get } = select( preferencesStore ); const siteSettings = canUser( 'read', 'settings' ) @@ -131,6 +128,7 @@ function useBlockEditorSettings( settings, postType, postId ) { postType, postId )?._links?.hasOwnProperty( 'wp:action-unfiltered-html' ), + focusMode: get( 'core', 'focusMode' ), keepCaretInsideBlock: get( 'core', 'keepCaretInsideBlock' ), reusableBlocks: isWeb ? getEntityRecords( 'postType', 'wp_block', { @@ -144,7 +142,6 @@ function useBlockEditorSettings( settings, postType, postId ) { userPatternCategories: getUserPatternCategories(), restBlockPatterns: getBlockPatterns(), restBlockPatternCategories: getBlockPatternCategories(), - getPostLinkProps: postLinkProps, }; }, [ postType, postId ] @@ -214,6 +211,8 @@ function useBlockEditorSettings( settings, postType, postId ) { [ saveEntityRecord, userCanCreatePages ] ); + const forceDisableFocusMode = settings.focusMode === false; + return useMemo( () => ( { ...Object.fromEntries( @@ -222,6 +221,7 @@ function useBlockEditorSettings( settings, postType, postId ) { ) ), allowRightClickOverrides, + focusMode: focusMode && ! forceDisableFocusMode, keepCaretInsideBlock, mediaUpload: hasUploadPermissions ? mediaUpload : undefined, __experimentalReusableBlocks: reusableBlocks, @@ -253,10 +253,11 @@ function useBlockEditorSettings( settings, postType, postId ) { ? [ [ 'core/navigation', {}, [] ] ] : settings.template, __experimentalSetIsInserterOpened: setIsInserterOpened, - __experimentalGetPostLinkProps: getPostLinkProps, } ), [ allowRightClickOverrides, + focusMode, + forceDisableFocusMode, keepCaretInsideBlock, settings, hasUploadPermissions, @@ -272,7 +273,6 @@ function useBlockEditorSettings( settings, postType, postId ) { pageForPosts, postType, setIsInserterOpened, - getPostLinkProps, ] ); } diff --git a/packages/preferences-persistence/src/migrations/preferences-package-data/convert-editor-settings.js b/packages/preferences-persistence/src/migrations/preferences-package-data/convert-editor-settings.js index 693375af3d79c5..ef522721787d7e 100644 --- a/packages/preferences-persistence/src/migrations/preferences-package-data/convert-editor-settings.js +++ b/packages/preferences-persistence/src/migrations/preferences-package-data/convert-editor-settings.js @@ -6,6 +6,7 @@ export default function convertEditorSettings( data ) { let newData = data; const settingsToMoveToCore = [ 'allowRightClickOverrides', + 'focusMode', 'keepCaretInsideBlock', 'showBlockBreadcrumbs', 'showIconLabels', diff --git a/packages/react-native-aztec/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecText.java b/packages/react-native-aztec/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecText.java index 71df8e0c2888a2..380cdd1c5d6132 100644 --- a/packages/react-native-aztec/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecText.java +++ b/packages/react-native-aztec/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecText.java @@ -1,5 +1,7 @@ package org.wordpress.mobile.ReactNativeAztec; +import static android.content.ClipData.Item; + import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; @@ -10,18 +12,19 @@ import android.graphics.drawable.Drawable; import android.os.Handler; import android.os.Looper; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - import android.text.Editable; import android.text.InputType; import android.text.Spannable; import android.text.TextUtils; import android.text.TextWatcher; import android.view.View; +import android.view.ViewTreeObserver; import android.view.inputmethod.InputMethodManager; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.facebook.infer.annotation.Assertions; import com.facebook.react.bridge.ReactContext; import com.facebook.react.uimanager.ThemedReactContext; @@ -41,12 +44,10 @@ import java.lang.reflect.Field; import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedList; import java.util.Set; -import java.util.HashSet; -import java.util.HashMap; - -import static android.content.ClipData.*; public class ReactAztecText extends AztecText { @@ -64,6 +65,7 @@ public class ReactAztecText extends AztecText { private @Nullable TextWatcherDelegator mTextWatcherDelegator; private @Nullable ContentSizeWatcher mContentSizeWatcher; private @Nullable ScrollWatcher mScrollWatcher; + private @Nullable Runnable mKeyboardRunnable; // FIXME: Used in `incrementAndGetEventCounter` but never read. I guess we can get rid of it, but before this // check when it's used in EditText in RN. (maybe tests?) @@ -264,18 +266,46 @@ public boolean requestFocus(int direction, Rect previouslyFocusedRect) { } private void showSoftKeyboard() { - new Handler(Looper.getMainLooper()).post(new Runnable() { + // If the text input is already focused we can show the keyboard. + if(hasWindowFocus()) { + showSoftKeyboardNow(); + } + // Otherwise, we'll wait until it gets focused. + else { + getViewTreeObserver().addOnWindowFocusChangeListener(new ViewTreeObserver.OnWindowFocusChangeListener() { + @Override + public void onWindowFocusChanged(boolean hasFocus) { + if (hasFocus) { + showSoftKeyboardNow(); + getViewTreeObserver().removeOnWindowFocusChangeListener(this); + } + } + }); + } + } + + private void showSoftKeyboardNow() { + // Cancel any previously scheduled Runnable + if (mKeyboardRunnable != null) { + removeCallbacks(mKeyboardRunnable); + } + + mKeyboardRunnable = new Runnable() { @Override public void run() { if (mInputMethodManager != null) { - mInputMethodManager.showSoftInput(ReactAztecText.this, 0); + mInputMethodManager.showSoftInput(ReactAztecText.this, InputMethodManager.SHOW_IMPLICIT); } } - }); + }; + + post(mKeyboardRunnable); } private void hideSoftKeyboard() { - mInputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0); + if (mInputMethodManager != null) { + mInputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0); + } } public void setScrollWatcher(ScrollWatcher scrollWatcher) { diff --git a/packages/react-native-aztec/package.json b/packages/react-native-aztec/package.json index 4b24bdbc707562..f91b214758b49a 100644 --- a/packages/react-native-aztec/package.json +++ b/packages/react-native-aztec/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-native-aztec", - "version": "1.109.3", + "version": "1.110.0", "description": "Aztec view for react-native.", "private": true, "author": "The WordPress Contributors", diff --git a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/GutenbergProps.kt b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/GutenbergProps.kt index ce427be2ad09b0..ec847d71bf51c9 100644 --- a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/GutenbergProps.kt +++ b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/GutenbergProps.kt @@ -92,7 +92,6 @@ data class GutenbergProps @JvmOverloads constructor( content?.let { putString(PROP_INITIAL_DATA, it) } } - private const val PROP_INITIAL_TITLE = "initialTitle" private const val PROP_INITIAL_HTML_MODE_ENABLED = "initialHtmlModeEnabled" private const val PROP_POST_TYPE = "postType" private const val PROP_HOST_APP_NAMESPACE = "hostAppNamespace" @@ -105,6 +104,7 @@ data class GutenbergProps @JvmOverloads constructor( private const val PROP_QUOTE_BLOCK_V2 = "quoteBlockV2" private const val PROP_LIST_BLOCK_V2 = "listBlockV2" + const val PROP_INITIAL_TITLE = "initialTitle" const val PROP_INITIAL_DATA = "initialData" const val PROP_STYLES = "rawStyles" const val PROP_FEATURES = "rawFeatures" diff --git a/packages/react-native-bridge/package.json b/packages/react-native-bridge/package.json index 586c5159ef165f..276e536dbf929f 100644 --- a/packages/react-native-bridge/package.json +++ b/packages/react-native-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-native-bridge", - "version": "1.109.3", + "version": "1.110.0", "description": "Native bridge library used to integrate the block editor into a native App.", "private": true, "author": "The WordPress Contributors", diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index 23ee8d50a2e73b..ed3f7b5e961eb6 100644 --- a/packages/react-native-editor/CHANGELOG.md +++ b/packages/react-native-editor/CHANGELOG.md @@ -10,6 +10,8 @@ For each user feature we should also add a importance categorization label to i --> ## Unreleased + +## 1.110.0 - [*] [internal] Move InserterButton from components package to block-editor package [#56494] - [*] [internal] Move ImageLinkDestinationsScreen from components package to block-editor package [#56775] - [*] Fix crash when blockType wrapperProps are not defined [#56846] diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-device-actions.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-device-actions.test.js index e7ee4a20df03f2..230c844491d282 100644 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-device-actions.test.js +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-device-actions.test.js @@ -27,6 +27,10 @@ describe( 'Gutenberg Editor Rotation tests', () => { await editorPage.addNewBlock( blockNames.paragraph ); if ( isAndroid() ) { + // Waits until the keyboard is visible + await editorPage.driver.waitUntil( + editorPage.driver.isKeyboardShown + ); await editorPage.dismissKeyboard(); } diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-heading-@canary.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-heading-@canary.test.js deleted file mode 100644 index 50a2a3ee8fd640..00000000000000 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-heading-@canary.test.js +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Internal dependencies - */ -import { blockNames } from './pages/editor-page'; -import testData from './helpers/test-data'; - -describe( 'Gutenberg Editor tests', () => { - it( 'should be able to create a post with heading and paragraph blocks', async () => { - await editorPage.initializeEditor(); - await editorPage.addNewBlock( blockNames.heading ); - let headingBlockElement = await editorPage.getTextBlockAtPosition( - blockNames.heading - ); - - await editorPage.typeTextToTextBlock( - headingBlockElement, - testData.heading - ); - - await editorPage.addNewBlock( blockNames.paragraph ); - let paragraphBlockElement = await editorPage.getTextBlockAtPosition( - blockNames.paragraph, - 2 - ); - await editorPage.typeTextToTextBlock( - paragraphBlockElement, - testData.mediumText - ); - - await editorPage.addNewBlock( blockNames.paragraph ); - paragraphBlockElement = await editorPage.getTextBlockAtPosition( - blockNames.paragraph, - 3 - ); - await editorPage.typeTextToTextBlock( - paragraphBlockElement, - testData.mediumText - ); - - await editorPage.addNewBlock( blockNames.heading ); - headingBlockElement = await editorPage.getTextBlockAtPosition( - blockNames.heading, - 4 - ); - await editorPage.typeTextToTextBlock( - headingBlockElement, - testData.heading - ); - - await editorPage.addNewBlock( blockNames.paragraph ); - paragraphBlockElement = await editorPage.getTextBlockAtPosition( - blockNames.paragraph, - 5 - ); - await editorPage.typeTextToTextBlock( - paragraphBlockElement, - testData.mediumText - ); - - // Assert that even though there are 5 blocks, there should only be 3 paragraph blocks - expect( await editorPage.getNumberOfParagraphBlocks() ).toEqual( 3 ); - } ); -} ); diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-paragraph.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-paragraph.test.js deleted file mode 100644 index 8f21ef04858fb6..00000000000000 --- a/packages/react-native-editor/__device-tests__/gutenberg-editor-paragraph.test.js +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Internal dependencies - */ -import { blockNames } from './pages/editor-page'; -import { - backspace, - clickMiddleOfElement, - clickBeginningOfElement, -} from './helpers/utils'; -import testData from './helpers/test-data'; - -describe( 'Gutenberg Editor tests for Paragraph Block', () => { - it( 'should be able to split one paragraph block into two', async () => { - await editorPage.initializeEditor(); - await editorPage.addNewBlock( blockNames.paragraph ); - const paragraphBlockElement = await editorPage.getTextBlockAtPosition( - blockNames.paragraph - ); - await editorPage.typeTextToTextBlock( - paragraphBlockElement, - testData.shortText - ); - await clickMiddleOfElement( editorPage.driver, paragraphBlockElement ); - await editorPage.typeTextToTextBlock( - paragraphBlockElement, - '\n', - false - ); - const text0 = await editorPage.getTextForParagraphBlockAtPosition( 1 ); - const text1 = await editorPage.getTextForParagraphBlockAtPosition( 2 ); - expect( await editorPage.getNumberOfParagraphBlocks() ).toEqual( 2 ); - expect( text0 ).not.toBe( '' ); - expect( text1 ).not.toBe( '' ); - expect( testData.shortText ).toMatch( - new RegExp( `${ text0 + text1 }|${ text0 } ${ text1 }` ) - ); - } ); - - it( 'should be able to merge 2 paragraph blocks into 1', async () => { - await editorPage.initializeEditor(); - await editorPage.addNewBlock( blockNames.paragraph ); - let paragraphBlockElement = await editorPage.getTextBlockAtPosition( - blockNames.paragraph - ); - - await editorPage.typeTextToTextBlock( - paragraphBlockElement, - testData.shortText - ); - await clickMiddleOfElement( editorPage.driver, paragraphBlockElement ); - await editorPage.typeTextToTextBlock( paragraphBlockElement, '\n' ); - - const text0 = await editorPage.getTextForParagraphBlockAtPosition( 1 ); - const text1 = await editorPage.getTextForParagraphBlockAtPosition( 2 ); - expect( await editorPage.getNumberOfParagraphBlocks() ).toEqual( 2 ); - paragraphBlockElement = await editorPage.getTextBlockAtPosition( - blockNames.paragraph, - 2 - ); - - await clickBeginningOfElement( - editorPage.driver, - paragraphBlockElement - ); - - await editorPage.typeTextToTextBlock( - paragraphBlockElement, - backspace - ); - - const text = await editorPage.getTextForParagraphBlockAtPosition( 1 ); - expect( text0 + text1 ).toMatch( text ); - paragraphBlockElement = await editorPage.getTextBlockAtPosition( - blockNames.paragraph, - 1 - ); - await paragraphBlockElement.click(); - expect( await editorPage.getNumberOfParagraphBlocks() ).toEqual( 1 ); - } ); - - it( 'should be able to create a post with multiple paragraph blocks', async () => { - await editorPage.initializeEditor(); - await editorPage.addNewBlock( blockNames.paragraph ); - await editorPage.sendTextToParagraphBlock( 1, testData.longText ); - expect( await editorPage.getNumberOfParagraphBlocks() ).toEqual( 3 ); - } ); - - it( 'should be able to merge blocks with unknown html elements', async () => { - await editorPage.initializeEditor( { - initialData: [ - testData.unknownElementParagraphBlock, - testData.lettersInParagraphBlock, - ].join( '\n\n' ), - } ); - - // Merge paragraphs. - const paragraphBlockElement = await editorPage.getTextBlockAtPosition( - blockNames.paragraph, - 2 - ); - - const text0 = await editorPage.getTextForParagraphBlockAtPosition( 1 ); - const text1 = await editorPage.getTextForParagraphBlockAtPosition( 2 ); - - await clickBeginningOfElement( - editorPage.driver, - paragraphBlockElement - ); - await editorPage.typeTextToTextBlock( - paragraphBlockElement, - backspace - ); - - // Verify the editor has not crashed. - const mergedBlockText = - await editorPage.getTextForParagraphBlockAtPosition( 1 ); - expect( text0 + text1 ).toMatch( mergedBlockText ); - } ); - - // Based on https://github.com/wordpress-mobile/gutenberg-mobile/pull/1507 - it( 'should handle multiline paragraphs from web', async () => { - await editorPage.initializeEditor( { - initialData: [ - testData.multiLinesParagraphBlock, - testData.paragraphBlockEmpty, - ].join( '\n\n' ), - } ); - - // Merge paragraphs. - const paragraphBlockElement = await editorPage.getTextBlockAtPosition( - blockNames.paragraph, - 2 - ); - await editorPage.typeTextToTextBlock( - paragraphBlockElement, - backspace - ); - - // Verify the editor has not crashed. - const text = await editorPage.getTextForParagraphBlockAtPosition( 1 ); - expect( text.length ).not.toEqual( 0 ); - } ); -} ); diff --git a/packages/react-native-editor/__device-tests__/gutenberg-editor-writing-flow-@canary.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-writing-flow-@canary.test.js new file mode 100644 index 00000000000000..3a12bf5d13345b --- /dev/null +++ b/packages/react-native-editor/__device-tests__/gutenberg-editor-writing-flow-@canary.test.js @@ -0,0 +1,333 @@ +/** + * Internal dependencies + */ +import { blockNames } from './pages/editor-page'; +import { + backspace, + clickBeginningOfElement, + waitForMediaLibrary, +} from './helpers/utils'; +import testData from './helpers/test-data'; + +describe( 'Gutenberg Editor Writing flow tests', () => { + it( 'should be able to write a post title', async () => { + await editorPage.initializeEditor( { initialTitle: '' } ); + + const titleInput = await editorPage.getEmptyTitleTextInputElement(); + expect( await editorPage.driver.isKeyboardShown() ).toBe( true ); + await editorPage.typeTextToTextBlock( titleInput, testData.shortText ); + + // Trigger the return key to go to the first Paragraph + await editorPage.typeTextToTextBlock( titleInput, '\n' ); + + const paragraphBlockElement = await editorPage.getTextBlockAtPosition( + blockNames.paragraph + ); + expect( paragraphBlockElement ).toBeTruthy(); + expect( await editorPage.driver.isKeyboardShown() ).toBe( true ); + + // Trigger the return key to delete the Paragraph block + await editorPage.typeTextToTextBlock( + paragraphBlockElement, + backspace + ); + // Expect to have an empty Paragraph block and the keyboard visible + expect( + await editorPage.getTextBlockAtPosition( blockNames.paragraph ) + ).toBeTruthy(); + expect( await editorPage.driver.isKeyboardShown() ).toBe( true ); + } ); + + it( 'should be able to create a new Paragraph block when pressing the enter key', async () => { + await editorPage.initializeEditor( { initialTitle: '' } ); + + const defaultBlockAppenderElement = + await editorPage.getDefaultBlockAppenderElement(); + await defaultBlockAppenderElement.click(); + + const paragraphBlockElement = await editorPage.getTextBlockAtPosition( + blockNames.paragraph + ); + expect( await editorPage.driver.isKeyboardShown() ).toBe( true ); + + await editorPage.typeTextToTextBlock( + paragraphBlockElement, + testData.shortText + ); + await editorPage.typeTextToTextBlock( paragraphBlockElement, '\n' ); + + // Expect to have a new Paragraph block and the keyboard visible + expect( + await editorPage.getTextBlockAtPosition( blockNames.paragraph, 2 ) + ).toBeTruthy(); + expect( await editorPage.driver.isKeyboardShown() ).toBe( true ); + } ); + + it( 'should automatically dismiss the keyboard when selecting non-text-based-blocks', async () => { + await editorPage.initializeEditor( { initialTitle: '' } ); + + await editorPage.addNewBlock( blockNames.image ); + // Wait for the Media picker to show up + await waitForMediaLibrary( editorPage.driver ); + + // Select the WordPress Media Library option + await editorPage.chooseMediaLibrary(); + + // Wait until the media is added + await editorPage.driver.pause( 500 ); + + const captionElement = await editorPage.getImageBlockCaptionButton(); + await captionElement.click(); + const captionInput = + await editorPage.getImageBlockCaptionInput( captionElement ); + + expect( captionInput ).toBeTruthy(); + expect( await editorPage.driver.isKeyboardShown() ).toBe( true ); + + // Sets a new caption + await editorPage.typeTextToTextBlock( + captionInput, + testData.listItem2, + true + ); + + // Trigger the return key to exit the caption and create a new Paragraph block + await editorPage.typeTextToTextBlock( captionInput, '\n' ); + + // Expect to have an empty Paragraph block and the keyboard visible + let paragraphBlockElement = await editorPage.getTextBlockAtPosition( + blockNames.paragraph, + 2 + ); + expect( paragraphBlockElement ).toBeTruthy(); + expect( await editorPage.driver.isKeyboardShown() ).toBe( true ); + await editorPage.typeTextToTextBlock( + paragraphBlockElement, + backspace + ); + + // When deleting the Paragraph block, the keyboard should be hidden and + // the image block should be focused. + await editorPage.driver.waitUntil( async function () { + return ! ( await editorPage.driver.isKeyboardShown() ); + } ); + expect( await editorPage.isImageBlockSelected() ).toBe( true ); + + // Adding a new Paragraph block + await editorPage.addNewBlock( blockNames.paragraph ); + paragraphBlockElement = await editorPage.getTextBlockAtPosition( + blockNames.paragraph, + 2 + ); + + // It should be focused and the keyboard should be visible + expect( paragraphBlockElement ).toBeTruthy(); + expect( await editorPage.driver.isKeyboardShown() ).toBe( true ); + + await editorPage.typeTextToTextBlock( + paragraphBlockElement, + testData.shortText + ); + + const imageBlockElement = await editorPage.getBlockAtPosition( + blockNames.image + ); + await imageBlockElement.click(); + + await editorPage.driver.waitUntil( async function () { + return ! ( await editorPage.driver.isKeyboardShown() ); + } ); + expect( await editorPage.isImageBlockSelected() ).toBe( true ); + } ); + + it( 'should manually dismiss the keyboard', async () => { + await editorPage.initializeEditor( { initialTitle: '' } ); + + const defaultBlockAppenderElement = + await editorPage.getDefaultBlockAppenderElement(); + await defaultBlockAppenderElement.click(); + + const paragraphBlockElement = await editorPage.getTextBlockAtPosition( + blockNames.paragraph + ); + expect( paragraphBlockElement ).toBeTruthy(); + expect( await editorPage.driver.isKeyboardShown() ).toBe( true ); + + await editorPage.dismissKeyboard(); + + // Checks that no block is selected by looking for the block menu actions button + expect( await editorPage.isBlockActionsMenuButtonDisplayed() ).toBe( + false + ); + expect( await editorPage.driver.isKeyboardShown() ).toBe( false ); + } ); + + it( 'should dismiss the keyboard and show it back when opening modals', async () => { + await editorPage.initializeEditor( { initialTitle: '' } ); + + const defaultBlockAppenderElement = + await editorPage.getDefaultBlockAppenderElement(); + await defaultBlockAppenderElement.click(); + + await editorPage.openBlockSettings(); + await editorPage.driver.waitUntil( async function () { + return ! ( await editorPage.driver.isKeyboardShown() ); + } ); + + await editorPage.dismissBottomSheet(); + + await editorPage.driver.waitUntil( editorPage.driver.isKeyboardShown ); + const paragraphBlockElement = await editorPage.getTextBlockAtPosition( + blockNames.paragraph + ); + await editorPage.typeTextToTextBlock( + paragraphBlockElement, + testData.listItem1 + ); + const typedText = await paragraphBlockElement.getText(); + expect( typedText ).toMatch( testData.listItem1 ); + } ); + + it( 'should be able to split and merge paragraph blocks', async () => { + await editorPage.initializeEditor(); + + // Add the first Paragraph block using the default block appender + const defaultBlockAppenderElement = + await editorPage.getDefaultBlockAppenderElement(); + await defaultBlockAppenderElement.click(); + + // Type text into the first Paragraph block + const firstParagraphBlockElement = + await editorPage.getTextBlockAtPosition( blockNames.paragraph ); + await editorPage.typeTextToTextBlock( + firstParagraphBlockElement, + testData.shortText + ); + + // Add a second Paragraph block and type some text + await editorPage.addParagraphBlockByTappingEmptyAreaBelowLastBlock(); + expect( await editorPage.driver.isKeyboardShown() ).toBe( true ); + const secondParagraphBlockElement = + await editorPage.getTextBlockAtPosition( blockNames.paragraph, 2 ); + await editorPage.typeTextToTextBlock( + secondParagraphBlockElement, + testData.mediumText + ); + + // Merge Paragraph blocks + await clickBeginningOfElement( + editorPage.driver, + secondParagraphBlockElement + ); + await editorPage.typeTextToTextBlock( + secondParagraphBlockElement, + backspace + ); + + // Wait for blocks to be merged + await editorPage.driver.waitUntil( async function () { + return ( await editorPage.getNumberOfParagraphBlocks() ) === 1; + } ); + expect( await editorPage.driver.isKeyboardShown() ).toBe( true ); + + // Split the current Paragraph block right where the caret is positioned + const paragraphBlockElement = await editorPage.getTextBlockAtPosition( + blockNames.paragraph, + 1, + true + ); + await editorPage.typeTextToTextBlock( paragraphBlockElement, '\n' ); + + // Wait for blocks to be split + await editorPage.driver.waitUntil( async function () { + return ( await editorPage.getNumberOfParagraphBlocks() ) === 2; + } ); + expect( await editorPage.driver.isKeyboardShown() ).toBe( true ); + + const firstParagraphText = + await editorPage.getTextForParagraphBlockAtPosition( 1 ); + const secondParagraphText = + await editorPage.getTextForParagraphBlockAtPosition( 2 ); + + expect( firstParagraphText ).toEqual( testData.shortText ); + expect( secondParagraphText ).toEqual( testData.mediumText ); + } ); + + it( 'should be able to create a post with multiple paragraph blocks', async () => { + await editorPage.initializeEditor(); + const defaultBlockAppenderElement = + await editorPage.getDefaultBlockAppenderElement(); + await defaultBlockAppenderElement.click(); + + await editorPage.sendTextToParagraphBlock( 1, testData.longText ); + expect( await editorPage.getNumberOfParagraphBlocks() ).toEqual( 3 ); + } ); + + it( 'should be able to merge blocks with unknown html elements', async () => { + await editorPage.initializeEditor( { + initialData: [ + testData.unknownElementParagraphBlock, + testData.lettersInParagraphBlock, + ].join( '\n\n' ), + } ); + + // Merge paragraphs. + const paragraphBlockElement = await editorPage.getTextBlockAtPosition( + blockNames.paragraph, + 2 + ); + + const text0 = await editorPage.getTextForParagraphBlockAtPosition( 1 ); + const text1 = await editorPage.getTextForParagraphBlockAtPosition( 2 ); + + await clickBeginningOfElement( + editorPage.driver, + paragraphBlockElement + ); + await editorPage.typeTextToTextBlock( + paragraphBlockElement, + backspace + ); + + // Verify the editor has not crashed. + const mergedBlockText = + await editorPage.getTextForParagraphBlockAtPosition( 1 ); + expect( text0 + text1 ).toMatch( mergedBlockText ); + } ); + + it( 'should be able to create a post with heading and paragraph blocks', async () => { + await editorPage.initializeEditor(); + await editorPage.addNewBlock( blockNames.heading ); + const headingBlockElement = await editorPage.getTextBlockAtPosition( + blockNames.heading + ); + + await editorPage.typeTextToTextBlock( + headingBlockElement, + testData.heading + ); + + await editorPage.addParagraphBlockByTappingEmptyAreaBelowLastBlock(); + let paragraphBlockElement = await editorPage.getTextBlockAtPosition( + blockNames.paragraph, + 2 + ); + await editorPage.typeTextToTextBlock( + paragraphBlockElement, + testData.mediumText + ); + + await editorPage.addParagraphBlockByTappingEmptyAreaBelowLastBlock(); + paragraphBlockElement = await editorPage.getTextBlockAtPosition( + blockNames.paragraph, + 3 + ); + await editorPage.typeTextToTextBlock( + paragraphBlockElement, + testData.mediumText + ); + + // Assert that even though there are 3 blocks, there should only be 2 paragraph blocks + expect( await editorPage.getNumberOfParagraphBlocks() ).toEqual( 2 ); + } ); +} ); diff --git a/packages/react-native-editor/__device-tests__/pages/editor-page.js b/packages/react-native-editor/__device-tests__/pages/editor-page.js index a19aaf5445d79f..b00be20458e802 100644 --- a/packages/react-native-editor/__device-tests__/pages/editor-page.js +++ b/packages/react-native-editor/__device-tests__/pages/editor-page.js @@ -45,8 +45,18 @@ class EditorPage { } } - async initializeEditor( { initialData, rawStyles, rawFeatures } = {} ) { - await launchApp( this.driver, { initialData, rawStyles, rawFeatures } ); + async initializeEditor( { + initialTitle, + initialData, + rawStyles, + rawFeatures, + } = {} ) { + await launchApp( this.driver, { + initialTitle, + initialData, + rawStyles, + rawFeatures, + } ); // Stores initial values from the editor for different helpers. const addButton = await this.driver.$$( `~${ ADD_BLOCK_ID }` ); @@ -72,9 +82,13 @@ class EditorPage { // Text blocks functions // E.g. Paragraph, Heading blocks // =============================== - async getTextBlockAtPosition( blockName, position = 1 ) { + async getTextBlockAtPosition( + blockName, + position = 1, + skipWrapperClick = false + ) { // iOS needs a click to get the text element - if ( ! isAndroid() ) { + if ( ! isAndroid() && ! skipWrapperClick ) { const textBlockLocator = `(//XCUIElementTypeButton[contains(@name, "${ blockName } Block. Row ${ position }")])`; await clickIfClickable( this.driver, textBlockLocator ); @@ -171,14 +185,25 @@ class EditorPage { } async addParagraphBlockByTappingEmptyAreaBelowLastBlock() { - const emptyAreaBelowLastBlock = - await this.driver.elementByAccessibilityId( 'Add paragraph block' ); + const element = isAndroid() + ? '~Add paragraph block' + : '(//XCUIElementTypeOther[@name="Add paragraph block"])'; + const emptyAreaBelowLastBlock = await this.driver.$( element ); await emptyAreaBelowLastBlock.click(); } - async getTitleElement( options = { autoscroll: false } ) { + async getDefaultBlockAppenderElement() { + const appenderElement = isAndroid() + ? `//android.widget.EditText[@text='Start writing…']` + : '(//XCUIElementTypeOther[contains(@name, "Start writing…")])[2]'; + return this.driver.$( appenderElement ); + } + + async getTitleElement( options = { autoscroll: false, isEmpty: false } ) { const titleElement = isAndroid() - ? 'Post title. Welcome to Gutenberg!' + ? `Post title. ${ + options.isEmpty ? 'Empty' : 'Welcome to Gutenberg!' + }` : 'post-title'; if ( options.autoscroll ) { @@ -200,6 +225,18 @@ class EditorPage { return elements[ 0 ]; } + async getEmptyTitleTextInputElement() { + const titleWrapperElement = await this.getTitleElement( { + isEmpty: true, + } ); + await titleWrapperElement.click(); + + const titleElement = isAndroid() + ? '//android.widget.EditText[@content-desc="Post title. Empty"]' + : '~Add title'; + return this.driver.$( titleElement ); + } + // iOS loads the block list more eagerly compared to Android. // This makes this function return elements without scrolling on iOS. // So we are keeping this Android only. @@ -370,10 +407,14 @@ class EditorPage { await settingsButton.click(); } - async removeBlock() { - const blockActionsButtonElement = isAndroid() + getBlockActionsMenuElement() { + return isAndroid() ? '//android.widget.Button[contains(@content-desc, "Open Block Actions Menu")]' : '//XCUIElementTypeButton[@name="Open Block Actions Menu"]'; + } + + async removeBlock() { + const blockActionsButtonElement = this.getBlockActionsMenuElement(); const blockActionsMenu = await this.swipeToolbarToElement( blockActionsButtonElement ); @@ -391,6 +432,12 @@ class EditorPage { return await swipeDown( this.driver ); } + async isBlockActionsMenuButtonDisplayed() { + const menuButtonElement = this.getBlockActionsMenuElement(); + const elementsFound = await this.driver.$$( menuButtonElement ); + return elementsFound.length !== 0; + } + // ========================= // Block toolbar functions // ========================= @@ -406,8 +453,6 @@ class EditorPage { swipeRight: true, } ); await addButton[ 0 ].click(); - // Wait for Bottom sheet animation to finish - await this.driver.pause( 3000 ); } // Click on block of choice. @@ -425,10 +470,9 @@ class EditorPage { const inserterElement = isAndroid() ? 'Blocks menu' : 'InserterUI-Blocks'; - return await this.waitForElementToBeDisplayedById( - inserterElement, - 4000 - ); + await this.driver + .$( `~${ inserterElement }` ) + .waitForDisplayed( { timeout: 4000 } ); } static async isElementOutOfBounds( element, { width, height } = {} ) { @@ -787,13 +831,25 @@ class EditorPage { await clickIfClickable( this.driver, mediaLibraryLocator ); } + async getImageBlockCaptionButton() { + const captionElement = isAndroid() + ? '//android.widget.Button[starts-with(@content-desc, "Image caption")]' + : '//XCUIElementTypeButton[starts-with(@name, "Image caption.")]'; + return this.driver.$( captionElement ); + } + + async getImageBlockCaptionInput( imageBlockCaptionButton ) { + const captionInputElement = isAndroid() + ? '//android.widget.EditText' + : '//XCUIElementTypeTextView'; + return imageBlockCaptionButton.$( captionInputElement ); + } + async enterCaptionToSelectedImageBlock( caption, clear = true ) { - const imageBlockCaptionButton = await this.driver.$( - '//XCUIElementTypeButton[starts-with(@name, "Image caption.")]' - ); + const imageBlockCaptionButton = await this.getImageBlockCaptionButton(); await imageBlockCaptionButton.click(); - const imageBlockCaptionField = await imageBlockCaptionButton.$( - '//XCUIElementTypeTextView' + const imageBlockCaptionField = await this.getImageBlockCaptionInput( + imageBlockCaptionButton ); await typeString( this.driver, imageBlockCaptionField, caption, clear ); } @@ -814,6 +870,16 @@ class EditorPage { .perform(); } + async isImageBlockSelected() { + // Since there isn't an easy way to see if a block is selected, + // it will check if the edit image button is visible + const editImageElement = isAndroid() + ? '(//android.widget.Button[@content-desc="Edit image"])' + : '(//XCUIElementTypeButton[@name="Edit image"])'; + + return await this.driver.$( editImageElement ).isDisplayed(); + } + // ============================= // Search Block functions // ============================= diff --git a/packages/react-native-editor/android/app/src/main/java/com/gutenberg/MainActivity.java b/packages/react-native-editor/android/app/src/main/java/com/gutenberg/MainActivity.java index 69985317ddad33..3ea19fa97b3831 100644 --- a/packages/react-native-editor/android/app/src/main/java/com/gutenberg/MainActivity.java +++ b/packages/react-native-editor/android/app/src/main/java/com/gutenberg/MainActivity.java @@ -113,6 +113,8 @@ protected void onCreate(Bundle savedInstanceState) { LinearLayout linearLayout = new LinearLayout(this); linearLayout.setOrientation(LinearLayout.VERTICAL); linearLayout.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + linearLayout.setFocusable(false); + linearLayout.setFocusableInTouchMode(true); // Create a Toolbar instance Toolbar toolbar = new Toolbar(this); @@ -166,6 +168,7 @@ private Bundle getAppOptions() { Bundle bundle = new Bundle(); // Parse initial props from launch arguments + String initialTitle = null; String initialData = null; String rawStyles = null; String rawFeatures = null; @@ -175,6 +178,9 @@ private Bundle getAppOptions() { String initialProps = extrasBundle.getString(EXTRAS_INITIAL_PROPS, "{}"); try { JSONObject jsonObject = new JSONObject(initialProps); + if (jsonObject.has(GutenbergProps.PROP_INITIAL_TITLE)) { + initialTitle = jsonObject.getString(GutenbergProps.PROP_INITIAL_TITLE); + } if (jsonObject.has(GutenbergProps.PROP_INITIAL_DATA)) { initialData = jsonObject.getString(GutenbergProps.PROP_INITIAL_DATA); } @@ -209,6 +215,9 @@ private Bundle getAppOptions() { capabilities.putBoolean(GutenbergProps.PROP_CAPABILITIES_SMARTFRAME_EMBED_BLOCK, true); bundle.putBundle(GutenbergProps.PROP_CAPABILITIES, capabilities); + if(initialTitle != null) { + bundle.putString(GutenbergProps.PROP_INITIAL_TITLE, initialTitle); + } if(initialData != null) { bundle.putString(GutenbergProps.PROP_INITIAL_DATA, initialData); } diff --git a/packages/react-native-editor/ios/GutenbergDemo/GutenbergViewController.swift b/packages/react-native-editor/ios/GutenbergDemo/GutenbergViewController.swift index ef95c7e65862f6..0c04308125df71 100644 --- a/packages/react-native-editor/ios/GutenbergDemo/GutenbergViewController.swift +++ b/packages/react-native-editor/ios/GutenbergDemo/GutenbergViewController.swift @@ -399,7 +399,10 @@ extension GutenbergViewController: GutenbergBridgeDataSource { } func gutenbergInitialTitle() -> String? { - return nil + guard isUITesting(), let initialProps = getInitialPropsFromArgs() else { + return nil + } + return initialProps["initialTitle"] } func gutenbergHostAppNamespace() -> String { diff --git a/packages/react-native-editor/ios/Podfile.lock b/packages/react-native-editor/ios/Podfile.lock index f4eaa1c15bc1f3..cf40d621834bda 100644 --- a/packages/react-native-editor/ios/Podfile.lock +++ b/packages/react-native-editor/ios/Podfile.lock @@ -13,7 +13,7 @@ PODS: - ReactCommon/turbomodule/core (= 0.71.11) - fmt (6.2.1) - glog (0.3.5) - - Gutenberg (1.109.3): + - Gutenberg (1.110.0): - React-Core (= 0.71.11) - React-CoreModules (= 0.71.11) - React-RCTImage (= 0.71.11) @@ -429,7 +429,7 @@ PODS: - React-RCTImage - RNSVG (13.9.0): - React-Core - - RNTAztecView (1.109.3): + - RNTAztecView (1.110.0): - React-Core - WordPress-Aztec-iOS (= 1.19.9) - SDWebImage (5.11.1): @@ -617,7 +617,7 @@ SPEC CHECKSUMS: FBReactNativeSpec: f07662560742d82a5b73cee116c70b0b49bcc220 fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b - Gutenberg: 74c7183474e117f4ffaae5eac944cf598a383095 + Gutenberg: 758124df95be2159a16909fcf00e289b9299fa39 hermes-engine: 34c863b446d0135b85a6536fa5fd89f48196f848 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 libwebp: 60305b2e989864154bd9be3d772730f08fc6a59c @@ -662,7 +662,7 @@ SPEC CHECKSUMS: RNReanimated: d4f363f4987ae0ade3e36ff81c94e68261bf4b8d RNScreens: 68fd1060f57dd1023880bf4c05d74784b5392789 RNSVG: 53c661b76829783cdaf9b7a57258f3d3b4c28315 - RNTAztecView: fd32ea370f13d9edd7f43b65b6270ae499757d69 + RNTAztecView: 75ea6f071cbdd0f0afe83de7b93c0691a2bebd21 SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d WordPress-Aztec-iOS: fbebd569c61baa252b3f5058c0a2a9a6ada686bb diff --git a/packages/react-native-editor/package.json b/packages/react-native-editor/package.json index 90f99b36a0b0a8..0a8ceed231ae4f 100644 --- a/packages/react-native-editor/package.json +++ b/packages/react-native-editor/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-native-editor", - "version": "1.109.3", + "version": "1.110.0", "description": "Mobile WordPress gutenberg editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/rich-text/src/create.js b/packages/rich-text/src/create.js index a35fabbd4e2fad..7c0989a11dc4a6 100644 --- a/packages/rich-text/src/create.js +++ b/packages/rich-text/src/create.js @@ -178,6 +178,19 @@ export class RichTextData { } } +for ( const name of Object.getOwnPropertyNames( String.prototype ) ) { + if ( RichTextData.prototype.hasOwnProperty( name ) ) { + continue; + } + + Object.defineProperty( RichTextData.prototype, name, { + value( ...args ) { + // Should we convert back to RichTextData? + return this.toHTMLString()[ name ]( ...args ); + }, + } ); +} + /** * Create a RichText value from an `Element` tree (DOM), an HTML string or a * plain text string, with optionally a `Range` object to set the selection. If diff --git a/phpunit/block-supports/background-test.php b/phpunit/block-supports/background-test.php index 9fa350ef36c4e0..92e1d2fc345a0e 100644 --- a/phpunit/block-supports/background-test.php +++ b/phpunit/block-supports/background-test.php @@ -134,7 +134,7 @@ public function data_background_block_support() { 'source' => 'file', ), ), - 'expected_wrapper' => '<div style="background-image:url('https://example.com/image.jpg');background-size:cover;">Content</div>', + 'expected_wrapper' => '<div class="has-background" style="background-image:url('https://example.com/image.jpg');background-size:cover;">Content</div>', 'wrapper' => '<div>Content</div>', ), 'background image style with contain, position, and repeat is applied' => array( @@ -151,7 +151,7 @@ public function data_background_block_support() { 'backgroundRepeat' => 'no-repeat', 'backgroundSize' => 'contain', ), - 'expected_wrapper' => '<div style="background-image:url('https://example.com/image.jpg');background-position:center;background-repeat:no-repeat;background-size:contain;">Content</div>', + 'expected_wrapper' => '<div class="has-background" style="background-image:url('https://example.com/image.jpg');background-position:center;background-repeat:no-repeat;background-size:contain;">Content</div>', 'wrapper' => '<div>Content</div>', ), 'background image style is appended if a style attribute already exists' => array( @@ -166,8 +166,8 @@ public function data_background_block_support() { 'source' => 'file', ), ), - 'expected_wrapper' => '<div classname="wp-block-test" style="color: red;background-image:url('https://example.com/image.jpg');background-size:cover;">Content</div>', - 'wrapper' => '<div classname="wp-block-test" style="color: red">Content</div>', + 'expected_wrapper' => '<div class="wp-block-test has-background" style="color: red;background-image:url('https://example.com/image.jpg');background-size:cover;">Content</div>', + 'wrapper' => '<div class="wp-block-test" style="color: red">Content</div>', ), 'background image style is appended if a style attribute containing multiple styles already exists' => array( 'theme_name' => 'block-theme-child-with-fluid-typography', @@ -181,8 +181,8 @@ public function data_background_block_support() { 'source' => 'file', ), ), - 'expected_wrapper' => '<div classname="wp-block-test" style="color: red;font-size: 15px;background-image:url('https://example.com/image.jpg');background-size:cover;">Content</div>', - 'wrapper' => '<div classname="wp-block-test" style="color: red;font-size: 15px;">Content</div>', + 'expected_wrapper' => '<div class="wp-block-test has-background" style="color: red;font-size: 15px;background-image:url('https://example.com/image.jpg');background-size:cover;">Content</div>', + 'wrapper' => '<div class="wp-block-test" style="color: red;font-size: 15px;">Content</div>', ), 'background image style is not applied if the block does not support background image' => array( 'theme_name' => 'block-theme-child-with-fluid-typography', diff --git a/test/e2e/specs/editor/various/block-locking.spec.js b/test/e2e/specs/editor/various/block-locking.spec.js index b40e7a4b7448a8..eafb468902ef92 100644 --- a/test/e2e/specs/editor/various/block-locking.spec.js +++ b/test/e2e/specs/editor/various/block-locking.spec.js @@ -82,6 +82,12 @@ test.describe( 'Block Locking', () => { await page.click( 'role=checkbox[name="Lock all"]' ); await page.click( 'role=button[name="Apply"]' ); + await expect( + page + .getByRole( 'toolbar', { name: 'Block tools' } ) + .getByRole( 'button', { name: 'Lock' } ) + ).toBeFocused(); + expect( await editor.getEditedPostContent() ) .toBe( `<!-- wp:paragraph {"lock":{"move":false,"remove":false}} --> <p>Some paragraph</p> diff --git a/test/e2e/specs/editor/various/datepicker.spec.js b/test/e2e/specs/editor/various/datepicker.spec.js new file mode 100644 index 00000000000000..00030efa1fe274 --- /dev/null +++ b/test/e2e/specs/editor/various/datepicker.spec.js @@ -0,0 +1,114 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +// Set browser to a timezone that's different to `timezone`. +test.use( { + timezoneId: 'America/New_York', +} ); + +// The `timezone` setting exposed via REST API only accepts `UTC` +// and timezone strings by location. +const TIMEZONES = [ 'Pacific/Honolulu', 'UTC', 'Australia/Sydney' ]; + +TIMEZONES.forEach( ( timezone ) => { + test.describe( `Datepicker: ${ timezone }`, () => { + let orignalTimezone; + test.beforeAll( async ( { requestUtils } ) => { + orignalTimezone = ( await requestUtils.getSiteSettings() ).timezone; + await requestUtils.updateSiteSettings( { timezone } ); + } ); + + test.beforeEach( async ( { admin, editor } ) => { + await admin.createNewPost(); + await editor.openDocumentSettingsSidebar(); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.updateSiteSettings( { + timezone: orignalTimezone, + } ); + } ); + + test( 'should show the publishing date as "Immediately" if the date is not altered', async ( { + page, + } ) => { + await expect( + page.getByRole( 'button', { name: 'Change date' } ) + ).toHaveText( 'Immediately' ); + } ); + + test( 'should show the publishing date if the date is in the past', async ( { + page, + } ) => { + const datepicker = page.getByRole( 'button', { + name: 'Change date', + } ); + await datepicker.click(); + + // Change the publishing date to a year in the future. + await page + .getByRole( 'group', { name: 'Date' } ) + .getByRole( 'spinbutton', { name: 'Year' } ) + .click(); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'Escape' ); + + // The expected date format will be "Sep 26, 2018 11:52 pm". + await expect( + page.getByRole( 'button', { name: 'Change date' } ) + ).toContainText( /^[A-Za-z]+\s\d{1,2},\s\d{1,4}/ ); + } ); + + test( 'should show the publishing date if the date is in the future', async ( { + page, + } ) => { + const datepicker = page.getByRole( 'button', { + name: 'Change date', + } ); + await datepicker.click(); + + // Change the publishing date to a year in the future. + await page + .getByRole( 'group', { name: 'Date' } ) + .getByRole( 'spinbutton', { name: 'Year' } ) + .click(); + await page.keyboard.press( 'ArrowUp' ); + await page.keyboard.press( 'Escape' ); + + // The expected date format will be "Sep 26, 2018 11:52 pm". + await expect( + page.getByRole( 'button', { name: 'Change date' } ) + ).toContainText( /^[A-Za-z]+\s\d{1,2},\s\d{1,4}/ ); + } ); + + test( 'should show the publishing date as "Immediately" if the date is cleared', async ( { + page, + } ) => { + const datepicker = page.getByRole( 'button', { + name: 'Change date', + } ); + await datepicker.click(); + + // Change the publishing date to a year in the future. + await page + .getByRole( 'group', { name: 'Date' } ) + .getByRole( 'spinbutton', { name: 'Year' } ) + .click(); + await page.keyboard.press( 'ArrowUp' ); + await page.keyboard.press( 'Escape' ); + + // Clear the date. + await datepicker.click(); + await page + .getByLabel( 'Change publish date' ) + .getByRole( 'button', { name: 'Now' } ) + .click(); + + await expect( + page.getByRole( 'button', { name: 'Change date' } ) + ).toHaveText( 'Immediately' ); + } ); + } ); +} ); diff --git a/test/e2e/specs/editor/various/invalid-block.spec.js b/test/e2e/specs/editor/various/invalid-block.spec.js new file mode 100644 index 00000000000000..07c04a5a55457e --- /dev/null +++ b/test/e2e/specs/editor/various/invalid-block.spec.js @@ -0,0 +1,119 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Invalid blocks', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test( 'should show an invalid block message with clickable options', async ( { + editor, + page, + } ) => { + // Create an empty paragraph with the focus in the block. + await editor.canvas + .getByRole( 'button', { name: 'Add default block' } ) + .click(); + await page.keyboard.type( 'hello' ); + + // Change to HTML mode and close the options. + await editor.clickBlockOptionsMenuItem( 'Edit as HTML' ); + + // Focus on the textarea and enter an invalid paragraph. + await editor.canvas + .getByRole( 'document', { name: 'Block: Paragraph' } ) + .getByRole( 'textbox' ) + .fill( '<p>invalid paragraph' ); + + // Takes the focus away from the block so the invalid warning is triggered. + await editor.saveDraft(); + + // Click on the 'three-dots' menu toggle. + await editor.canvas + .getByRole( 'document', { name: 'Block: Paragraph' } ) + .getByRole( 'button', { name: 'More options' } ) + .click(); + + await page + .getByRole( 'menu', { name: 'More options' } ) + .getByRole( 'menuitem', { name: 'Resolve' } ) + .click(); + + // Check we get the resolve modal with the appropriate contents. + await expect( + page + .getByRole( 'dialog', { name: 'Resolve Block' } ) + .locator( '.block-editor-block-compare__html' ) + ).toHaveText( [ '<p>invalid paragraph', '<p>invalid paragraph</p>' ] ); + } ); + + test( 'should strip potentially malicious on* attributes', async ( { + editor, + page, + } ) => { + let hasAlert = false; + let error = ''; + let warning = ''; + + page.on( 'dialog', () => { + hasAlert = true; + } ); + + page.on( 'console', ( msg ) => { + if ( msg.type() === 'error' ) { + error = msg.text(); + } + + if ( msg.type() === 'warning' ) { + warning = msg.text(); + } + } ); + + await editor.setContent( ` + <!-- wp:paragraph --> + <p>aaaa <img src onerror=alert(1)></x dde></x>1 + <!-- /wp:paragraph --> + ` ); + + // Give the browser time to show the alert. + await expect( + editor.canvas + .getByRole( 'document', { name: 'Block: Paragraph' } ) + .getByRole( 'button', { name: 'Attempt Block Recovery' } ) + ).toBeVisible(); + + expect( hasAlert ).toBe( false ); + expect( error ).toContain( + 'Block validation: Block validation failed' + ); + expect( warning ).toContain( + 'Block validation: Malformed HTML detected' + ); + } ); + + test( 'should not trigger malicious script tags when using a shortcode block', async ( { + editor, + page, + } ) => { + let hasAlert = false; + + page.on( 'dialog', () => { + hasAlert = true; + } ); + + await editor.setContent( ` + <!-- wp:shortcode --> + <animate onbegin=alert(1) attributeName=x dur=1s><script>alert("EVIL");</script><style>@keyframes x{}</style><a style="animation-name:x" onanimationstart="alert(2)"></a> + <!-- /wp:shortcode --> + ` ); + + // Give the browser time to show the alert. + await expect( + editor.canvas.getByRole( 'document', { name: 'Block: Shortcode' } ) + ).toBeVisible(); + + expect( hasAlert ).toBe( false ); + } ); +} ); diff --git a/test/e2e/specs/editor/various/nux.spec.js b/test/e2e/specs/editor/various/nux.spec.js new file mode 100644 index 00000000000000..ff55dbfa54e478 --- /dev/null +++ b/test/e2e/specs/editor/various/nux.spec.js @@ -0,0 +1,138 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'New User Experience (NUX)', () => { + test( 'should show the guide to first-time users', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost( { showWelcomeGuide: true } ); + + const welcomeGuide = page.getByRole( 'dialog', { + name: 'Welcome to the block editor', + } ); + const guideHeading = welcomeGuide.getByRole( 'heading', { level: 1 } ); + const nextButton = welcomeGuide.getByRole( 'button', { name: 'Next' } ); + const prevButton = welcomeGuide.getByRole( 'button', { + name: 'Previous', + } ); + + await expect( guideHeading ).toHaveText( + 'Welcome to the block editor' + ); + + await nextButton.click(); + await expect( guideHeading ).toHaveText( 'Make each block your own' ); + + await prevButton.click(); + // Guide should be on page 1 of 4 + await expect( guideHeading ).toHaveText( + 'Welcome to the block editor' + ); + + // Press the button for Page 2. + await welcomeGuide + .getByRole( 'button', { name: 'Page 2 of 4' } ) + .click(); + await expect( guideHeading ).toHaveText( 'Make each block your own' ); + + // Press the right arrow key for Page 3. + await page.keyboard.press( 'ArrowRight' ); + await expect( guideHeading ).toHaveText( + 'Get to know the block library' + ); + + // Press the right arrow key for Page 4. + await page.keyboard.press( 'ArrowRight' ); + await expect( guideHeading ).toHaveText( + 'Learn how to use the block editor' + ); + + // Click on the *visible* 'Get started' button. + await welcomeGuide + .getByRole( 'button', { name: 'Get started' } ) + .click(); + + // Guide should be closed. + await expect( welcomeGuide ).toBeHidden(); + + // Reload the editor. + await page.reload(); + + // Guide should be closed. + await expect( + editor.canvas.getByRole( 'textbox', { name: 'Add title' } ) + ).toBeVisible(); + await expect( welcomeGuide ).toBeHidden(); + } ); + + test( 'should not show the welcome guide again if it is dismissed', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost( { showWelcomeGuide: true } ); + + const welcomeGuide = page.getByRole( 'dialog', { + name: 'Welcome to the block editor', + } ); + + await expect( welcomeGuide ).toBeVisible(); + await welcomeGuide.getByRole( 'button', { name: 'Close' } ).click(); + + // Reload the editor. + await page.reload(); + await expect( + editor.canvas.getByRole( 'textbox', { name: 'Add title' } ) + ).toBeFocused(); + + await expect( welcomeGuide ).toBeHidden(); + } ); + + test( 'should focus post title field after welcome guide is dismissed and post is empty', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost( { showWelcomeGuide: true } ); + + const welcomeGuide = page.getByRole( 'dialog', { + name: 'Welcome to the block editor', + } ); + + await expect( welcomeGuide ).toBeVisible(); + await welcomeGuide.getByRole( 'button', { name: 'Close' } ).click(); + + await expect( + editor.canvas.getByRole( 'textbox', { name: 'Add title' } ) + ).toBeFocused(); + } ); + + test( 'should show the welcome guide if it is manually opened', async ( { + admin, + page, + } ) => { + await admin.createNewPost(); + const welcomeGuide = page.getByRole( 'dialog', { + name: 'Welcome to the block editor', + } ); + + await expect( welcomeGuide ).toBeHidden(); + + // Manually open the guide + await page + .getByRole( 'region', { + name: 'Editor top bar', + } ) + .getByRole( 'button', { name: 'Options' } ) + .click(); + await page + .getByRole( 'menuitemcheckbox', { name: 'Welcome Guide' } ) + .click(); + + await expect( welcomeGuide ).toBeVisible(); + } ); +} ); diff --git a/test/e2e/specs/editor/various/publishing.spec.js b/test/e2e/specs/editor/various/publishing.spec.js new file mode 100644 index 00000000000000..8f448c58e58bd4 --- /dev/null +++ b/test/e2e/specs/editor/various/publishing.spec.js @@ -0,0 +1,164 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +const POST_TYPES = [ 'post', 'page' ]; + +test.describe( 'Publishing', () => { + POST_TYPES.forEach( ( postType ) => { + test.describe( `${ postType } locking prevent saving`, () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost( { postType } ); + } ); + + test( `disables the publish button when a ${ postType } is locked`, async ( { + editor, + page, + } ) => { + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'E2E Test Post' ); + + await page.evaluate( () => + window.wp.data + .dispatch( 'core/editor' ) + .lockPostSaving( 'futurelock' ) + ); + + // Open publish panel. + await page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Publish' } ) + .click(); + + // Publish button should be disabled. + await expect( + page + .getByRole( 'region', { name: 'Editor publish' } ) + .getByRole( 'button', { name: 'Publish', exact: true } ) + ).toBeDisabled(); + } ); + + test( `disables the save shortcut when a ${ postType } is locked`, async ( { + editor, + page, + pageUtils, + } ) => { + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'E2E Test Post' ); + + await page.evaluate( () => + window.wp.data + .dispatch( 'core/editor' ) + .lockPostSaving( 'futurelock' ) + ); + + await pageUtils.pressKeys( 'primary+s' ); + + await expect( + page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Save draft' } ) + ).toBeEnabled(); + } ); + } ); + } ); + + POST_TYPES.forEach( ( postType ) => { + test.describe( `a ${ postType } with pre-publish checks disabled`, () => { + test.beforeEach( async ( { admin, editor } ) => { + await admin.createNewPost( { postType } ); + await editor.setPreferences( 'core/edit-post', { + isPublishSidebarEnabled: false, + } ); + } ); + + test.afterEach( async ( { editor } ) => { + await editor.setPreferences( 'core/edit-post', { + isPublishSidebarEnabled: true, + } ); + } ); + + test( `should publish the ${ postType } without opening the post-publish sidebar`, async ( { + editor, + page, + } ) => { + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'E2E Test Post' ); + + // Publish the post. + await page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Publish' } ) + .click(); + + const publishPanel = page.getByRole( 'region', { + name: 'Editor publish', + } ); + + // The pre-publishing panel should have been not shown. + await expect( + publishPanel.getByRole( 'button', { + name: 'Publish', + exact: true, + } ) + ).toBeHidden(); + + // The post-publishing panel should have been not shown. + await expect( + publishPanel.getByRole( 'button', { + name: 'View Post', + } ) + ).toBeHidden(); + + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'published' } ) + ).toBeVisible(); + } ); + } ); + } ); + + POST_TYPES.forEach( ( postType ) => { + test.describe( `a ${ postType } in small viewports`, () => { + test.beforeEach( async ( { admin, editor, pageUtils } ) => { + await admin.createNewPost( { postType } ); + await editor.setPreferences( 'core/edit-post', { + isPublishSidebarEnabled: false, + } ); + await pageUtils.setBrowserViewport( 'small' ); + } ); + + test.afterEach( async ( { editor, pageUtils } ) => { + await editor.setPreferences( 'core/edit-post', { + isPublishSidebarEnabled: true, + } ); + await pageUtils.setBrowserViewport( 'large' ); + } ); + + test( 'should ignore the pre-publish checks and show the publish panel', async ( { + editor, + page, + } ) => { + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'E2E Test Post' ); + + await page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Publish' } ) + .click(); + + await expect( + page + .getByRole( 'region', { name: 'Editor publish' } ) + .getByRole( 'button', { name: 'Publish', exact: true } ) + ).toBeVisible(); + } ); + } ); + } ); +} ); diff --git a/test/e2e/specs/editor/various/scheduling.spec.js b/test/e2e/specs/editor/various/scheduling.spec.js new file mode 100644 index 00000000000000..1fa41a79ea7ccc --- /dev/null +++ b/test/e2e/specs/editor/various/scheduling.spec.js @@ -0,0 +1,90 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +// The `timezone` setting exposed via REST API only accepts `UTC` +// and timezone strings by location. +const TIMEZONES = [ 'Pacific/Honolulu', 'UTC', 'Australia/Sydney' ]; + +test.describe( 'Scheduling', () => { + TIMEZONES.forEach( ( timezone ) => { + test.describe( `Timezone ${ timezone }`, () => { + let orignalTimezone; + test.beforeAll( async ( { requestUtils } ) => { + orignalTimezone = ( await requestUtils.getSiteSettings() ) + .timezone; + + await requestUtils.updateSiteSettings( { timezone } ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.updateSiteSettings( { + timezone: orignalTimezone, + } ); + } ); + + test( 'Should change publishing button text from "Publish" to "Schedule"', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost(); + await editor.openDocumentSettingsSidebar(); + + const topBar = page.getByRole( 'region', { + name: 'Editor top bar', + } ); + + await expect( + topBar.getByRole( 'button', { name: 'Publish' } ) + ).toBeVisible(); + + // Open the datepicker. + await page + .getByRole( 'button', { name: 'Change date' } ) + .click(); + + // Change the publishing date to a year in the future. + await page + .getByRole( 'group', { name: 'Date' } ) + .getByRole( 'spinbutton', { name: 'Year' } ) + .click(); + await page.keyboard.press( 'ArrowUp' ); + + // Close the datepicker. + await page.keyboard.press( 'Escape' ); + + await expect( + topBar.getByRole( 'button', { name: 'Schedule…' } ) + ).toBeVisible(); + } ); + } ); + } ); + + test( 'should keep date time UI focused when the previous and next month buttons are clicked', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost(); + await editor.openDocumentSettingsSidebar(); + await page.getByRole( 'button', { name: 'Change date' } ).click(); + + const calendar = page.getByRole( 'application', { name: 'Calendar' } ); + const prevMonth = calendar.getByRole( 'button', { + name: 'View previous month', + } ); + const nextMonth = calendar.getByRole( 'button', { + name: 'View next month', + } ); + + await prevMonth.click(); + await expect( prevMonth ).toBeFocused(); + await expect( calendar ).toBeVisible(); + + await nextMonth.click(); + await expect( nextMonth ).toBeFocused(); + await expect( calendar ).toBeVisible(); + } ); +} ); diff --git a/test/integration/fixtures/blocks/core__post-featured-image.json b/test/integration/fixtures/blocks/core__post-featured-image.json index 158007533a3f2b..dec6e14712a3a2 100644 --- a/test/integration/fixtures/blocks/core__post-featured-image.json +++ b/test/integration/fixtures/blocks/core__post-featured-image.json @@ -7,7 +7,8 @@ "scale": "cover", "rel": "", "linkTarget": "_self", - "dimRatio": 0 + "dimRatio": 0, + "useFirstImageFromPost": false }, "innerBlocks": [] } diff --git a/test/integration/fixtures/blocks/core__search.json b/test/integration/fixtures/blocks/core__search.json index f692eac10993d8..ec961ed41b0244 100644 --- a/test/integration/fixtures/blocks/core__search.json +++ b/test/integration/fixtures/blocks/core__search.json @@ -8,7 +8,6 @@ "buttonPosition": "button-outside", "buttonUseIcon": false, "query": {}, - "buttonBehavior": "expand-searchfield", "isSearchFieldHidden": false }, "innerBlocks": [] diff --git a/test/integration/fixtures/blocks/core__search__custom-text.json b/test/integration/fixtures/blocks/core__search__custom-text.json index c763cb60f65e86..3738816762ba1e 100644 --- a/test/integration/fixtures/blocks/core__search__custom-text.json +++ b/test/integration/fixtures/blocks/core__search__custom-text.json @@ -10,7 +10,6 @@ "buttonPosition": "button-outside", "buttonUseIcon": false, "query": {}, - "buttonBehavior": "expand-searchfield", "isSearchFieldHidden": false }, "innerBlocks": []