From d5bcf8703387c4fc28846e6e013599455db77c71 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Thu, 4 Jan 2024 13:16:57 +0400 Subject: [PATCH 01/31] Migrate 'publishing' e2e tests to Playwright (#57521) * Migrate 'publishing' e2e tests to Playwright * Remove old test file --- .../specs/editor/various/publishing.test.js | 176 ------------------ .../specs/editor/various/publishing.spec.js | 164 ++++++++++++++++ 2 files changed, 164 insertions(+), 176 deletions(-) delete mode 100644 packages/e2e-tests/specs/editor/various/publishing.test.js create mode 100644 test/e2e/specs/editor/various/publishing.spec.js 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 fbac8cf98638b..0000000000000 --- 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/test/e2e/specs/editor/various/publishing.spec.js b/test/e2e/specs/editor/various/publishing.spec.js new file mode 100644 index 0000000000000..8f448c58e58bd --- /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(); + } ); + } ); + } ); +} ); From 598b4a85961de0f49b255e27f9ab5193beed740b Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Thu, 4 Jan 2024 14:40:41 +0400 Subject: [PATCH 02/31] Migrate 'invalid blocks' e2e tests to Playwright (#57508) * Migrate 'invalid blocks' e2e tests to Playwright * Remove old test file --- .../editor/various/invalid-block.test.js | 100 --------------- .../editor/various/invalid-block.spec.js | 119 ++++++++++++++++++ 2 files changed, 119 insertions(+), 100 deletions(-) delete mode 100644 packages/e2e-tests/specs/editor/various/invalid-block.test.js create mode 100644 test/e2e/specs/editor/various/invalid-block.spec.js 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 354c370434be9..0000000000000 --- 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/test/e2e/specs/editor/various/invalid-block.spec.js b/test/e2e/specs/editor/various/invalid-block.spec.js new file mode 100644 index 0000000000000..07c04a5a55457 --- /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( '

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( [ '

invalid paragraph', '

invalid paragraph

' ] ); + } ); + + 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( ` + +

aaaa 1 + + ` ); + + // 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( ` + + + + ` ); + + // Give the browser time to show the alert. + await expect( + editor.canvas.getByRole( 'document', { name: 'Block: Shortcode' } ) + ).toBeVisible(); + + expect( hasAlert ).toBe( false ); + } ); +} ); From 81b8e05def01adf5ad6e4efbfaccbcfa1abf8fa8 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Thu, 4 Jan 2024 12:00:39 +0100 Subject: [PATCH 03/31] Editor: Unify spotlight mode preference (#57533) --- .../edit-post/src/components/header/writing-menu/index.js | 2 +- .../edit-post/src/components/preferences-modal/index.js | 1 + packages/edit-post/src/editor.js | 4 ---- packages/edit-post/src/editor.native.js | 5 ----- .../edit-post/src/hooks/commands/use-common-commands.js | 2 +- .../components/block-editor/use-site-editor-settings.js | 5 +---- .../src/components/header-edit-mode/more-menu/index.js | 2 +- .../edit-site/src/components/preferences-modal/index.js | 1 + .../src/hooks/commands/use-edit-mode-commands.js | 2 +- packages/edit-site/src/index.js | 2 +- .../src/components/provider/use-block-editor-settings.js | 8 +++++++- .../preferences-package-data/convert-editor-settings.js | 1 + 12 files changed, 16 insertions(+), 19 deletions(-) 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 11d07e52ec590..f0a4dc762ac5c 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 2a8d1e591e084..d493a2a297b18 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 86cac4244dea9..cf7de98d6fbbb 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 07f27cd31ea79..a7151c6e908d6 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; From bccb34441202532bc7584b2d1e0f92d931c69344 Mon Sep 17 00:00:00 2001 From: Ella <4710635+ellatrix@users.noreply.github.com> Date: Thu, 4 Jan 2024 17:54:08 +0200 Subject: [PATCH 10/31] Rich text: add HTML string methods to RichTextData (#57322) --- packages/rich-text/src/create.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/rich-text/src/create.js b/packages/rich-text/src/create.js index a35fabbd4e2fa..7c0989a11dc4a 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 From e98d1fec2a401b04840a3df26e97e7d26982d768 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 4 Jan 2024 15:54:51 +0000 Subject: [PATCH 11/31] Update: Material design icons link. (#57550) --- docs/explanations/user-interface/block-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/explanations/user-interface/block-design.md b/docs/explanations/user-interface/block-design.md index e3a7b84bfa583..66411744fa566 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:** From 716f664a0681af0c163ba81fb21da59f582ff26f Mon Sep 17 00:00:00 2001 From: Nik Tsekouras Date: Thu, 4 Jan 2024 18:10:01 +0200 Subject: [PATCH 12/31] DataViews: Add sync filter in patterns page (#57532) * DataViews: Add sync filter in patterns page * remove isVirtual field * display `sync status` field * minor tweak --- packages/dataviews/src/add-filter.js | 8 +++ packages/dataviews/src/filter-summary.js | 6 ++ packages/dataviews/src/filters.js | 5 +- packages/dataviews/src/view-grid.js | 2 +- packages/dataviews/src/view-table.js | 6 ++ .../page-patterns/dataviews-patterns.js | 70 +++++++++++++++---- 6 files changed, 81 insertions(+), 16 deletions(-) diff --git a/packages/dataviews/src/add-filter.js b/packages/dataviews/src/add-filter.js index 0ebaa373c3ef9..4f77a35c52d2e 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 f09c15c8eb137..723fee5b3ee52 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 bcc9df9ae327a..a101a07753990 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 e43c9744c560f..5ac4d1d42d573 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 0a7e29cd1068c..5bfcba5def4aa 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/edit-site/src/components/page-patterns/dataviews-patterns.js b/packages/edit-site/src/components/page-patterns/dataviews-patterns.js index 0a7c9f204091b..c626336c4f498 100644 --- a/packages/edit-site/src/components/page-patterns/dataviews-patterns.js +++ b/packages/edit-site/src/components/page-patterns/dataviews-patterns.js @@ -9,7 +9,7 @@ import { Flex, } from '@wordpress/components'; import { getQueryArgs } from '@wordpress/url'; -import { __ } from '@wordpress/i18n'; +import { __, _x } from '@wordpress/i18n'; import { useState, useMemo, @@ -46,6 +46,8 @@ import { TEMPLATE_PART_POST_TYPE, PATTERN_SYNC_TYPES, PATTERN_DEFAULT_CATEGORY, + ENUMERATION_TYPE, + OPERATOR_IN, } from '../../utils/constants'; import { exportJSONaction, @@ -76,13 +78,31 @@ const DEFAULT_VIEW = { search: '', page: 1, perPage: 20, - hiddenFields: [], + hiddenFields: [ 'sync-status' ], layout: { ...defaultConfigPerViewType[ LAYOUT_GRID ], }, filters: [], }; +const SYNC_FILTERS = [ + { + value: PATTERN_SYNC_TYPES.full, + label: _x( 'Synced', 'Option that shows all synchronized patterns' ), + description: __( 'Patterns that are kept in sync across the site.' ), + }, + { + value: PATTERN_SYNC_TYPES.unsynced, + label: _x( + 'Not synced', + 'Option that shows all patterns that are not synchronized' + ), + description: __( + 'Patterns that can be changed freely without affecting the site.' + ), + }, +]; + function Preview( { item, viewType } ) { const descriptionId = useId(); const isUserPattern = item.type === PATTERN_TYPES.user; @@ -204,27 +224,25 @@ export default function DataviewsPatterns() { const isUncategorizedThemePatterns = type === PATTERN_TYPES.theme && categoryId === 'uncategorized'; const previousCategoryId = usePrevious( categoryId ); + const viewSyncStatus = view.filters?.find( + ( { field } ) => 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 ) { From 8d933d3839351a6d56b778b6ea46282cebc6e02b Mon Sep 17 00:00:00 2001 From: Jorge Costa <jorge.costa@developer.pt> Date: Thu, 4 Jan 2024 16:32:41 +0000 Subject: [PATCH 13/31] Fix: Create block getting started links. (#57551) --- README.md | 2 +- docs/contributors/documentation/README.md | 4 ++-- docs/explanations/architecture/key-concepts.md | 2 +- docs/how-to-guides/README.md | 2 +- .../block-tutorial/applying-styles-with-stylesheets.md | 2 +- docs/how-to-guides/metabox.md | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5ba112319b405..d5b299baadc7f 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/docs/contributors/documentation/README.md b/docs/contributors/documentation/README.md index 7089bf5714168..454f455ba891d 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 <a href="https://developer.wordpress.org/block-editor/getting-started/create-block/">https://developer.wordpress.org/block-editor/getting-started/create-block/</a> to link correctly in the handbook. </div> diff --git a/docs/explanations/architecture/key-concepts.md b/docs/explanations/architecture/key-concepts.md index a041b86effdc3..30318d29422d3 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/how-to-guides/README.md b/docs/how-to-guides/README.md index 152f8ce6184ae..c57dcad7a3528 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 697984c9456e0..41646bbe527cf 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 b1baac1f25585..9db89f51c748d 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: From 719d0b9d367654989284f98280d4ee74e6033b56 Mon Sep 17 00:00:00 2001 From: Gutenberg Repository Automation <gutenberg@wordpress.org> Date: Thu, 4 Jan 2024 17:01:40 +0000 Subject: [PATCH 14/31] Bump plugin version to 17.4.1 --- gutenberg.php | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/gutenberg.php b/gutenberg.php index f897dfa98b071..35e416006bea5 100644 --- a/gutenberg.php +++ b/gutenberg.php @@ -5,7 +5,7 @@ * Description: Printing since 1440. This is the development plugin for the block editor, site editor, and other future WordPress core functionality. * Requires at least: 6.3 * Requires PHP: 7.0 - * Version: 17.4.0 + * Version: 17.4.1 * Author: Gutenberg Team * Text Domain: gutenberg * diff --git a/package-lock.json b/package-lock.json index 0211430c2542b..326c7ac7bf80c 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": { diff --git a/package.json b/package.json index 5753cbfea0851..5f99726146658 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", From 1ef71dad50f20b3eafa07238048037d6c51df982 Mon Sep 17 00:00:00 2001 From: Jerry Jones <jones.jeremydavid@gmail.com> Date: Thu, 4 Jan 2024 11:10:56 -0600 Subject: [PATCH 15/31] Keep Lock button it in the toolbar until unmounted (#57229) 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 a lock button. Removing the lock button beforehand can cause focus loss issues, such as when unlocking the block from the modal. We need to return focus from whence it came, and to do that, we need to leave the button in the toolbar. --- .../src/components/block-lock/toolbar.js | 62 +++++-------------- .../src/components/block-toolbar/index.js | 3 +- .../editor/various/block-locking.spec.js | 6 ++ 3 files changed, 22 insertions(+), 49 deletions(-) diff --git a/packages/block-editor/src/components/block-lock/toolbar.js b/packages/block-editor/src/components/block-lock/toolbar.js index 14a941a9011b6..ccf04c5e5262d 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 } ) { <> <ToolbarGroup className="block-editor-block-lock-toolbar"> <ToolbarButton - ref={ lockButtonRef } - icon={ lock } - label={ __( 'Unlock' ) } + icon={ isLocked ? lock : unlock } + label={ isLocked ? __( 'Unlock' ) : __( 'Lock' ) } onClick={ toggleModal } aria-expanded={ isModalOpen } aria-haspopup="dialog" diff --git a/packages/block-editor/src/components/block-toolbar/index.js b/packages/block-editor/src/components/block-toolbar/index.js index 7bb52a7e8f090..0d9b61314c4ed 100644 --- a/packages/block-editor/src/components/block-toolbar/index.js +++ b/packages/block-editor/src/components/block-toolbar/index.js @@ -175,8 +175,7 @@ export function PrivateBlockToolbar( { <BlockSwitcher clientIds={ blockClientIds } /> { ! isMultiToolbar && ( <BlockLockToolbar - clientId={ blockClientIds[ 0 ] } - wrapperRef={ toolbarWrapperRef } + clientId={ blockClientId } /> ) } <BlockMover diff --git a/test/e2e/specs/editor/various/block-locking.spec.js b/test/e2e/specs/editor/various/block-locking.spec.js index b40e7a4b7448a..eafb468902ef9 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> From a983c7df1e234e9b810386e296625dbeb9536cf1 Mon Sep 17 00:00:00 2001 From: Gutenberg Repository Automation <gutenberg@wordpress.org> Date: Thu, 4 Jan 2024 17:32:01 +0000 Subject: [PATCH 16/31] Update Changelog for 17.3.1 --- changelog.txt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/changelog.txt b/changelog.txt index a66e3c00073d3..1adc17da88c6b 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,21 @@ == Changelog == += 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 = From 3703528d9dd8ef50b5aca9c077c556f6a0af0648 Mon Sep 17 00:00:00 2001 From: Gutenberg Repository Automation <gutenberg@wordpress.org> Date: Thu, 4 Jan 2024 17:50:40 +0000 Subject: [PATCH 17/31] Update Changelog for 17.4.1 --- changelog.txt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/changelog.txt b/changelog.txt index 1adc17da88c6b..de2e8d243ff74 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,20 @@ == 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 From fc0573831cc7385cd7b65a2094cae4baac7b01be Mon Sep 17 00:00:00 2001 From: Jon Surrell <sirreal@users.noreply.github.com> Date: Thu, 4 Jan 2024 21:20:05 +0100 Subject: [PATCH 18/31] Dependency Extraction Webpack Plugin: Add modules support (#57199) Add support for extracting modules information with the Dependency Extraction Webpack Plugin. As modules support matures, we should provide at least the same experience folks currently have with scripts. --- .../CHANGELOG.md | 10 +- .../README.md | 130 ++++++- .../lib/.eslintrc.json | 2 - .../lib/index.js | 166 +++++++- .../lib/types.d.ts | 1 + .../lib/util.js | 20 +- .../test/__snapshots__/build.js.snap | 363 ++++++++++++++++++ .../test/build.js | 164 ++++---- .../fixtures/combine-assets/webpack.config.js | 5 + .../fixtures/cyclic-dependency-graph/a.js | 13 + .../fixtures/cyclic-dependency-graph/b.js | 10 + .../fixtures/cyclic-dependency-graph/index.js | 8 + .../cyclic-dependency-graph/webpack.config.js | 8 + .../cyclic-dynamic-dependency-graph/a.js | 13 + .../cyclic-dynamic-dependency-graph/b.js | 10 + .../cyclic-dynamic-dependency-graph/index.js | 9 + .../webpack.config.js | 8 + .../fixtures/dynamic-import/webpack.config.js | 10 +- .../webpack.config.js | 10 +- .../has-extension-suffix/webpack.config.js | 10 +- .../test/fixtures/module-renames/index.js | 7 + .../fixtures/module-renames/webpack.config.js | 32 ++ .../webpack.config.js | 5 + .../option-output-filename/webpack.config.js | 5 + .../test/fixtures/overrides/webpack.config.js | 12 + .../runtime-chunk-single/webpack.config.js | 10 +- .../fixtures/style-imports/webpack.config.js | 8 +- .../fixtures/wordpress-interactivity/index.js | 12 + .../wordpress-interactivity/webpack.config.js | 8 + .../wordpress-require/webpack.config.js | 10 +- .../test/fixtures/wordpress/webpack.config.js | 10 +- 31 files changed, 978 insertions(+), 111 deletions(-) create mode 100644 packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dependency-graph/a.js create mode 100644 packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dependency-graph/b.js create mode 100644 packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dependency-graph/index.js create mode 100644 packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dependency-graph/webpack.config.js create mode 100644 packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dynamic-dependency-graph/a.js create mode 100644 packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dynamic-dependency-graph/b.js create mode 100644 packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dynamic-dependency-graph/index.js create mode 100644 packages/dependency-extraction-webpack-plugin/test/fixtures/cyclic-dynamic-dependency-graph/webpack.config.js create mode 100644 packages/dependency-extraction-webpack-plugin/test/fixtures/module-renames/index.js create mode 100644 packages/dependency-extraction-webpack-plugin/test/fixtures/module-renames/webpack.config.js create mode 100644 packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress-interactivity/index.js create mode 100644 packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress-interactivity/webpack.config.js diff --git a/packages/dependency-extraction-webpack-plugin/CHANGELOG.md b/packages/dependency-extraction-webpack-plugin/CHANGELOG.md index 78de3e4142c87..414a5229117ce 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 eac1e8e27ffc5..91fb36e8ad09d 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 +<?php return array('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 305ba4347aedd..5e1457134b16a 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 98b484672ef3f..400ea39c02293 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<string>} */ 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 `<?php return ${ json2php( @@ -104,7 +130,19 @@ class DependencyExtractionWebpackPlugin { return JSON.stringify( asset ); } + /** @type {webpack.WebpackPluginInstance['apply']} */ apply( compiler ) { + this.useModules = Boolean( compiler.options.output?.module ); + + /** + * Offload externalization work to the ExternalsPlugin. + * @type {webpack.ExternalsPlugin} + */ + this.externalsPlugin = new webpack.ExternalsPlugin( + this.useModules ? 'module' : 'window', + this.externalizeWpDeps.bind( this ) + ); + this.externalsPlugin.apply( compiler ); compiler.hooks.thisCompilation.tap( @@ -122,6 +160,7 @@ class DependencyExtractionWebpackPlugin { ); } + /** @param {webpack.Compilation} compilation */ addAssets( compilation ) { const { combineAssets, @@ -160,27 +199,53 @@ class DependencyExtractionWebpackPlugin { for ( const chunk of entrypointChunks ) { const chunkFiles = Array.from( chunk.files ); - const chunkJSFile = chunkFiles.find( ( f ) => /\.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<string>} */ + const chunkStaticDeps = new Set(); + /** @type {Set<string>} */ + 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 179b4dab593bd..c4a4af52b1b2f 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 bd328430313ce..bc2b2221e8fc9 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 4240c2f2ea378..3c8f89fc14ee9 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`] = ` +"<?php return array('fileA.mjs' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` "<?php return array('fileA.js' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` "<?php return array('dependencies' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` "<?php return array('dependencies' => 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`] = ` +"<?php return array('dependencies' => 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`] = ` "<?php return array('dependencies' => 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 7c84e4b0bcc8b..3b29d55caf2bb 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 2ce7ba1be98e2..fb7ba94ca8099 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 0000000000000..11dd7764ad5f9 --- /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 0000000000000..ce109acccbd37 --- /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 0000000000000..13b17a73ad4af --- /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 0000000000000..bfffff3ae7831 --- /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 0000000000000..11dd7764ad5f9 --- /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 0000000000000..25a6aa127d26f --- /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 0000000000000..073b4244dcea2 --- /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 0000000000000..bfffff3ae7831 --- /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 bfffff3ae7831..6856d328ab7c6 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 2d5b2e43b735e..f637a4087e3ca 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 d814beacdf4dc..ada40c8bf8e54 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 0000000000000..dc3702922c6ff --- /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 0000000000000..8b78e1fdea150 --- /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 e328c817851ce..5056f312c3999 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 9ec78f6437a18..be52e66165386 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 9885e5cade7e9..89eaf6ee4b2f5 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 e16f6b6b0fe70..1e0824563c52f 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 52cb718a579de..332e182e34b04 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 0000000000000..b4dd2f288661e --- /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 0000000000000..bfffff3ae7831 --- /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 bfffff3ae7831..6856d328ab7c6 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 bfffff3ae7831..6856d328ab7c6 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; + } + }, + } ), + ], }; From e8b0a4c459dd2dd8f6775f312128cc6e7106a6bc Mon Sep 17 00:00:00 2001 From: Gerardo Pacheco <gerardo.pacheco@automattic.com> Date: Thu, 4 Jan 2024 21:28:27 +0100 Subject: [PATCH 19/31] =?UTF-8?q?[Mobile]=C2=A0-=20Update=20E2E=20Tests=20?= =?UTF-8?q?related=20to=20the=20Writing=20flow=20(#57460)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add support to pass the initialTitle to the E2E tests * Editor Page - Add new E2E utils * E2E - Paragraph block - Remove deprecated multiline prop test * Add initial E2E Writing flow tests * E2E Tests - Merge Paragraph block tests with the Writing flow tests * E2E Tests - Merge Heading block tests with the Writing flow tests * Add missing part of the test * Fixes comment typo * Fix typo * Increase pause value * Add pause before dismissing the keyboard to avoid flakiness * Remove pause in favor of waitUntil * Removes pause in favor of waitUntil when checking for the Keyboard state in some cases, which also expects the condition to be true. * Simplify waitUntil to just pass the promise * Rename getEmptyTitleElement to getEmptyTitleTextInputElement and move the logic to click on the title wrapper within the function * Editor Page - Adds new param skipWrapperClick to getTextBlockAtPosition to avoid clicking on the wrapper on iOS if its set to true for cases where its already focused and the caret is at the desired position * Unify the split and merge tests into one * Update `showSoftKeyboard` to wait for the text input to get focus * Check input method manager is defined before hiding keyboard * Set wrapper view of the editor to be not focusable in the demo app This matches the behavior of the host app WP-Android. --------- Co-authored-by: Carlos Garcia <fluiddot@gmail.com> --- .../mobile/WPAndroidGlue/GutenbergProps.kt | 2 +- .../gutenberg-editor-device-actions.test.js | 4 + .../gutenberg-editor-heading-@canary.test.js | 63 ---- .../gutenberg-editor-paragraph.test.js | 143 -------- ...enberg-editor-writing-flow-@canary.test.js | 333 ++++++++++++++++++ .../__device-tests__/pages/editor-page.js | 108 ++++-- .../main/java/com/gutenberg/MainActivity.java | 7 + .../GutenbergViewController.swift | 5 +- 8 files changed, 436 insertions(+), 229 deletions(-) delete mode 100644 packages/react-native-editor/__device-tests__/gutenberg-editor-heading-@canary.test.js delete mode 100644 packages/react-native-editor/__device-tests__/gutenberg-editor-paragraph.test.js create mode 100644 packages/react-native-editor/__device-tests__/gutenberg-editor-writing-flow-@canary.test.js 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 ce427be2ad09b..ec847d71bf51c 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-editor/__device-tests__/gutenberg-editor-device-actions.test.js b/packages/react-native-editor/__device-tests__/gutenberg-editor-device-actions.test.js index e7ee4a20df03f..230c844491d28 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 50a2a3ee8fd64..0000000000000 --- 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 8f21ef04858fb..0000000000000 --- 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 0000000000000..3a12bf5d13345 --- /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 a19aaf5445d79..b00be20458e80 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 a938df715ec00..3ea19fa97b383 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 @@ -168,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; @@ -177,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); } @@ -211,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 ef95c7e65862f..0c04308125df7 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 { From 15b698116fdf9cac04905950bda0c2864bd9c095 Mon Sep 17 00:00:00 2001 From: Nick Diego <nick.diego@automattic.com> Date: Thu, 4 Jan 2024 15:38:40 -0600 Subject: [PATCH 20/31] Add new section to the Quick Start Guide about wp-env (#57559) * Add new section to guide. * Minor tweak. --- docs/getting-started/quick-start-guide.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/getting-started/quick-start-guide.md b/docs/getting-started/quick-start-guide.md index e978b250ab8af..c6f22ce219136 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/) From 5e02c302ee5df4c3a67857cc854f79ec09ff94a4 Mon Sep 17 00:00:00 2001 From: Ben Dwyer <ben@scruffian.com> Date: Thu, 4 Jan 2024 22:13:32 +0000 Subject: [PATCH 21/31] Post Featured Image: Add a useFirstImageFromPost attribute (#56573) * Post Featured Image: Add a useFirstImageFromPost attribute * Add a comment and remove the toggle * PHPCS * only load image if there is post content * use a regex to improve performance * don't update the value that comes from the useSelect * update snapshots * simplify regex --- docs/reference-guides/core-blocks.md | 2 +- .../src/post-featured-image/block.json | 4 +++ .../src/post-featured-image/edit.js | 33 ++++++++++++++++++- .../src/post-featured-image/index.php | 31 +++++++++++++++++ .../blocks/core__post-featured-image.json | 3 +- 5 files changed, 70 insertions(+), 3 deletions(-) diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index b091f9d143b7a..1da60d60aff08 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 diff --git a/packages/block-library/src/post-featured-image/block.json b/packages/block-library/src/post-featured-image/block.json index 34e3bd6b2325f..4c4ba6919eaff 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 843f1cf66cdfc..26f3439964f90 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+wp:(?:core\/)?image\s+(?<attrs>{(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*)?}\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 4a7aa2f3d8ab9..9a1fd315bb952 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( '<img>' ); + $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/test/integration/fixtures/blocks/core__post-featured-image.json b/test/integration/fixtures/blocks/core__post-featured-image.json index 158007533a3f2..dec6e14712a3a 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": [] } From 1b75cf668be5b4eb7554e28ae5ae533a910346bc Mon Sep 17 00:00:00 2001 From: Ben Dwyer <ben@scruffian.com> Date: Thu, 4 Jan 2024 22:43:55 +0000 Subject: [PATCH 22/31] Navigation: Refactor mobile overlay breakpoints to JS (#57520) * Navigation: Refactor mobile overlay breakpoints to JS * remove unused code * refactor to shared function * fix PHPCS * also get the editor working * move the 600 value to constants * add a comment to explain why we don't need the JS for the overlayMenu 'always' mode * Use matchMedia instead of window.resize * remove useEffects and use a hook for the media query * Remove the listener when the component is unmounted * use wp-init instead of wp-watch since we don't need to be reactive * Don't mutate the DOM in directives * Update packages/block-library/src/navigation/edit/index.js Co-authored-by: Dave Smith <getdavemail@gmail.com> * Update packages/block-library/src/navigation/edit/index.js Co-authored-by: Dave Smith <getdavemail@gmail.com> * Add px to the constant * rename to NAVIGATION_MOBILE_COLLAPSE * Add a comment to the navigation CSS * Update lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php Co-authored-by: Dave Smith <getdavemail@gmail.com> --------- Co-authored-by: Dave Smith <getdavemail@gmail.com> --- .../class-wp-navigation-block-renderer.php | 33 +++++++++++++----- .../block-library/src/navigation/constants.js | 2 ++ .../src/navigation/edit/index.js | 12 ++++++- .../block-library/src/navigation/editor.scss | 2 +- .../block-library/src/navigation/style.scss | 34 ++++++++++--------- packages/block-library/src/navigation/view.js | 26 ++++++++++++++ 6 files changed, 82 insertions(+), 27 deletions(-) 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 ea94128e1dde2..9c270f59fa220 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/packages/block-library/src/navigation/constants.js b/packages/block-library/src/navigation/constants.js index 21fc8bfdfb74d..c712bc4000c36 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 2e94cddcc9bc2..5589e8ea9e60f 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 107fb6e6de5fd..eb796ae696541 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 0b70ebb656cfa..3f11c5564306c 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 fb3919168a267..d42832a1f8d02 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 ); + }; + }, }, } ); From e2bda4036db44339aa56d0c85fa3d75edd79cd81 Mon Sep 17 00:00:00 2001 From: Ramon <ramonjd@users.noreply.github.com> Date: Fri, 5 Jan 2024 09:58:21 +1100 Subject: [PATCH 23/31] The prop namespace should be "scope" (#57563) --- packages/edit-site/src/components/preferences-modal/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/edit-site/src/components/preferences-modal/index.js b/packages/edit-site/src/components/preferences-modal/index.js index c7ca111774410..8f6a8a5794cb9 100644 --- a/packages/edit-site/src/components/preferences-modal/index.js +++ b/packages/edit-site/src/components/preferences-modal/index.js @@ -129,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.' @@ -139,7 +139,7 @@ export default function EditSitePreferencesModal() { </PreferencesModalSection> <PreferencesModalSection title={ __( 'Interface' ) }> <EnableFeature - namespace="core" + scope="core" featureName="showIconLabels" label={ __( 'Show button text labels' ) } help={ __( From bb2a39f44b7a7a9ed0f3398bb9b2df5ece175458 Mon Sep 17 00:00:00 2001 From: Marco Ciampini <marco.ciampo@gmail.com> Date: Fri, 5 Jan 2024 00:57:55 +0100 Subject: [PATCH 24/31] Tooltip: improve tests (#57345) * Remove unnecessary RegExp * Use @ariakit/test instead of testing library * Use sleep utility instead of custom promise * Remove unnecessary import * Add utility functions * Make multiple children test actually test what it's supposed to test * Test for tooltip to hide when another element gets focus * Test for tooltip to hide when hovering outside the anchor * Improve tooltip hiding on click test * More utils refactor * Improve test with disabled anchor * Wait for tooltip to be hidden in the hideOnClick={false} test * Refactor custom delay test * Refactor "mouse leave early" test (skip as it's still not passing) * Improve shortcut-related tests * Improve the Modal Escape test * Refactor description text test * Add second-level describes * Improve TS expecte error message * Add missing async/await in test utils * Await for tooltip to appear in shortcut tests * Apply delay prop only when showing the tooltip * Add skip timeout waiting time after each test * CHANGELOG * Apply review feedback --- packages/components/CHANGELOG.md | 1 + packages/components/src/modal/index.tsx | 2 +- packages/components/src/tooltip/index.tsx | 2 +- .../components/src/tooltip/test/index.tsx | 616 ++++++++++-------- 4 files changed, 363 insertions(+), 258 deletions(-) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 9b27dd10c5ae0..8805736c2e440 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -30,6 +30,7 @@ - `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/modal/index.tsx b/packages/components/src/modal/index.tsx index b1bee51805f78..616539ed9b636 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 80407def54cd4..817d6d18812ee 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 4d58498e278d3..cbe144cfa53d4 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: <Button>Button</Button>, + children: <Button>Tooltip anchor</Button>, 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 - <Tooltip { ...props }> - <Button>This is a button</Button> - <Button>This is another button</Button> - </Tooltip> - ); - - 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( <Tooltip { ...props } /> ); +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( <Tooltip { ...props } /> ); +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 + <Tooltip { ...props }> + <Button>First button</Button> + <Button>Second button</Button> + </Tooltip> + ); - 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( <Tooltip { ...props } /> ); - render( <Tooltip { ...props } /> ); + // 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( <Tooltip { ...props } /> ); - 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( + <> + <Tooltip { ...props } /> + <button>Focus me</button> + </> + ); + + // 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( + <> + <Tooltip { ...props }> + <Button disabled __experimentalIsFocusable> + Tooltip anchor + </Button> + </Tooltip> + <button>Focus me</button> + </> + ); + + 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( <Tooltip { ...props } /> ); + + 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( + <> + <Tooltip { ...props }> + <Button disabled __experimentalIsFocusable> + Tooltip anchor + </Button> + </Tooltip> + <button>Focus me</button> + </> + ); + + 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( <Tooltip { ...props } /> ); + describe( 'mouse click', () => { + it( 'should hide tooltip when the tooltip anchor is clicked', async () => { + render( <Tooltip { ...props } /> ); - 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( - <Tooltip { ...props } delay={ TOOLTIP_DELAY + ADDITIONAL_DELAY } /> - ); + it( 'should not hide tooltip when the tooltip anchor is clicked and the `hideOnClick` prop is `false', async () => { + render( + <> + <Tooltip { ...props } hideOnClick={ false } /> + <button>Click me</button> + </> + ); - 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( - <Tooltip { ...props }> - <Button aria-disabled>Button</Button> - </Tooltip> - ); + render( + <Tooltip + { ...props } + delay={ TOOLTIP_DELAY + ADDITIONAL_DELAY } + /> + ); - 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( <Tooltip { ...props }> <Button onMouseEnter={ onMouseEnterMock } onMouseLeave={ onMouseLeaveMock } > - Button 1 + Tooltip anchor </Button> </Tooltip> - <Button>Button 2</Button> - </> - ); - - 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( <Tooltip { ...props } shortcut="shortcut text" /> ); + 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( - <Tooltip - { ...props } - shortcut={ { - display: '⇧⌘,', - ariaLabel: shortcutAriaLabel.primaryShift( ',' ), - } } - /> - ); + // 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 onRequestClose={ onRequestClose }> - <p>Modal content</p> - </Modal> - ); - - 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( <Tooltip { ...props } shortcut="shortcut text" /> ); + + // 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( + <Tooltip + { ...props } + shortcut={ { + display: '⇧⌘,', + ariaLabel: shortcutAriaLabel.primaryShift( ',' ), + } } + /> + ); + + // 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( <Tooltip { ...props } /> ); - - 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( <Tooltip { ...props } hideOnClick={ false } /> ); - - 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 onRequestClose={ onRequestClose }> + <p>Modal content</p> + </Modal> + ); + + 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(); + } ); } ); } ); From 946e056f7e7c709bf7146843b036f7d4813f90dc Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Fri, 5 Jan 2024 11:18:39 +1100 Subject: [PATCH 25/31] Background image: Add has-background classname when background image is applied (#57495) * Add has-background classname when background image is applied * Update tests * Move background image classname output to be handled alongside background color classnames --- lib/block-supports/background.php | 1 + packages/block-editor/src/hooks/background.js | 11 +++++++++++ packages/block-editor/src/hooks/color.js | 18 +++++++++++++++++- phpunit/block-supports/background-test.php | 12 ++++++------ 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/lib/block-supports/background.php b/lib/block-supports/background.php index c43603046c0c0..4b5f5614d64c9 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/packages/block-editor/src/hooks/background.js b/packages/block-editor/src/hooks/background.js index 67373ecd0516c..d093d3da55c8d 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 5767db829d1b3..0995f877309cc 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/phpunit/block-supports/background-test.php b/phpunit/block-supports/background-test.php index 9fa350ef36c4e..92e1d2fc345a0 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', From 915296f9be9a0d2eebbcc79269a519932e11e140 Mon Sep 17 00:00:00 2001 From: Daniel Richards <daniel.richards@automattic.com> Date: Fri, 5 Jan 2024 08:30:26 +0800 Subject: [PATCH 26/31] Remove duplicate setting for getPostLinkProps and prefer stable naming (#57535) --- packages/block-library/src/block/edit.js | 3 +-- .../src/components/provider/use-block-editor-settings.js | 8 +------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index 1892d614dba2a..19d1f8196df50 100644 --- a/packages/block-library/src/block/edit.js +++ b/packages/block-library/src/block/edit.js @@ -183,8 +183,7 @@ export default function ReusableBlockEdit( { innerBlocks: blocks, userCanEdit: canEdit, getBlockEditingMode: editingMode, - getPostLinkProps: - getSettings().__experimentalGetPostLinkProps, + getPostLinkProps: getSettings().getPostLinkProps, }; }, [ patternClientId, ref ] 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 9d60ef9cc6c48..e28ba9692dc28 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -50,6 +50,7 @@ const BLOCK_EDITOR_SETTINGS = [ 'fontSizes', 'gradients', 'generateAnchors', + 'getPostLinkProps', 'hasFixedToolbar', 'hasInlineToolbar', 'isDistractionFree', @@ -74,7 +75,6 @@ const BLOCK_EDITOR_SETTINGS = [ '__unstableIsBlockBasedTheme', '__experimentalArchiveTitleTypeLabel', '__experimentalArchiveTitleNameLabel', - '__experimentalGetPostLinkProps', ]; /** @@ -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' ) @@ -145,7 +142,6 @@ function useBlockEditorSettings( settings, postType, postId ) { userPatternCategories: getUserPatternCategories(), restBlockPatterns: getBlockPatterns(), restBlockPatternCategories: getBlockPatternCategories(), - getPostLinkProps: postLinkProps, }; }, [ postType, postId ] @@ -257,7 +253,6 @@ function useBlockEditorSettings( settings, postType, postId ) { ? [ [ 'core/navigation', {}, [] ] ] : settings.template, __experimentalSetIsInserterOpened: setIsInserterOpened, - __experimentalGetPostLinkProps: getPostLinkProps, } ), [ allowRightClickOverrides, @@ -278,7 +273,6 @@ function useBlockEditorSettings( settings, postType, postId ) { pageForPosts, postType, setIsInserterOpened, - getPostLinkProps, ] ); } From 6f61d8b802a880dc58eb490c17599cea0d614ba2 Mon Sep 17 00:00:00 2001 From: Kai Hao <kevin830726@gmail.com> Date: Fri, 5 Jan 2024 10:35:41 +0800 Subject: [PATCH 27/31] [Pattern Overrides] Fix duplication of inner blocks (#57538) --- packages/block-library/src/block/edit.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index 19d1f8196df50..607f073323d99 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 @@ -205,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 ) : [] ); From a889ef0a078011529a20468b72f99e5134855e87 Mon Sep 17 00:00:00 2001 From: Aki Hamano <54422211+t-hamano@users.noreply.github.com> Date: Fri, 5 Jan 2024 12:58:25 +0900 Subject: [PATCH 28/31] Search Block: Remove unused `buttonBehavior` attribute (#53467) * Search Block: Remove unused `buttonBehavior` attribute * Update fixture * Rebuild docs --- docs/reference-guides/core-blocks.md | 2 +- packages/block-library/src/search/block.json | 4 -- packages/block-library/src/search/edit.js | 8 +-- packages/block-library/src/search/index.php | 10 +--- packages/block-library/src/search/style.scss | 56 +++++++++---------- .../fixtures/blocks/core__search.json | 1 - .../blocks/core__search__custom-text.json | 1 - 7 files changed, 32 insertions(+), 50 deletions(-) diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index 1da60d60aff08..c05cdd3eb009b 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -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/packages/block-library/src/search/block.json b/packages/block-library/src/search/block.json index 5669a9089d0e0..15531475adc9a 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 2d39494c28239..0123bdfd56569 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 f5391eefc8cac..ae6ddb1c4fb37 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 b8a446721241b..4e283530a0e27 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/test/integration/fixtures/blocks/core__search.json b/test/integration/fixtures/blocks/core__search.json index f692eac10993d..ec961ed41b024 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 c763cb60f65e8..3738816762ba1 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": [] From 7946a0de0bd9b36f435a6913624fcaf626fecd3b Mon Sep 17 00:00:00 2001 From: Daniel Richards <daniel.richards@automattic.com> Date: Fri, 5 Jan 2024 13:23:19 +0800 Subject: [PATCH 29/31] Code Quality: Rename `patternBlock` to `patternPost` (#57568) * Improve naming of pattern conversion funciton * Rename patterBlock to patternPost --- .../dataviews-pattern-actions.js | 4 +- .../page-patterns/duplicate-menu-item.js | 2 +- .../src/components/page-patterns/grid-item.js | 4 +- .../components/page-patterns/use-patterns.js | 39 ++++++++++++------- 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/packages/edit-site/src/components/page-patterns/dataviews-pattern-actions.js b/packages/edit-site/src/components/page-patterns/dataviews-pattern-actions.js index dd0f630ba5b5f..a7e050660e954 100644 --- a/packages/edit-site/src/components/page-patterns/dataviews-pattern-actions.js +++ b/packages/edit-site/src/components/page-patterns/dataviews-pattern-actions.js @@ -36,8 +36,8 @@ export const exportJSONaction = { 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( `${ kebabCase( item.title || item.name ) }.json`, 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 118c954a851f3..e82666902ed16 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 bacb0f3190863..8d2cbaf7806b4 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 be5992bd9b4ef..a0b82247c85a6 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; From 2e39fb60b0ea2b5ea45a7c34a9bb0b69451d17db Mon Sep 17 00:00:00 2001 From: George Mamadashvili <georgemamadashvili@gmail.com> Date: Fri, 5 Jan 2024 10:10:39 +0400 Subject: [PATCH 30/31] Migrate 'scheduling' e2e tests to Playwright (#57539) * Migrate 'scheduling' e2e tests to Playwright * Remove old test file --- .../specs/editor/various/scheduling.test.js | 65 -------------- .../specs/editor/various/scheduling.spec.js | 90 +++++++++++++++++++ 2 files changed, 90 insertions(+), 65 deletions(-) delete mode 100644 packages/e2e-tests/specs/editor/various/scheduling.test.js create mode 100644 test/e2e/specs/editor/various/scheduling.spec.js 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 df75dcb92f282..0000000000000 --- 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/test/e2e/specs/editor/various/scheduling.spec.js b/test/e2e/specs/editor/various/scheduling.spec.js new file mode 100644 index 0000000000000..1fa41a79ea7cc --- /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(); + } ); +} ); From 93805f4edf3bc017aea8c034467adfec2234bd46 Mon Sep 17 00:00:00 2001 From: George Mamadashvili <georgemamadashvili@gmail.com> Date: Fri, 5 Jan 2024 12:32:14 +0400 Subject: [PATCH 31/31] Migrate 'datepicker' e2e tests to Playwright (#57545) * Migrate 'datepicker' e2e tests to Playwright * Remove old test file --- .../specs/editor/various/datepicker.test.js | 148 ------------------ .../specs/editor/various/datepicker.spec.js | 114 ++++++++++++++ 2 files changed, 114 insertions(+), 148 deletions(-) delete mode 100644 packages/e2e-tests/specs/editor/various/datepicker.test.js create mode 100644 test/e2e/specs/editor/various/datepicker.spec.js 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 6838fd56a2ba9..0000000000000 --- 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/test/e2e/specs/editor/various/datepicker.spec.js b/test/e2e/specs/editor/various/datepicker.spec.js new file mode 100644 index 0000000000000..00030efa1fe27 --- /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' ); + } ); + } ); +} );