From 932cc0d1a53a0d01e016df0cb169fdc8e00e9d4e Mon Sep 17 00:00:00 2001 From: Bart Kalisz Date: Thu, 30 Nov 2023 11:20:11 +0000 Subject: [PATCH 01/16] Scripts: Enable skipping Playwright browser installation (#56594) --- .github/workflows/end2end-test.yml | 2 ++ bin/plugin/commands/performance.js | 1 + packages/scripts/scripts/test-playwright.js | 31 +++++++++++++-------- packages/scripts/utils/index.js | 2 ++ packages/scripts/utils/process.js | 6 ++++ 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/.github/workflows/end2end-test.yml b/.github/workflows/end2end-test.yml index 777549e334aa96..5a9750c6bb0456 100644 --- a/.github/workflows/end2end-test.yml +++ b/.github/workflows/end2end-test.yml @@ -88,6 +88,8 @@ jobs: npm run wp-env start - name: Run the tests + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 run: | xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test:e2e:playwright -- --shard=${{ matrix.part }}/${{ matrix.totalParts }} diff --git a/bin/plugin/commands/performance.js b/bin/plugin/commands/performance.js index 4be675a0a5d40d..bdc38347e40c86 100644 --- a/bin/plugin/commands/performance.js +++ b/bin/plugin/commands/performance.js @@ -87,6 +87,7 @@ async function runTestSuite( testSuite, testRunnerDir, runKey ) { testRunnerDir, { ...process.env, + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1', WP_ARTIFACTS_PATH: ARTIFACTS_PATH, RESULTS_ID: runKey, } diff --git a/packages/scripts/scripts/test-playwright.js b/packages/scripts/scripts/test-playwright.js index 71bc6a63320cf1..4a8b0762336abd 100644 --- a/packages/scripts/scripts/test-playwright.js +++ b/packages/scripts/scripts/test-playwright.js @@ -24,21 +24,28 @@ const { hasProjectFile, hasArgInCLI, getArgsFromCLI, + getAsBooleanFromENV, } = require( '../utils' ); -const result = spawn( - 'node', - [ - path.resolve( require.resolve( 'playwright-core' ), '..', 'cli.js' ), - 'install', - ], - { - stdio: 'inherit', - } -); +if ( ! getAsBooleanFromENV( 'PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD' ) ) { + const result = spawn( + 'node', + [ + path.resolve( + require.resolve( 'playwright-core' ), + '..', + 'cli.js' + ), + 'install', + ], + { + stdio: 'inherit', + } + ); -if ( result.status > 0 ) { - process.exit( result.status ); + if ( result.status > 0 ) { + process.exit( result.status ); + } } const config = diff --git a/packages/scripts/utils/index.js b/packages/scripts/utils/index.js index 870c2423361b53..ae93160381df44 100644 --- a/packages/scripts/utils/index.js +++ b/packages/scripts/utils/index.js @@ -1,6 +1,7 @@ /** * Internal dependencies */ +const { getAsBooleanFromENV } = require( './process' ); const { getArgFromCLI, getArgsFromCLI, @@ -28,6 +29,7 @@ const { getPackageProp, hasPackageProp } = require( './package' ); module.exports = { fromProjectRoot, fromConfigRoot, + getAsBooleanFromENV, getArgFromCLI, getArgsFromCLI, getFileArgsFromCLI, diff --git a/packages/scripts/utils/process.js b/packages/scripts/utils/process.js index de07d36a8b59c4..48b884dc085bce 100644 --- a/packages/scripts/utils/process.js +++ b/packages/scripts/utils/process.js @@ -1,3 +1,8 @@ +const getAsBooleanFromENV = ( name ) => { + const value = process.env[ name ]; + return !! value && value !== 'false' && value !== '0'; +}; + const getArgsFromCLI = ( excludePrefixes ) => { const args = process.argv.slice( 2 ); if ( excludePrefixes ) { @@ -12,6 +17,7 @@ const getArgsFromCLI = ( excludePrefixes ) => { module.exports = { exit: process.exit, + getAsBooleanFromENV, getArgsFromCLI, getCurrentWorkingDirectory: process.cwd, }; From bbf7105c552d8eb981f97e60ed254fdae65a3293 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Thu, 30 Nov 2023 15:42:24 +0400 Subject: [PATCH 02/16] Migrate 'editor multi entity saving' e2e tests to Playwright (#56670) * Migrate 'editor multi entity saving' e2e tests to Playwright * Remove old test file --- .../site-editor/multi-entity-saving.test.js | 239 ------------------ .../various/multi-entity-saving.spec.js | 210 +++++++++++++++ 2 files changed, 210 insertions(+), 239 deletions(-) delete mode 100644 packages/e2e-tests/specs/site-editor/multi-entity-saving.test.js create mode 100644 test/e2e/specs/editor/various/multi-entity-saving.spec.js diff --git a/packages/e2e-tests/specs/site-editor/multi-entity-saving.test.js b/packages/e2e-tests/specs/site-editor/multi-entity-saving.test.js deleted file mode 100644 index fe5334d2a12769..00000000000000 --- a/packages/e2e-tests/specs/site-editor/multi-entity-saving.test.js +++ /dev/null @@ -1,239 +0,0 @@ -/** - * WordPress dependencies - */ -import { - createNewPost, - disablePrePublishChecks, - getOption, - insertBlock, - publishPost, - setOption, - trashAllPosts, - activateTheme, - clickButton, - createReusableBlock, - deleteAllTemplates, - canvas, -} from '@wordpress/e2e-test-utils'; - -describe( 'Multi-entity save flow', () => { - // Selectors - usable between Post/Site editors. - const checkedBoxSelector = '.components-checkbox-control__checked'; - const checkboxInputSelector = '.components-checkbox-control__input'; - const entitiesSaveSelector = '.editor-entities-saved-states__save-button'; - const savePanelSelector = '.entities-saved-states__panel'; - const closePanelButtonSelector = - '.editor-post-publish-panel__header-cancel-button button:not(:disabled)'; - - // Reusable assertions across Post/Site editors. - const assertAllBoxesChecked = async () => { - const checkedBoxes = await page.$$( checkedBoxSelector ); - const checkboxInputs = await page.$$( checkboxInputSelector ); - expect( checkedBoxes.length - checkboxInputs.length ).toBe( 0 ); - }; - const assertExistence = async ( selector, shouldBePresent ) => { - const element = await page.$( selector ); - if ( shouldBePresent ) { - expect( element ).not.toBeNull(); - } else { - expect( element ).toBeNull(); - } - }; - - let originalSiteTitle, originalBlogDescription; - - beforeAll( async () => { - await activateTheme( 'emptytheme' ); - await deleteAllTemplates( 'wp_template' ); - await deleteAllTemplates( 'wp_template_part' ); - await trashAllPosts( 'wp_block' ); - - // Get the current Site Title and Site Tagline, so that we can reset - // them back to the original values once the test suite has finished. - originalSiteTitle = await getOption( 'blogname' ); - originalBlogDescription = await getOption( 'blogdescription' ); - } ); - - afterAll( async () => { - await activateTheme( 'twentytwentyone' ); - - // Reset the Site Title and Site Tagline back to their original values. - await setOption( 'blogname', originalSiteTitle ); - await setOption( 'blogdescription', originalBlogDescription ); - } ); - - describe( 'Post Editor', () => { - // Selectors - Post editor specific. - const draftSavedSelector = '.editor-post-saved-state.is-saved'; - const multiSaveSelector = - '.editor-post-publish-button__button.has-changes-dot'; - const savePostSelector = '.editor-post-publish-button__button'; - const enabledSavePostSelector = `${ savePostSelector }[aria-disabled=false]`; - const publishA11ySelector = - '.edit-post-layout__toggle-publish-panel-button'; - const saveA11ySelector = - '.edit-post-layout__toggle-entities-saved-states-panel-button'; - const publishPanelSelector = '.editor-post-publish-panel'; - - // Reusable assertions inside Post editor. - const assertMultiSaveEnabled = async () => { - const multiSaveButton = - await page.waitForSelector( multiSaveSelector ); - expect( multiSaveButton ).not.toBeNull(); - }; - const assertMultiSaveDisabled = async () => { - const multiSaveButton = await page.waitForSelector( - multiSaveSelector, - { hidden: true } - ); - expect( multiSaveButton ).toBeNull(); - }; - - it( 'Save flow should work as expected.', async () => { - await createNewPost(); - // Edit the page some. - await canvas().waitForSelector( '.editor-post-title' ); - await canvas().click( '.editor-post-title' ); - await page.keyboard.type( 'Test Post...' ); - await page.keyboard.press( 'Enter' ); - - // Should not trigger multi-entity save button with only post edited. - await assertMultiSaveDisabled(); - - // Should only have publish panel a11y button active with only post edited. - await assertExistence( publishA11ySelector, true ); - await assertExistence( saveA11ySelector, false ); - await assertExistence( publishPanelSelector, false ); - await assertExistence( savePanelSelector, false ); - - // Add a reusable block and edit it. - await createReusableBlock( 'Hi!', 'Test' ); - await canvas().waitForSelector( 'p[data-type="core/paragraph"]' ); - await canvas().click( 'p[data-type="core/paragraph"]' ); - await page.keyboard.type( 'Oh!' ); - - // Should trigger multi-entity save button once template part edited. - await assertMultiSaveEnabled(); - - // Should only have save panel a11y button active after child entities edited. - await assertExistence( publishA11ySelector, false ); - await assertExistence( saveA11ySelector, true ); - await assertExistence( publishPanelSelector, false ); - await assertExistence( savePanelSelector, false ); - - // Opening panel has boxes checked by default. - await page.click( savePostSelector ); - await page.waitForSelector( savePanelSelector ); - await assertAllBoxesChecked(); - - // Should not show other panels (or their a11y buttons) while save panel opened. - await assertExistence( publishA11ySelector, false ); - await assertExistence( saveA11ySelector, false ); - await assertExistence( publishPanelSelector, false ); - - // Publish panel should open after saving. - await page.click( entitiesSaveSelector ); - await page.waitForSelector( publishPanelSelector ); - - // No other panels (or their a11y buttons) should be present with publish panel open. - await assertExistence( publishA11ySelector, false ); - await assertExistence( saveA11ySelector, false ); - await assertExistence( savePanelSelector, false ); - - // Close publish panel. - const closePanelButton = await page.waitForSelector( - closePanelButtonSelector - ); - await closePanelButton.click(); - - // Verify saving is disabled. - const draftSaved = await page.waitForSelector( draftSavedSelector ); - expect( draftSaved ).not.toBeNull(); - await assertMultiSaveDisabled(); - await assertExistence( saveA11ySelector, false ); - - await publishPost(); - // Wait for the success notice specifically for the published post. - // `publishPost()` has a similar check but it only checks for the - // existence of any snackbars. In this case, there's another "Site updated" - // notice which will be sufficient for that and thus creating a false-positive. - await page.waitForXPath( - '//*[@id="a11y-speak-polite"][contains(text(), "Post published")]' - ); - - // Unselect the blocks to avoid clicking the block toolbar. - await page.evaluate( () => { - wp.data.dispatch( 'core/block-editor' ).clearSelectedBlock(); - } ); - - // Update the post. - await canvas().click( '.editor-post-title' ); - await page.keyboard.type( '...more title!' ); - - // Verify update button is enabled. - const enabledSaveButton = await page.waitForSelector( - enabledSavePostSelector - ); - expect( enabledSaveButton ).not.toBeNull(); - // Verify multi-entity saving not enabled. - await assertMultiSaveDisabled(); - await assertExistence( saveA11ySelector, false ); - - // Update reusable block again. - await canvas().click( 'p[data-type="core/paragraph"]' ); - // We need to click again due to the clickthrough overlays in reusable blocks. - await canvas().click( 'p[data-type="core/paragraph"]' ); - await page.keyboard.type( 'R!' ); - - // Multi-entity saving should be enabled. - await assertMultiSaveEnabled(); - await assertExistence( saveA11ySelector, true ); - } ); - - it( 'Site blocks should save individually', async () => { - await createNewPost(); - await disablePrePublishChecks(); - - await insertBlock( 'Site Title' ); - // Ensure title is retrieved before typing. - await page.waitForXPath( - `//a[contains(text(), "${ originalSiteTitle }")]` - ); - const editableSiteTitleSelector = - '.wp-block-site-title a[contenteditable="true"]'; - await canvas().waitForSelector( editableSiteTitleSelector ); - await canvas().focus( editableSiteTitleSelector ); - await page.keyboard.type( '...' ); - - await insertBlock( 'Site Tagline' ); - // Wait for the placeholder. - await canvas().waitForXPath( - '//span[@data-rich-text-placeholder="Write site tagline…"]' - ); - const editableSiteTagLineSelector = - '.wp-block-site-tagline[contenteditable="true"]'; - await canvas().waitForSelector( editableSiteTagLineSelector ); - await canvas().focus( editableSiteTagLineSelector ); - await page.keyboard.type( 'Just another WordPress site' ); - - await clickButton( 'Publish' ); - await page.waitForSelector( savePanelSelector ); - let checkboxInputs = await page.$$( checkboxInputSelector ); - expect( checkboxInputs ).toHaveLength( 3 ); - - await checkboxInputs[ 1 ].click(); - await page.click( entitiesSaveSelector ); - - // Wait for the snackbar notice that the post has been published. - await page.waitForSelector( '.components-snackbar' ); - - await clickButton( 'Update…' ); - await page.waitForSelector( savePanelSelector ); - - await page.waitForSelector( checkboxInputSelector ); - checkboxInputs = await page.$$( checkboxInputSelector ); - - expect( checkboxInputs ).toHaveLength( 1 ); - } ); - } ); -} ); diff --git a/test/e2e/specs/editor/various/multi-entity-saving.spec.js b/test/e2e/specs/editor/various/multi-entity-saving.spec.js new file mode 100644 index 00000000000000..7a7298c137c4b5 --- /dev/null +++ b/test/e2e/specs/editor/various/multi-entity-saving.spec.js @@ -0,0 +1,210 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Editor - Multi-entity save flow', () => { + let originalSiteTitle, originalBlogDescription; + + test.beforeEach( async ( { requestUtils } ) => { + const siteSettings = await requestUtils.getSiteSettings(); + + originalSiteTitle = siteSettings.title; + originalBlogDescription = siteSettings.description; + } ); + + test.afterEach( async ( { requestUtils, editor } ) => { + await requestUtils.updateSiteSettings( { + title: originalSiteTitle, + description: originalBlogDescription, + } ); + + // Restore the Publish sidebar. + await editor.setPreferences( 'core/edit-post', { + isPublishSidebarEnabled: true, + } ); + } ); + + test( 'Save flow should work as expected', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost(); + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Test Post...' ); + await page.keyboard.press( 'Enter' ); + + const topBar = page.getByRole( 'region', { name: 'Editor top bar' } ); + const publishButton = topBar.getByRole( 'button', { name: 'Publish' } ); + + // Should not trigger multi-entity save button with only post edited. + await expect( publishButton ).toBeEnabled(); + await expect( publishButton ).not.toHaveClass( /has-changes-dot/ ); + + const openPublishPanel = page.getByRole( 'button', { + name: 'Open publish panel', + } ); + const openSavePanel = page.getByRole( 'button', { + name: 'Open save panel', + } ); + const publishPanel = page.getByRole( 'region', { + name: 'Editor publish', + } ); + + // Should only have publish panel a11y button active with only post edited. + await expect( openPublishPanel ).toBeVisible(); + await expect( openSavePanel ).toBeHidden(); + await expect( publishPanel ).not.toContainText( + 'Are you ready to publish?' + ); + await expect( publishPanel ).not.toContainText( + 'Are you ready to save?' + ); + + // Add a title block and edit it. + await editor.insertBlock( { + name: 'core/site-title', + } ); + const siteTitleField = editor.canvas.getByRole( 'textbox', { + name: 'Site title text', + } ); + await siteTitleField.fill( `${ originalSiteTitle }...` ); + + // Should trigger multi-entity save button once template part edited. + await expect( publishButton ).toHaveClass( /has-changes-dot/ ); + + // Should only have save panel a11y button active after child entities edited. + await expect( openPublishPanel ).toBeHidden(); + await expect( openSavePanel ).toBeVisible(); + await expect( publishPanel ).not.toContainText( + 'Are you ready to publish?' + ); + await expect( publishPanel ).not.toContainText( + 'Are you ready to save?' + ); + + // Opening panel has boxes checked by default. + await publishButton.click(); + await expect( publishPanel ).toContainText( 'Are you ready to save?' ); + const allCheckboxes = await publishPanel + .getByRole( 'checkbox' ) + .count(); + await expect( + publishPanel.getByRole( 'checkbox', { checked: true } ) + ).toHaveCount( allCheckboxes ); + + // Should not show other panels (or their a11y buttons) while save panel opened. + await expect( openPublishPanel ).toBeHidden(); + await expect( openSavePanel ).toBeHidden(); + await expect( publishPanel ).not.toContainText( + 'Are you ready to publish?' + ); + + // Publish panel should open after saving. + await publishPanel.getByRole( 'button', { name: 'Save' } ).click(); + await expect( publishPanel ).toContainText( + 'Are you ready to publish?' + ); + + // No other panels (or their a11y buttons) should be present with publish panel open. + await expect( openPublishPanel ).toBeHidden(); + await expect( openSavePanel ).toBeHidden(); + await expect( publishPanel ).not.toContainText( + 'Are you ready to save?' + ); + + // Close publish panel. + await publishPanel.getByRole( 'button', { name: 'Cancel' } ).click(); + + // Verify saving is disabled. + await expect( + topBar.getByRole( 'button', { name: 'Saved' } ) + ).toBeDisabled(); + await expect( publishButton ).not.toHaveClass( /has-changes-dot/ ); + await expect( openSavePanel ).toBeHidden(); + + await editor.publishPost(); + + // Update the post. + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Updated post title' ); + + const updateButton = topBar.getByRole( 'button', { name: 'Update' } ); + + // Verify update button is enabled. + await expect( updateButton ).toBeEnabled(); + + // Verify multi-entity saving not enabled. + await expect( updateButton ).not.toHaveClass( /has-changes-dot/ ); + await expect( openSavePanel ).toBeHidden(); + + await siteTitleField.fill( `${ originalSiteTitle }!` ); + + // Multi-entity saving should be enabled. + await expect( updateButton ).toHaveClass( /has-changes-dot/ ); + await expect( openSavePanel ).toBeVisible(); + } ); + + test( 'Site blocks should save individually', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost(); + await editor.setPreferences( 'core/edit-post', { + isPublishSidebarEnabled: false, + } ); + + // Add site blocks. + await editor.insertBlock( { + name: 'core/site-title', + } ); + await editor.insertBlock( { + name: 'core/site-tagline', + } ); + + const siteTitleField = editor.canvas.getByRole( 'textbox', { + name: 'Site title text', + } ); + + // Ensure title is retrieved before typing. + await expect( siteTitleField ).toHaveText( originalSiteTitle ); + + await siteTitleField.fill( `${ originalSiteTitle }...` ); + await editor.canvas + .getByRole( 'document', { + name: 'Block: Site Tagline', + } ) + .fill( 'Just another WordPress site' ); + + const topBar = page.getByRole( 'region', { name: 'Editor top bar' } ); + const publishPanel = page.getByRole( 'region', { + name: 'Editor publish', + } ); + + await topBar.getByRole( 'button', { name: 'Publish' } ).click(); + await expect( publishPanel.getByRole( 'checkbox' ) ).toHaveCount( 3 ); + + // Skip site title saving. + await publishPanel + .getByRole( 'checkbox', { + name: 'Title', + } ) + .setChecked( false ); + + await publishPanel.getByRole( 'button', { name: 'Save' } ).click(); + + // Wait for the snackbar notice that the post has been published. + await page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'published' } ) + .waitFor(); + + await topBar.getByRole( 'button', { name: 'Update' } ).click(); + + await expect( publishPanel.getByRole( 'checkbox' ) ).toHaveCount( 1 ); + } ); +} ); From 615fc5ba137a6c5d6055a2eaf3ad3c6b51c44fae Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Thu, 30 Nov 2023 15:43:10 +0400 Subject: [PATCH 03/16] Migrate 'inner-blocks-locking-all-embed' e2e tests to Playwright (#56673) * Migrate 'inner-blocks-locking-all-embed' e2e tests to Playwright * Remove old test file --- .../plugins/innerblocks-locking-all-embed.js | 56 ------------------ .../inner-blocks-locking-all-embed.spec.js | 59 +++++++++++++++++++ 2 files changed, 59 insertions(+), 56 deletions(-) delete mode 100644 packages/e2e-tests/specs/editor/plugins/innerblocks-locking-all-embed.js create mode 100644 test/e2e/specs/editor/plugins/inner-blocks-locking-all-embed.spec.js diff --git a/packages/e2e-tests/specs/editor/plugins/innerblocks-locking-all-embed.js b/packages/e2e-tests/specs/editor/plugins/innerblocks-locking-all-embed.js deleted file mode 100644 index a0a83fbf90e616..00000000000000 --- a/packages/e2e-tests/specs/editor/plugins/innerblocks-locking-all-embed.js +++ /dev/null @@ -1,56 +0,0 @@ -/** - * WordPress dependencies - */ -import { - activatePlugin, - createNewPost, - deactivatePlugin, - insertBlock, - createEmbeddingMatcher, - createJSONResponse, - setUpResponseMocking, -} from '@wordpress/e2e-test-utils'; - -const MOCK_RESPONSES = [ - { - match: createEmbeddingMatcher( 'https://twitter.com/wordpress' ), - onRequestMatch: createJSONResponse( { - url: 'https://twitter.com/wordpress', - html: '

Mock success response.

', - type: 'rich', - provider_name: 'Twitter', - provider_url: 'https://twitter.com', - version: '1.0', - } ), - }, -]; - -describe( 'Embed block inside a locked all parent', () => { - beforeAll( async () => { - await activatePlugin( 'gutenberg-test-innerblocks-locking-all-embed' ); - } ); - - beforeEach( async () => { - await setUpResponseMocking( MOCK_RESPONSES ); - await createNewPost(); - } ); - - afterAll( async () => { - await deactivatePlugin( - 'gutenberg-test-innerblocks-locking-all-embed' - ); - } ); - - it( 'embed block should be able to embed external content', async () => { - await insertBlock( 'Test Inner Blocks Locking All Embed' ); - const embedInputSelector = - '.components-placeholder__input[aria-label="Embed URL"]'; - await page.waitForSelector( embedInputSelector ); - await page.click( embedInputSelector ); - // This URL should not have a trailing slash. - await page.keyboard.type( 'https://twitter.com/wordpress' ); - await page.keyboard.press( 'Enter' ); - // The twitter block should appear correctly. - await page.waitForSelector( 'figure.wp-block-embed' ); - } ); -} ); diff --git a/test/e2e/specs/editor/plugins/inner-blocks-locking-all-embed.spec.js b/test/e2e/specs/editor/plugins/inner-blocks-locking-all-embed.spec.js new file mode 100644 index 00000000000000..3bf0ff459cb7fe --- /dev/null +++ b/test/e2e/specs/editor/plugins/inner-blocks-locking-all-embed.spec.js @@ -0,0 +1,59 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +const EMBED_URLS = [ + '/oembed/1.0/proxy', + `rest_route=${ encodeURIComponent( '/oembed/1.0/proxy' ) }`, +]; +const MOCK_RESPONSES = { + url: 'https://twitter.com/wordpress', + html: '

Mock success response.

', + type: 'rich', + provider_name: 'Twitter', + provider_url: 'https://twitter.com', + version: '1.0', +}; + +test.describe( 'Embed block inside a locked all parent', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activatePlugin( + 'gutenberg-test-innerblocks-locking-all-embed' + ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deactivatePlugin( + 'gutenberg-test-innerblocks-locking-all-embed' + ); + } ); + + test( 'embed block should be able to embed external content', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost(); + await page.route( + ( url ) => EMBED_URLS.some( ( u ) => url.href.includes( u ) ), + async ( route ) => { + await route.fulfill( { + json: MOCK_RESPONSES, + } ); + } + ); + + await editor.insertBlock( { + name: 'test/test-inner-blocks-locking-all-embed', + } ); + await page + .getByRole( 'textbox', { name: 'Embed URL' } ) + .fill( 'https://twitter.com/wordpress' ); + await page.keyboard.press( 'Enter' ); + + await expect( + page.getByRole( 'document', { name: 'Block: Twitter' } ) + ).toBeVisible(); + } ); +} ); From e557042902d7822dfbd3c730931f4630ee6cddd2 Mon Sep 17 00:00:00 2001 From: James Koster Date: Thu, 30 Nov 2023 11:48:35 +0000 Subject: [PATCH 04/16] Align data view icon usage (#56602) --- .../edit-site/src/components/dataviews/view-actions.js | 10 +++++++--- .../src/components/sidebar-dataviews/dataview-item.js | 8 ++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/edit-site/src/components/dataviews/view-actions.js b/packages/edit-site/src/components/dataviews/view-actions.js index d3c6558e824f86..70248eb72d5030 100644 --- a/packages/edit-site/src/components/dataviews/view-actions.js +++ b/packages/edit-site/src/components/dataviews/view-actions.js @@ -9,10 +9,10 @@ import { import { chevronRightSmall, check, - blockTable, + formatListBullets, arrowUp, arrowDown, - grid, + category, columns, } from '@wordpress/icons'; import { __ } from '@wordpress/i18n'; @@ -276,7 +276,11 @@ function SortMenu( { fields, view, onChangeView } ) { ); } -const VIEW_TYPE_ICONS = { list: blockTable, grid, 'side-by-side': columns }; +const VIEW_TYPE_ICONS = { + list: formatListBullets, + grid: category, + 'side-by-side': columns, +}; export default function ViewActions( { fields, diff --git a/packages/edit-site/src/components/sidebar-dataviews/dataview-item.js b/packages/edit-site/src/components/sidebar-dataviews/dataview-item.js index c6d7bbe4a231ba..e4e7f1f3b96ed2 100644 --- a/packages/edit-site/src/components/sidebar-dataviews/dataview-item.js +++ b/packages/edit-site/src/components/sidebar-dataviews/dataview-item.js @@ -6,7 +6,7 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { page, columns, pullRight } from '@wordpress/icons'; +import { formatListBullets, category, drawerLeft } from '@wordpress/icons'; import { privateApis as routerPrivateApis } from '@wordpress/router'; import { __experimentalHStack as HStack } from '@wordpress/components'; @@ -19,7 +19,11 @@ import { unlock } from '../../lock-unlock'; const { useLocation } = unlock( routerPrivateApis ); function getDataViewIcon( type ) { - const icons = { list: page, grid: columns, 'side-by-side': pullRight }; + const icons = { + list: formatListBullets, + grid: category, + 'side-by-side': drawerLeft, + }; return icons[ type ]; } From 5f925409c239a347d5942a256c12f7ec91989c35 Mon Sep 17 00:00:00 2001 From: Gerardo Pacheco Date: Thu, 30 Nov 2023 12:56:36 +0100 Subject: [PATCH 05/16] Mobile Release v1.109.1 (#56644) * Release script: Update react-native-editor version to 1.109.0 * Release script: Update CHANGELOG for version 1.109.0 * Release script: Update podfile * Update `react-native-editor` changelog * Update `react-native-editor` changelog * Mobile - Fix issue when backspacing in an empty Paragraph block (#56496) * Bring changes from #55134 to the mobile code * Mobile - RichText - Force focus when the block is selected but the textinput is not, for cases when merging blocks. * Update Buttons integration test due to a change in the logic of the app where deleting the only button available does not remove the block * Mobile - Heading block - Adds integration test for merging a Heading block with an empty Paragraph block * Mobile - Paragraph block - Adds integration test to check that backspacing in an empty Paragraph block merges succesfully with the previous block and keeps the focus on the TextInput * Mobile - RichText - Set selection values to be the last character position when merging and adds some comments to explain what is doing * Mobile - Paragraph block test - Use focusTextInput to check the TextInput is in focused instead of checking for the fomatting toolbar button * Rename shouldFocusTextInputAfterUpdate to shouldFocusTextInputAfterMerge * Update CHANGELOG * Release script: Update react-native-editor version to 1.109.1 * Release script: Update CHANGELOG for version 1.109.1 * Release script: Update podfile --------- Co-authored-by: Carlos Garcia --- package-lock.json | 6 +++--- packages/react-native-aztec/package.json | 2 +- packages/react-native-bridge/package.json | 2 +- packages/react-native-editor/CHANGELOG.md | 2 ++ packages/react-native-editor/ios/Podfile.lock | 8 ++++---- packages/react-native-editor/package.json | 2 +- 6 files changed, 12 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 04ca205868578e..86196be6ee76c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56123,7 +56123,7 @@ }, "packages/react-native-aztec": { "name": "@wordpress/react-native-aztec", - "version": "1.109.0", + "version": "1.109.1", "license": "GPL-2.0-or-later", "dependencies": { "@wordpress/element": "file:../element", @@ -56136,7 +56136,7 @@ }, "packages/react-native-bridge": { "name": "@wordpress/react-native-bridge", - "version": "1.109.0", + "version": "1.109.1", "license": "GPL-2.0-or-later", "dependencies": { "@wordpress/react-native-aztec": "file:../react-native-aztec" @@ -56147,7 +56147,7 @@ }, "packages/react-native-editor": { "name": "@wordpress/react-native-editor", - "version": "1.109.0", + "version": "1.109.1", "hasInstallScript": true, "license": "GPL-2.0-or-later", "dependencies": { diff --git a/packages/react-native-aztec/package.json b/packages/react-native-aztec/package.json index 631781600d78d8..e814be90943a74 100644 --- a/packages/react-native-aztec/package.json +++ b/packages/react-native-aztec/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-native-aztec", - "version": "1.109.0", + "version": "1.109.1", "description": "Aztec view for react-native.", "private": true, "author": "The WordPress Contributors", diff --git a/packages/react-native-bridge/package.json b/packages/react-native-bridge/package.json index 20ae851c89686b..ca7cfc3de79bcf 100644 --- a/packages/react-native-bridge/package.json +++ b/packages/react-native-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-native-bridge", - "version": "1.109.0", + "version": "1.109.1", "description": "Native bridge library used to integrate the block editor into a native App.", "private": true, "author": "The WordPress Contributors", diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index 31429cb873c7c6..47b64e8418fcbc 100644 --- a/packages/react-native-editor/CHANGELOG.md +++ b/packages/react-native-editor/CHANGELOG.md @@ -11,6 +11,8 @@ For each user feature we should also add a importance categorization label to i ## Unreleased - [*] [internal] Move InserterButton from components package to block-editor package [#56494] + +## 1.109.1 - [***] Fix issue when backspacing in an empty Paragraph block [#56496] ## 1.109.0 diff --git a/packages/react-native-editor/ios/Podfile.lock b/packages/react-native-editor/ios/Podfile.lock index d6f0ca39a09bf2..dd3021ab8a6dba 100644 --- a/packages/react-native-editor/ios/Podfile.lock +++ b/packages/react-native-editor/ios/Podfile.lock @@ -13,7 +13,7 @@ PODS: - ReactCommon/turbomodule/core (= 0.71.11) - fmt (6.2.1) - glog (0.3.5) - - Gutenberg (1.109.0): + - Gutenberg (1.109.1): - React-Core (= 0.71.11) - React-CoreModules (= 0.71.11) - React-RCTImage (= 0.71.11) @@ -429,7 +429,7 @@ PODS: - React-RCTImage - RNSVG (13.9.0): - React-Core - - RNTAztecView (1.109.0): + - RNTAztecView (1.109.1): - React-Core - WordPress-Aztec-iOS (= 1.19.9) - SDWebImage (5.11.1): @@ -617,7 +617,7 @@ SPEC CHECKSUMS: FBReactNativeSpec: f07662560742d82a5b73cee116c70b0b49bcc220 fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b - Gutenberg: dd556a8be3f8b5225862823f050e57d0a22e0614 + Gutenberg: ce2b737d183d0179cb86596412bad21d48eafdcb hermes-engine: 34c863b446d0135b85a6536fa5fd89f48196f848 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 libwebp: 60305b2e989864154bd9be3d772730f08fc6a59c @@ -662,7 +662,7 @@ SPEC CHECKSUMS: RNReanimated: d4f363f4987ae0ade3e36ff81c94e68261bf4b8d RNScreens: 68fd1060f57dd1023880bf4c05d74784b5392789 RNSVG: 53c661b76829783cdaf9b7a57258f3d3b4c28315 - RNTAztecView: 8415d8e322e98d087b3f8fbba0669e84d6b235cb + RNTAztecView: 8d9b3bd517873101ab1ea89948b45c601bcedea0 SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d WordPress-Aztec-iOS: fbebd569c61baa252b3f5058c0a2a9a6ada686bb diff --git a/packages/react-native-editor/package.json b/packages/react-native-editor/package.json index b599c2cc51c65a..aa41fc9ffa1af1 100644 --- a/packages/react-native-editor/package.json +++ b/packages/react-native-editor/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-native-editor", - "version": "1.109.0", + "version": "1.109.1", "description": "Mobile WordPress gutenberg editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", From 074b3770e083d3ae9fc0055b43346d8bd76887b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= <583546+oandregal@users.noreply.github.com> Date: Thu, 30 Nov 2023 13:23:33 +0100 Subject: [PATCH 06/16] DataViews: export the view components as defaults (#56677) --- packages/edit-site/src/components/dataviews/dataviews.js | 6 +++--- packages/edit-site/src/components/dataviews/view-grid.js | 2 +- .../edit-site/src/components/dataviews/view-side-by-side.js | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/edit-site/src/components/dataviews/dataviews.js b/packages/edit-site/src/components/dataviews/dataviews.js index 56a9cfd7c6ae38..fc03c32008ee06 100644 --- a/packages/edit-site/src/components/dataviews/dataviews.js +++ b/packages/edit-site/src/components/dataviews/dataviews.js @@ -10,13 +10,13 @@ import { useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import ViewList from './view-list'; import Pagination from './pagination'; import ViewActions from './view-actions'; import Filters from './filters'; import Search from './search'; -import { ViewGrid } from './view-grid'; -import { ViewSideBySide } from './view-side-by-side'; +import ViewList from './view-list'; +import ViewGrid from './view-grid'; +import ViewSideBySide from './view-side-by-side'; // To do: convert to view type registry. export const viewTypeSupportsMap = { diff --git a/packages/edit-site/src/components/dataviews/view-grid.js b/packages/edit-site/src/components/dataviews/view-grid.js index 597f3b13bd3091..8a39bdd8353d1a 100644 --- a/packages/edit-site/src/components/dataviews/view-grid.js +++ b/packages/edit-site/src/components/dataviews/view-grid.js @@ -13,7 +13,7 @@ import { useAsyncList } from '@wordpress/compose'; */ import ItemActions from './item-actions'; -export function ViewGrid( { data, fields, view, actions, getItemId } ) { +export default function ViewGrid( { data, fields, view, actions, getItemId } ) { const mediaField = fields.find( ( field ) => field.id === view.layout.mediaField ); diff --git a/packages/edit-site/src/components/dataviews/view-side-by-side.js b/packages/edit-site/src/components/dataviews/view-side-by-side.js index 47b1551b379b31..9b06ca799096eb 100644 --- a/packages/edit-site/src/components/dataviews/view-side-by-side.js +++ b/packages/edit-site/src/components/dataviews/view-side-by-side.js @@ -3,7 +3,7 @@ */ import ViewList from './view-list'; -export function ViewSideBySide( props ) { +export default function ViewSideBySide( props ) { // To do: change to email-like preview list. return ; } From 50c10dad6ff6c8ea6ce33d3a89a56eac9201a9fa Mon Sep 17 00:00:00 2001 From: Ella <4710635+ellatrix@users.noreply.github.com> Date: Thu, 30 Nov 2023 13:39:17 +0100 Subject: [PATCH 07/16] setImmutably: don't clone all objects (#56612) --- packages/block-editor/src/utils/object.js | 80 +++++------------------ 1 file changed, 16 insertions(+), 64 deletions(-) diff --git a/packages/block-editor/src/utils/object.js b/packages/block-editor/src/utils/object.js index e54990db1d0666..526f037899fd02 100644 --- a/packages/block-editor/src/utils/object.js +++ b/packages/block-editor/src/utils/object.js @@ -4,27 +4,6 @@ import { paramCase } from 'change-case'; import memoize from 'memize'; -/** - * Converts a path to an array of its fragments. - * Supports strings, numbers and arrays: - * - * 'foo' => [ 'foo' ] - * 2 => [ '2' ] - * [ 'foo', 'bar' ] => [ 'foo', 'bar' ] - * - * @param {string|number|Array} path Path - * @return {Array} Normalized path. - */ -function normalizePath( path ) { - if ( Array.isArray( path ) ) { - return path; - } else if ( typeof path === 'number' ) { - return [ path.toString() ]; - } - - return [ path ]; -} - /** * Converts any string to kebab case. * Backwards compatible with Lodash's `_.kebabCase()`. @@ -55,33 +34,6 @@ export function kebabCase( str ) { } ); } -/** - * Clones an object. - * Arrays are also cloned as arrays. - * Non-object values are returned unchanged. - * - * @param {*} object Object to clone. - * @return {*} Cloned object, or original literal non-object value. - */ -function cloneObject( object ) { - if ( Array.isArray( object ) ) { - return object.map( cloneObject ); - } - - if ( object && typeof object === 'object' ) { - return { - ...Object.fromEntries( - Object.entries( object ).map( ( [ key, value ] ) => [ - key, - cloneObject( value ), - ] ) - ), - }; - } - - return object; -} - /** * Immutably sets a value inside an object. Like `lodash#set`, but returning a * new object. Treats nullish initial values as empty objects. Clones any @@ -93,24 +45,24 @@ function cloneObject( object ) { * @return {Object} Cloned object with the new value set. */ export function setImmutably( object, path, value ) { - const normalizedPath = normalizePath( path ); - const newObject = object ? cloneObject( object ) : {}; + // Normalize path + path = Array.isArray( path ) ? [ ...path ] : [ path ]; - normalizedPath.reduce( ( acc, key, i ) => { - if ( acc[ key ] === undefined ) { - if ( Number.isInteger( path[ i + 1 ] ) ) { - acc[ key ] = []; - } else { - acc[ key ] = {}; - } - } - if ( i === normalizedPath.length - 1 ) { - acc[ key ] = value; - } - return acc[ key ]; - }, newObject ); + // Shallowly clone the base of the object + object = Array.isArray( object ) ? [ ...object ] : { ...object }; - return newObject; + const leaf = path.pop(); + + // Traverse object from root to leaf, shallowly cloning at each level + let prev = object; + for ( const key of path ) { + const lvl = prev[ key ]; + prev = prev[ key ] = Array.isArray( lvl ) ? [ ...lvl ] : { ...lvl }; + } + + prev[ leaf ] = value; + + return object; } const stringToPath = memoize( ( path ) => path.split( '.' ) ); From 99028935dbd3b6150222b18711261a6eb6408e84 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Thu, 30 Nov 2023 17:01:06 +0400 Subject: [PATCH 08/16] Migrate 'site editor export' e2e tests to Playwright (#56675) * Migrate 'site editor export' e2e tests to Playwright * Remove old test file * Simplify * Use query argument enabling edit mode --- .../site-editor/site-editor-export.test.js | 63 ------------------- .../site-editor/site-editor-export.spec.js | 38 +++++++++++ 2 files changed, 38 insertions(+), 63 deletions(-) delete mode 100644 packages/e2e-tests/specs/site-editor/site-editor-export.test.js create mode 100644 test/e2e/specs/site-editor/site-editor-export.spec.js diff --git a/packages/e2e-tests/specs/site-editor/site-editor-export.test.js b/packages/e2e-tests/specs/site-editor/site-editor-export.test.js deleted file mode 100644 index 0e560e9b7e0ade..00000000000000 --- a/packages/e2e-tests/specs/site-editor/site-editor-export.test.js +++ /dev/null @@ -1,63 +0,0 @@ -/** - * External dependencies - */ -import fs from 'fs'; -import path from 'path'; -import os from 'os'; - -/** - * WordPress dependencies - */ -import { - deleteAllTemplates, - activateTheme, - visitSiteEditor, - enterEditMode, - clickOnMoreMenuItem, -} from '@wordpress/e2e-test-utils'; - -async function waitForFileExists( filePath, timeout = 10000 ) { - const start = Date.now(); - while ( ! fs.existsSync( filePath ) ) { - // Puppeteer doesn't have an API for managing file downloads. - // We are using `waitForTimeout` to add delays between check of file existence. - // eslint-disable-next-line no-restricted-syntax - await page.waitForTimeout( 1000 ); - if ( Date.now() - start > timeout ) { - throw Error( 'waitForFileExists timeout' ); - } - } -} - -describe( 'Site Editor Templates Export', () => { - beforeAll( async () => { - await activateTheme( 'emptytheme' ); - await deleteAllTemplates( 'wp_template' ); - await deleteAllTemplates( 'wp_template_part' ); - } ); - - afterAll( async () => { - await activateTheme( 'twentytwentyone' ); - } ); - - beforeEach( async () => { - await visitSiteEditor(); - await enterEditMode(); - } ); - - it( 'clicking export should download emptytheme.zip file', async () => { - const directory = fs.mkdtempSync( - path.join( os.tmpdir(), 'test-edit-site-export-' ) - ); - await page._client.send( 'Page.setDownloadBehavior', { - behavior: 'allow', - downloadPath: directory, - } ); - - await clickOnMoreMenuItem( 'Export', 'site-editor' ); - const filePath = path.join( directory, 'emptytheme.zip' ); - await waitForFileExists( filePath ); - expect( fs.existsSync( filePath ) ).toBe( true ); - fs.unlinkSync( filePath ); - } ); -} ); diff --git a/test/e2e/specs/site-editor/site-editor-export.spec.js b/test/e2e/specs/site-editor/site-editor-export.spec.js new file mode 100644 index 00000000000000..a0a56c18089cc2 --- /dev/null +++ b/test/e2e/specs/site-editor/site-editor-export.spec.js @@ -0,0 +1,38 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Site Editor Templates Export', () => { + test.beforeAll( async ( { requestUtils } ) => { + await Promise.all( [ + requestUtils.activateTheme( 'emptytheme' ), + requestUtils.deleteAllTemplates( 'wp_template' ), + requestUtils.deleteAllTemplates( 'wp_template_part' ), + ] ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentyone' ); + } ); + + test( 'clicking export should download emptytheme.zip file', async ( { + admin, + page, + } ) => { + await admin.visitSiteEditor( { + postId: 'emptytheme//index', + postType: 'wp_template', + canvas: 'edit', + } ); + await page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Options' } ) + .click(); + + const promise = page.waitForEvent( 'download' ); + await page.getByRole( 'menuitem', { name: 'Export' } ).click(); + const download = await promise; + expect( download.suggestedFilename() ).toBe( 'emptytheme.zip' ); + } ); +} ); From 28e4385fd33ee94b84f0185762c5d75c52149e6d Mon Sep 17 00:00:00 2001 From: Alex Lende Date: Thu, 30 Nov 2023 08:40:13 -0600 Subject: [PATCH 09/16] Fix order of typography sizes and families (#56659) --- .../global-styles/typography-panel.js | 66 +++++++------------ .../src/components/use-settings/index.js | 16 ++++- 2 files changed, 38 insertions(+), 44 deletions(-) diff --git a/packages/block-editor/src/components/global-styles/typography-panel.js b/packages/block-editor/src/components/global-styles/typography-panel.js index 1b41db748df3c2..103b1e63b75b5e 100644 --- a/packages/block-editor/src/components/global-styles/typography-panel.js +++ b/packages/block-editor/src/components/global-styles/typography-panel.js @@ -13,6 +13,7 @@ import { useCallback } from '@wordpress/element'; /** * Internal dependencies */ +import { mergeOrigins, hasMergedOrigins } from '../use-settings'; import FontFamilyControl from '../font-family'; import FontAppearanceControl from '../font-appearance-control'; import LineHeightControl from '../line-height-control'; @@ -51,25 +52,14 @@ export function useHasTypographyPanel( settings ) { } function useHasFontSizeControl( settings ) { - const disableCustomFontSizes = ! settings?.typography?.customFontSize; - const fontSizesPerOrigin = settings?.typography?.fontSizes ?? {}; - const fontSizes = [] - .concat( fontSizesPerOrigin?.custom ?? [] ) - .concat( fontSizesPerOrigin?.theme ?? [] ) - .concat( fontSizesPerOrigin.default ?? [] ); - return !! fontSizes?.length || ! disableCustomFontSizes; + return ( + hasMergedOrigins( settings?.typography?.fontSizes ) || + settings?.typography?.customFontSize + ); } function useHasFontFamilyControl( settings ) { - const fontFamiliesPerOrigin = settings?.typography?.fontFamilies; - const fontFamilies = [] - .concat( fontFamiliesPerOrigin?.custom ?? [] ) - .concat( fontFamiliesPerOrigin?.theme ?? [] ) - .concat( fontFamiliesPerOrigin?.default ?? [] ) - .sort( ( a, b ) => - ( a?.name || a?.slug )?.localeCompare( b?.name || a?.slug ) - ); - return !! fontFamilies?.length; + return hasMergedOrigins( settings?.typography?.fontFamilies ); } function useHasLineHeightControl( settings ) { @@ -77,18 +67,14 @@ function useHasLineHeightControl( settings ) { } function useHasAppearanceControl( settings ) { - const hasFontStyles = settings?.typography?.fontStyle; - const hasFontWeights = settings?.typography?.fontWeight; - return hasFontStyles || hasFontWeights; + return settings?.typography?.fontStyle || settings?.typography?.fontWeight; } function useAppearanceControlLabel( settings ) { - const hasFontStyles = settings?.typography?.fontStyle; - const hasFontWeights = settings?.typography?.fontWeight; - if ( ! hasFontStyles ) { + if ( ! settings?.typography?.fontStyle ) { return __( 'Font weight' ); } - if ( ! hasFontWeights ) { + if ( ! settings?.typography?.fontWeight ) { return __( 'Font style' ); } return __( 'Appearance' ); @@ -115,18 +101,15 @@ function useHasTextColumnsControl( settings ) { } function getUniqueFontSizesBySlug( settings ) { - const fontSizesPerOrigin = settings?.typography?.fontSizes ?? {}; - const fontSizes = [] - .concat( fontSizesPerOrigin?.custom ?? [] ) - .concat( fontSizesPerOrigin?.theme ?? [] ) - .concat( fontSizesPerOrigin.default ?? [] ); - - return fontSizes.reduce( ( acc, currentSize ) => { - if ( ! acc.some( ( { slug } ) => slug === currentSize.slug ) ) { - acc.push( currentSize ); + const fontSizes = settings?.typography?.fontSizes; + const mergedFontSizes = fontSizes ? mergeOrigins( fontSizes ) : []; + const uniqueSizes = []; + for ( const currentSize of mergedFontSizes ) { + if ( ! uniqueSizes.some( ( { slug } ) => slug === currentSize.slug ) ) { + uniqueSizes.push( currentSize ); } - return acc; - }, [] ); + } + return uniqueSizes; } function TypographyToolsPanel( { @@ -178,14 +161,11 @@ export default function TypographyPanel( { // Font Family const hasFontFamilyEnabled = useHasFontFamilyControl( settings ); - const fontFamiliesPerOrigin = settings?.typography?.fontFamilies; - const fontFamilies = [] - .concat( fontFamiliesPerOrigin?.custom ?? [] ) - .concat( fontFamiliesPerOrigin?.theme ?? [] ) - .concat( fontFamiliesPerOrigin?.default ?? [] ); + const fontFamilies = settings?.typography?.fontFamilies; + const mergedFontFamilies = fontFamilies ? mergeOrigins( fontFamilies ) : []; const fontFamily = decodeValue( inheritedValue?.typography?.fontFamily ); const setFontFamily = ( newValue ) => { - const slug = fontFamilies?.find( + const slug = mergedFontFamilies?.find( ( { fontFamily: f } ) => f === newValue )?.slug; onChange( @@ -204,7 +184,7 @@ export default function TypographyPanel( { // Font Size const hasFontSizeEnabled = useHasFontSizeControl( settings ); const disableCustomFontSizes = ! settings?.typography?.customFontSize; - const fontSizes = getUniqueFontSizesBySlug( settings ); + const mergedFontSizes = getUniqueFontSizesBySlug( settings ); const fontSize = decodeValue( inheritedValue?.typography?.fontSize ); const setFontSize = ( newValue, metadata ) => { @@ -368,7 +348,7 @@ export default function TypographyPanel( { panelId={ panelId } > { * @param {Object} value Object to merge * @return {Array} Array of merged items */ -function mergeOrigins( value ) { +export function mergeOrigins( value ) { let result = mergeCache.get( value ); if ( ! result ) { result = [ 'default', 'theme', 'custom' ].flatMap( @@ -115,6 +115,20 @@ function mergeOrigins( value ) { } const mergeCache = new WeakMap(); +/** + * For settings like `color.palette`, which have a value that is an object + * with `default`, `theme`, `custom`, with field values that are arrays of + * items, see if any of the three origins have values. + * + * @param {Object} value Object to check + * @return {boolean} Whether the object has values in any of the three origins + */ +export function hasMergedOrigins( value ) { + return [ 'default', 'theme', 'custom' ].some( + ( key ) => value?.[ key ]?.length + ); +} + /** * Hook that retrieves the given settings for the block instance in use. * From a4e1414e7ee5c7cbcb01d71228f1d29af276863f Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Thu, 30 Nov 2023 19:27:40 +0400 Subject: [PATCH 10/16] Editor: Avoid double parsing content in 'getSuggestedPostFormat' selector (#56679) --- .../reference-guides/data/data-core-editor.md | 4 - packages/editor/src/store/selectors.js | 82 +++--- packages/editor/src/store/test/selectors.js | 235 +++++++----------- 3 files changed, 129 insertions(+), 192 deletions(-) diff --git a/docs/reference-guides/data/data-core-editor.md b/docs/reference-guides/data/data-core-editor.md index 4774934651b139..0bfa052cf15229 100644 --- a/docs/reference-guides/data/data-core-editor.md +++ b/docs/reference-guides/data/data-core-editor.md @@ -547,10 +547,6 @@ Returns state object prior to a specified optimist transaction ID, or `null` if Returns a suggested post format for the current post, inferred only if there is a single block within the post and it is of a type known to match a default post format. Returns null if the format cannot be determined. -_Parameters_ - -- _state_ `Object`: Global application state. - _Returns_ - `?string`: Suggested post format. diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index 78944335bd3981..7aaa2f970a5241 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -819,54 +819,54 @@ export function getEditedPostPreviewLink( state ) { * is a single block within the post and it is of a type known to match a * default post format. Returns null if the format cannot be determined. * - * @param {Object} state Global application state. - * * @return {?string} Suggested post format. */ -export function getSuggestedPostFormat( state ) { - const blocks = getEditorBlocks( state ); - - if ( blocks.length > 2 ) return null; - - let name; - // If there is only one block in the content of the post grab its name - // so we can derive a suitable post format from it. - if ( blocks.length === 1 ) { - name = blocks[ 0 ].name; - // Check for core/embed `video` and `audio` eligible suggestions. - if ( name === 'core/embed' ) { - const provider = blocks[ 0 ].attributes?.providerNameSlug; - if ( [ 'youtube', 'vimeo' ].includes( provider ) ) { - name = 'core/video'; - } else if ( [ 'spotify', 'soundcloud' ].includes( provider ) ) { - name = 'core/audio'; +export const getSuggestedPostFormat = createRegistrySelector( + ( select ) => () => { + const blocks = select( blockEditorStore ).getBlocks(); + + if ( blocks.length > 2 ) return null; + + let name; + // If there is only one block in the content of the post grab its name + // so we can derive a suitable post format from it. + if ( blocks.length === 1 ) { + name = blocks[ 0 ].name; + // Check for core/embed `video` and `audio` eligible suggestions. + if ( name === 'core/embed' ) { + const provider = blocks[ 0 ].attributes?.providerNameSlug; + if ( [ 'youtube', 'vimeo' ].includes( provider ) ) { + name = 'core/video'; + } else if ( [ 'spotify', 'soundcloud' ].includes( provider ) ) { + name = 'core/audio'; + } } } - } - // If there are two blocks in the content and the last one is a text blocks - // grab the name of the first one to also suggest a post format from it. - if ( blocks.length === 2 && blocks[ 1 ].name === 'core/paragraph' ) { - name = blocks[ 0 ].name; - } + // If there are two blocks in the content and the last one is a text blocks + // grab the name of the first one to also suggest a post format from it. + if ( blocks.length === 2 && blocks[ 1 ].name === 'core/paragraph' ) { + name = blocks[ 0 ].name; + } - // We only convert to default post formats in core. - switch ( name ) { - case 'core/image': - return 'image'; - case 'core/quote': - case 'core/pullquote': - return 'quote'; - case 'core/gallery': - return 'gallery'; - case 'core/video': - return 'video'; - case 'core/audio': - return 'audio'; - default: - return null; + // We only convert to default post formats in core. + switch ( name ) { + case 'core/image': + return 'image'; + case 'core/quote': + case 'core/pullquote': + return 'quote'; + case 'core/gallery': + return 'gallery'; + case 'core/video': + return 'video'; + case 'core/audio': + return 'audio'; + default: + return null; + } } -} +); /** * Returns the content of the post being edited. diff --git a/packages/editor/src/store/test/selectors.js b/packages/editor/src/store/test/selectors.js index c18377c4e385e7..211ff717c88bda 100644 --- a/packages/editor/src/store/test/selectors.js +++ b/packages/editor/src/store/test/selectors.js @@ -122,6 +122,10 @@ selectorNames.forEach( ( name ) => { }, }; }, + + getBlocks() { + return state.getBlocks && state.getBlocks(); + }, } ); selectorNames.forEach( ( otherName ) => { @@ -2155,16 +2159,9 @@ describe( 'selectors', () => { describe( 'getSuggestedPostFormat', () => { it( 'returns null if cannot be determined', () => { const state = { - editor: { - present: { - blocks: { - value: [], - }, - edits: {}, - }, + getBlocks() { + return []; }, - initialEdits: {}, - currentPost: {}, }; expect( getSuggestedPostFormat( state ) ).toBeNull(); @@ -2172,77 +2169,56 @@ describe( 'selectors', () => { it( 'return null if only one block of type `core/embed` and provider not matched', () => { const state = { - editor: { - present: { - blocks: { - value: [ - { - clientId: 567, - name: 'core/embed', - attributes: { - providerNameSlug: 'instagram', - }, - innerBlocks: [], - }, - ], + getBlocks() { + return [ + { + clientId: 567, + name: 'core/embed', + attributes: { + providerNameSlug: 'instagram', + }, + innerBlocks: [], }, - edits: {}, - }, + ]; }, - initialEdits: {}, - currentPost: {}, }; expect( getSuggestedPostFormat( state ) ).toBeNull(); } ); it( 'return null if only one block of type `core/embed` and provider not exists', () => { const state = { - editor: { - present: { - blocks: { - value: [ - { - clientId: 567, - name: 'core/embed', - attributes: {}, - innerBlocks: [], - }, - ], + getBlocks() { + return [ + { + clientId: 567, + name: 'core/embed', + attributes: {}, + innerBlocks: [], }, - edits: {}, - }, + ]; }, - initialEdits: {}, - currentPost: {}, }; expect( getSuggestedPostFormat( state ) ).toBeNull(); } ); it( 'returns null if there is more than one block in the post', () => { const state = { - editor: { - present: { - blocks: { - value: [ - { - clientId: 123, - name: 'core/image', - attributes: {}, - innerBlocks: [], - }, - { - clientId: 456, - name: 'core/quote', - attributes: {}, - innerBlocks: [], - }, - ], + getBlocks() { + return [ + { + clientId: 123, + name: 'core/image', + attributes: {}, + innerBlocks: [], }, - edits: {}, - }, + { + clientId: 456, + name: 'core/quote', + attributes: {}, + innerBlocks: [], + }, + ]; }, - initialEdits: {}, - currentPost: {}, }; expect( getSuggestedPostFormat( state ) ).toBeNull(); @@ -2250,23 +2226,16 @@ describe( 'selectors', () => { it( 'returns Image if the first block is of type `core/image`', () => { const state = { - editor: { - present: { - blocks: { - value: [ - { - clientId: 123, - name: 'core/image', - attributes: {}, - innerBlocks: [], - }, - ], + getBlocks() { + return [ + { + clientId: 123, + name: 'core/image', + attributes: {}, + innerBlocks: [], }, - edits: {}, - }, + ]; }, - initialEdits: {}, - currentPost: {}, }; expect( getSuggestedPostFormat( state ) ).toBe( 'image' ); @@ -2274,23 +2243,16 @@ describe( 'selectors', () => { it( 'returns Quote if the first block is of type `core/quote`', () => { const state = { - editor: { - present: { - blocks: { - value: [ - { - clientId: 456, - name: 'core/quote', - attributes: {}, - innerBlocks: [], - }, - ], + getBlocks() { + return [ + { + clientId: 456, + name: 'core/quote', + attributes: {}, + innerBlocks: [], }, - edits: {}, - }, + ]; }, - initialEdits: {}, - currentPost: {}, }; expect( getSuggestedPostFormat( state ) ).toBe( 'quote' ); @@ -2298,25 +2260,18 @@ describe( 'selectors', () => { it( 'returns Video if the first block is of type `core/embed from youtube`', () => { const state = { - editor: { - present: { - blocks: { - value: [ - { - clientId: 567, - name: 'core/embed', - attributes: { - providerNameSlug: 'youtube', - }, - innerBlocks: [], - }, - ], + getBlocks() { + return [ + { + clientId: 567, + name: 'core/embed', + attributes: { + providerNameSlug: 'youtube', + }, + innerBlocks: [], }, - edits: {}, - }, + ]; }, - initialEdits: {}, - currentPost: {}, }; expect( getSuggestedPostFormat( state ) ).toBe( 'video' ); @@ -2324,25 +2279,18 @@ describe( 'selectors', () => { it( 'returns Audio if the first block is of type `core/embed from soundcloud`', () => { const state = { - editor: { - present: { - blocks: { - value: [ - { - clientId: 567, - name: 'core/embed', - attributes: { - providerNameSlug: 'soundcloud', - }, - innerBlocks: [], - }, - ], + getBlocks() { + return [ + { + clientId: 567, + name: 'core/embed', + attributes: { + providerNameSlug: 'soundcloud', + }, + innerBlocks: [], }, - edits: {}, - }, + ]; }, - initialEdits: {}, - currentPost: {}, }; expect( getSuggestedPostFormat( state ) ).toBe( 'audio' ); @@ -2350,29 +2298,22 @@ describe( 'selectors', () => { it( 'returns Quote if the first block is of type `core/quote` and second is of type `core/paragraph`', () => { const state = { - editor: { - present: { - blocks: { - value: [ - { - clientId: 456, - name: 'core/quote', - attributes: {}, - innerBlocks: [], - }, - { - clientId: 789, - name: 'core/paragraph', - attributes: {}, - innerBlocks: [], - }, - ], + getBlocks() { + return [ + { + clientId: 456, + name: 'core/quote', + attributes: {}, + innerBlocks: [], }, - edits: {}, - }, + { + clientId: 789, + name: 'core/paragraph', + attributes: {}, + innerBlocks: [], + }, + ]; }, - initialEdits: {}, - currentPost: {}, }; expect( getSuggestedPostFormat( state ) ).toBe( 'quote' ); From aa197879d62140dbe3169aaece8c91abf550aaf8 Mon Sep 17 00:00:00 2001 From: Brooke <35543432+brookewp@users.noreply.github.com> Date: Thu, 30 Nov 2023 09:32:28 -0800 Subject: [PATCH 11/16] `QueryControls`: Add opt-in prop for 40px default size (#56576) * `QueryControls`: Add opt-in prop for 40px default size * Update RangeControl 40px size to be opt-in * Update changelog --- packages/components/CHANGELOG.md | 6 +++++- packages/components/src/query-controls/author-select.tsx | 2 ++ .../components/src/query-controls/category-select.tsx | 2 ++ packages/components/src/query-controls/index.tsx | 7 ++++++- packages/components/src/query-controls/types.ts | 9 +++++++++ 5 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 0f4c8ebb8a08cf..6a818780d9dc1e 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Enhancements + +- `QueryControls`: Add opt-in prop for 40px default size ([#56576](https://github.com/WordPress/gutenberg/pull/56576)). + ## 25.13.0 (2023-11-29) ### Enhancements @@ -23,7 +27,7 @@ ### Documentation -- `Text` and `Heading`: improve docs around default values and truncation logic ([#56518](https://github.com/WordPress/gutenberg/pull/56518)) +- `Text` and `Heading`: improve docs around default values and truncation logic ([#56518](https://github.com/WordPress/gutenberg/pull/56518)) ### Internal diff --git a/packages/components/src/query-controls/author-select.tsx b/packages/components/src/query-controls/author-select.tsx index fb5f575108230d..f5f4feb9525f15 100644 --- a/packages/components/src/query-controls/author-select.tsx +++ b/packages/components/src/query-controls/author-select.tsx @@ -6,6 +6,7 @@ import TreeSelect from '../tree-select'; import type { AuthorSelectProps } from './types'; export default function AuthorSelect( { + __next40pxDefaultSize, label, noOptionLabel, authorList, @@ -28,6 +29,7 @@ export default function AuthorSelect( { : undefined } __nextHasNoMarginBottom + __next40pxDefaultSize={ __next40pxDefaultSize } /> ); } diff --git a/packages/components/src/query-controls/category-select.tsx b/packages/components/src/query-controls/category-select.tsx index 9f9c1b3a0f07c8..bc2306ff048fa1 100644 --- a/packages/components/src/query-controls/category-select.tsx +++ b/packages/components/src/query-controls/category-select.tsx @@ -11,6 +11,7 @@ import { useMemo } from '@wordpress/element'; import type { CategorySelectProps } from './types'; export default function CategorySelect( { + __next40pxDefaultSize, label, noOptionLabel, categoriesList, @@ -37,6 +38,7 @@ export default function CategorySelect( { } { ...props } __nextHasNoMarginBottom + __next40pxDefaultSize={ __next40pxDefaultSize } /> ); } diff --git a/packages/components/src/query-controls/index.tsx b/packages/components/src/query-controls/index.tsx index 6c3c6ba952a062..ee207b6da82b9e 100644 --- a/packages/components/src/query-controls/index.tsx +++ b/packages/components/src/query-controls/index.tsx @@ -60,6 +60,7 @@ function isMultipleCategorySelection( * ``` */ export function QueryControls( { + __next40pxDefaultSize = false, authorList, selectedAuthorId, numberOfItems, @@ -81,6 +82,7 @@ export function QueryControls( { onOrderChange && onOrderByChange && ( void; selectedCategoryId?: Category[ 'id' ]; + __next40pxDefaultSize: boolean; }; export type AuthorSelectProps = Pick< @@ -40,6 +41,7 @@ export type AuthorSelectProps = Pick< authorList?: Author[]; onChange: ( newAuthor: string ) => void; selectedAuthorId?: Author[ 'id' ]; + __next40pxDefaultSize: boolean; }; type Order = 'asc' | 'desc'; @@ -101,6 +103,13 @@ type BaseQueryControlsProps = { * The selected author ID. */ selectedAuthorId?: AuthorSelectProps[ 'selectedAuthorId' ]; + /** + * Start opting into the larger default height that will become the + * default size in a future version. + * + * @default false + */ + __next40pxDefaultSize?: boolean; }; export type QueryControlsWithSingleCategorySelectionProps = From 482a507a11a57c0abf715dbabe762459e8958f4f Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Thu, 30 Nov 2023 18:34:23 +0100 Subject: [PATCH 12/16] [RNMobile] Fixes a crash on pasting MS Word list markup (#56653) * Add polyfill for Element.prototype.remove * Enable unit tests of `raw-handling` API filter `ms-list-converter` * Update `react-native-editor` changelog --- packages/react-native-editor/CHANGELOG.md | 1 + packages/react-native-editor/src/jsdom-patches.js | 12 ++++++++++++ test/native/jest.config.js | 1 - 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index 47b64e8418fcbc..5c37c7dd1b5b2d 100644 --- a/packages/react-native-editor/CHANGELOG.md +++ b/packages/react-native-editor/CHANGELOG.md @@ -14,6 +14,7 @@ For each user feature we should also add a importance categorization label to i ## 1.109.1 - [***] Fix issue when backspacing in an empty Paragraph block [#56496] +- [**] Fixes a crash on pasting MS Word list markup [#56653] ## 1.109.0 - [*] Audio block: Improve legibility of audio file details on various background colors [#55627] diff --git a/packages/react-native-editor/src/jsdom-patches.js b/packages/react-native-editor/src/jsdom-patches.js index f33dd892f8c18c..c86e4c82f31270 100644 --- a/packages/react-native-editor/src/jsdom-patches.js +++ b/packages/react-native-editor/src/jsdom-patches.js @@ -171,6 +171,18 @@ Element.prototype.closest = function ( selector ) { return null; }; +/** + * Implementation of Element.prototype.remove based on polyfills: + * - https://github.com/chenzhenxi/element-remove/blob/master/index.js + * (referenced in https://developer.mozilla.org/en-US/docs/Web/API/Element/remove#see_also) + * - https://github.com/JakeChampion/polyfill-library/blob/master/polyfills/Element/prototype/remove/polyfill.js + */ +Element.prototype.remove = function () { + if ( this.parentNode ) { + this.parentNode.removeChild( this ); + } +}; + /** * Helper function to check if a node implements the NonDocumentTypeChildNode * interface diff --git a/test/native/jest.config.js b/test/native/jest.config.js index ad5c794ebbce88..4859ea597e0f63 100644 --- a/test/native/jest.config.js +++ b/test/native/jest.config.js @@ -24,7 +24,6 @@ const transpiledPackageNames = glob( 'packages/*/src/index.{js,ts}' ).map( const RAW_HANDLING_UNSUPPORTED_UNIT_TESTS = [ 'html-formatting-remover', 'phrasing-content-reducer', - 'ms-list-converter', 'figure-content-reducer', 'special-comment-converter', 'normalise-blocks', From 7b1f24c60e7ee71f3391349acf58d14f0bd03ce1 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 30 Nov 2023 21:42:03 +0100 Subject: [PATCH 13/16] FormToggle: do not use "/" math operator (#56672) * FormToggle: do not use "/" math operator * Use math.div * CHANGELOG --- packages/components/CHANGELOG.md | 1 + packages/components/src/form-toggle/style.scss | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 6a818780d9dc1e..106863c020c5d9 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -4,6 +4,7 @@ ### Enhancements +- `FormToggle`: fix sass deprecation warning ([#56672](https://github.com/WordPress/gutenberg/pull/56672)). - `QueryControls`: Add opt-in prop for 40px default size ([#56576](https://github.com/WordPress/gutenberg/pull/56576)). ## 25.13.0 (2023-11-29) diff --git a/packages/components/src/form-toggle/style.scss b/packages/components/src/form-toggle/style.scss index 314f6fa36d12f2..d04ad4c651f862 100644 --- a/packages/components/src/form-toggle/style.scss +++ b/packages/components/src/form-toggle/style.scss @@ -1,3 +1,5 @@ +@use "sass:math"; + $toggle-width: 36px; $toggle-height: 18px; $toggle-border-width: 1px; @@ -20,7 +22,7 @@ $transition-duration: 0.2s; border: $toggle-border-width solid $gray-900; width: $toggle-width; height: $toggle-height; - border-radius: $toggle-height * 0.5; + border-radius: math.div($toggle-height, 2); transition: $transition-duration background-color ease, $transition-duration border-color ease; @@ -59,7 +61,7 @@ $transition-duration: 0.2s; background-color: $gray-900; // Transparent border acts as a fill in Windows High Contrast Mode. - border: $thumb-size / 2 solid transparent; + border: math.div($thumb-size, 2) solid transparent; } // Checked state. From 16eb0095190a1373ab2bd24c27ce833b599b8c34 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Fri, 1 Dec 2023 10:56:22 +0800 Subject: [PATCH 14/16] Implement partially synced patterns behind an experimental flag (#56235) * Try using the block connections api * Fix native * Rename dynamicContent to overrides * Add new experiment flag for partial syncing * Automatically assign block ids to pattern blocks for use in partial synching of pattern instances (#56495) * Automatically assign block ids * Downgrade package to fix tooling * Move to the editor package and allow core/button * Fix test resolver * Fix the flag and remove support for core/button * Address code reviews * Rename to pattern/overrides * Fix php lint error --------- Co-authored-by: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Co-authored-by: Glen Davies --- lib/block-supports/pattern.php | 36 ++++ lib/experimental/blocks.php | 39 +++-- lib/experimental/connection-sources/index.php | 8 +- lib/experimental/editor-settings.php | 4 + lib/experiments-page.php | 12 ++ lib/load.php | 1 + package-lock.json | 6 +- .../block-editor/src/hooks/custom-fields.js | 7 +- .../block-editor/src/store/private-actions.js | 8 + packages/block-editor/src/store/reducer.js | 8 + packages/block-library/src/block/edit.js | 159 +++++++++++++++-- packages/block-library/src/block/index.js | 5 +- packages/block-library/src/block/index.php | 48 ++++++ packages/block-library/src/block/v1/edit.js | 163 ++++++++++++++++++ .../src/block/{ => v1}/edit.native.js | 4 +- packages/editor/src/hooks/index.js | 1 + .../src/hooks/pattern-partial-syncing.js | 73 ++++++++ packages/patterns/package.json | 3 +- .../components/partial-syncing-controls.js | 98 +++++++++++ packages/patterns/src/constants.js | 10 ++ packages/patterns/src/private-apis.js | 4 + test/unit/scripts/resolver.js | 3 +- 22 files changed, 661 insertions(+), 39 deletions(-) create mode 100644 lib/block-supports/pattern.php create mode 100644 packages/block-library/src/block/v1/edit.js rename packages/block-library/src/block/{ => v1}/edit.native.js (98%) create mode 100644 packages/editor/src/hooks/pattern-partial-syncing.js create mode 100644 packages/patterns/src/components/partial-syncing-controls.js diff --git a/lib/block-supports/pattern.php b/lib/block-supports/pattern.php new file mode 100644 index 00000000000000..a783135c793e3f --- /dev/null +++ b/lib/block-supports/pattern.php @@ -0,0 +1,36 @@ +supports, array( '__experimentalConnections' ), false ) : false; + + if ( $pattern_support ) { + if ( ! $block_type->uses_context ) { + $block_type->uses_context = array(); + } + + if ( ! in_array( 'pattern/overrides', $block_type->uses_context, true ) ) { + $block_type->uses_context[] = 'pattern/overrides'; + } + } + } + + // Register the block support. + WP_Block_Supports::get_instance()->register( + 'pattern', + array( + 'register_attribute' => 'gutenberg_register_pattern_support', + ) + ); +} diff --git a/lib/experimental/blocks.php b/lib/experimental/blocks.php index f9f2412ae51205..88e46b478389d2 100644 --- a/lib/experimental/blocks.php +++ b/lib/experimental/blocks.php @@ -82,7 +82,10 @@ function wp_enqueue_block_view_script( $block_name, $args ) { $gutenberg_experiments = get_option( 'gutenberg-experiments' ); -if ( $gutenberg_experiments && array_key_exists( 'gutenberg-connections', $gutenberg_experiments ) ) { +if ( $gutenberg_experiments && ( + array_key_exists( 'gutenberg-connections', $gutenberg_experiments ) || + array_key_exists( 'gutenberg-pattern-partial-syncing', $gutenberg_experiments ) +) ) { /** * Renders the block meta attributes. * @@ -132,9 +135,8 @@ function gutenberg_render_block_connections( $block_content, $block, $block_inst continue; } - // If the source value is not "meta_fields", skip it because the only supported - // connection source is meta (custom fields) for now. - if ( 'meta_fields' !== $attribute_value['source'] ) { + // Skip if the source value is not "meta_fields" or "pattern_attributes". + if ( 'meta_fields' !== $attribute_value['source'] && 'pattern_attributes' !== $attribute_value['source'] ) { continue; } @@ -143,16 +145,28 @@ function gutenberg_render_block_connections( $block_content, $block, $block_inst continue; } - // If the attribute does not specify the name of the custom field, skip it. - if ( ! isset( $attribute_value['value'] ) ) { - continue; + if ( 'pattern_attributes' === $attribute_value['source'] ) { + if ( ! _wp_array_get( $block_instance->attributes, array( 'metadata', 'id' ), false ) ) { + continue; + } + + $custom_value = $connection_sources[ $attribute_value['source'] ]( $block_instance ); + } else { + // If the attribute does not specify the name of the custom field, skip it. + if ( ! isset( $attribute_value['value'] ) ) { + continue; + } + + // Get the content from the connection source. + $custom_value = $connection_sources[ $attribute_value['source'] ]( + $block_instance, + $attribute_value['value'] + ); } - // Get the content from the connection source. - $custom_value = $connection_sources[ $attribute_value['source'] ]( - $block_instance, - $attribute_value['value'] - ); + if ( false === $custom_value ) { + continue; + } $tags = new WP_HTML_Tag_Processor( $block_content ); $found = $tags->next_tag( @@ -181,5 +195,6 @@ function gutenberg_render_block_connections( $block_content, $block, $block_inst return $block_content; } + add_filter( 'render_block', 'gutenberg_render_block_connections', 10, 3 ); } diff --git a/lib/experimental/connection-sources/index.php b/lib/experimental/connection-sources/index.php index b63abcad96f628..bf89ba177b6e94 100644 --- a/lib/experimental/connection-sources/index.php +++ b/lib/experimental/connection-sources/index.php @@ -6,10 +6,14 @@ */ return array( - 'name' => 'meta', - 'meta_fields' => function ( $block_instance, $meta_field ) { + 'name' => 'meta', + 'meta_fields' => function ( $block_instance, $meta_field ) { // We should probably also check if the meta field exists but for now it's okay because // if it doesn't, `get_post_meta()` will just return an empty string. return get_post_meta( $block_instance->context['postId'], $meta_field, true ); }, + 'pattern_attributes' => function ( $block_instance ) { + $block_id = $block_instance->attributes['metadata']['id']; + return _wp_array_get( $block_instance->context, array( 'pattern/overrides', $block_id ), false ); + }, ); diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index 2c7d6310005bfa..5f61684e8b1342 100644 --- a/lib/experimental/editor-settings.php +++ b/lib/experimental/editor-settings.php @@ -33,6 +33,10 @@ function gutenberg_enable_experiments() { if ( gutenberg_is_experiment_enabled( 'gutenberg-no-tinymce' ) ) { wp_add_inline_script( 'wp-block-library', 'window.__experimentalDisableTinymce = true', 'before' ); } + + if ( $gutenberg_experiments && array_key_exists( 'gutenberg-pattern-partial-syncing', $gutenberg_experiments ) ) { + wp_add_inline_script( 'wp-block-editor', 'window.__experimentalPatternPartialSyncing = true', 'before' ); + } } add_action( 'admin_init', 'gutenberg_enable_experiments' ); diff --git a/lib/experiments-page.php b/lib/experiments-page.php index 0bcd28b2aa2c49..b77a69b692ff1f 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -138,6 +138,18 @@ function gutenberg_initialize_experiments_settings() { ) ); + add_settings_field( + 'gutenberg-pattern-partial-syncing', + __( 'Synced patterns partial syncing', 'gutenberg' ), + 'gutenberg_display_experiment_field', + 'gutenberg-experiments', + 'gutenberg_experiments_section', + array( + 'label' => __( 'Test partial syncing of patterns', 'gutenberg' ), + 'id' => 'gutenberg-pattern-partial-syncing', + ) + ); + register_setting( 'gutenberg-experiments', 'gutenberg-experiments' diff --git a/lib/load.php b/lib/load.php index 5da1a1126da26b..9c7618dbfc678b 100644 --- a/lib/load.php +++ b/lib/load.php @@ -248,6 +248,7 @@ function () { require __DIR__ . '/block-supports/shadow.php'; require __DIR__ . '/block-supports/background.php'; require __DIR__ . '/block-supports/behaviors.php'; +require __DIR__ . '/block-supports/pattern.php'; // Data views. require_once __DIR__ . '/experimental/data-views.php'; diff --git a/package-lock.json b/package-lock.json index 86196be6ee76c1..94a60889e63ccf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55952,7 +55952,8 @@ "@wordpress/icons": "file:../icons", "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", - "@wordpress/url": "file:../url" + "@wordpress/url": "file:../url", + "nanoid": "^3.3.4" }, "engines": { "node": ">=16.0.0" @@ -70962,7 +70963,8 @@ "@wordpress/icons": "file:../icons", "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", - "@wordpress/url": "file:../url" + "@wordpress/url": "file:../url", + "nanoid": "^3.3.4" } }, "@wordpress/plugins": { diff --git a/packages/block-editor/src/hooks/custom-fields.js b/packages/block-editor/src/hooks/custom-fields.js index adb9df15824a77..8ab816abc7352a 100644 --- a/packages/block-editor/src/hooks/custom-fields.js +++ b/packages/block-editor/src/hooks/custom-fields.js @@ -128,12 +128,17 @@ const withCustomFieldsControls = createHigherOrderComponent( ( BlockEdit ) => { }; }, 'withCustomFieldsControls' ); -if ( window.__experimentalConnections ) { +if ( + window.__experimentalConnections || + window.__experimentalPatternPartialSyncing +) { addFilter( 'blocks.registerBlockType', 'core/editor/connections/attribute', addAttribute ); +} +if ( window.__experimentalConnections ) { addFilter( 'editor.BlockEdit', 'core/editor/connections/with-inspector-controls', diff --git a/packages/block-editor/src/store/private-actions.js b/packages/block-editor/src/store/private-actions.js index dc62614b576812..1c29948d814165 100644 --- a/packages/block-editor/src/store/private-actions.js +++ b/packages/block-editor/src/store/private-actions.js @@ -290,3 +290,11 @@ export function deleteStyleOverride( id ) { id, }; } + +export function syncDerivedBlockAttributes( clientId, attributes ) { + return { + type: 'SYNC_DERIVED_BLOCK_ATTRIBUTES', + clientIds: [ clientId ], + attributes, + }; +} diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index d5ff85e9e4257b..5319a3b2553654 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -283,6 +283,7 @@ const withBlockTree = false ); break; + case 'SYNC_DERIVED_BLOCK_ATTRIBUTES': case 'UPDATE_BLOCK_ATTRIBUTES': { newState.tree = new Map( newState.tree ); action.clientIds.forEach( ( clientId ) => { @@ -456,6 +457,12 @@ function withPersistentBlockChange( reducer ) { return ( state, action ) => { let nextState = reducer( state, action ); + if ( action.type === 'SYNC_DERIVED_BLOCK_ATTRIBUTES' ) { + return nextState.isPersistentChange + ? { ...nextState, isPersistentChange: false } + : nextState; + } + const isExplicitPersistentChange = action.type === 'MARK_LAST_CHANGE_AS_PERSISTENT' || markNextChangeAsNotPersistent; @@ -860,6 +867,7 @@ export const blocks = pipe( return newState; } + case 'SYNC_DERIVED_BLOCK_ATTRIBUTES': case 'UPDATE_BLOCK_ATTRIBUTES': { // Avoid a state change if none of the block IDs are known. if ( action.clientIds.every( ( id ) => ! state.get( id ) ) ) { diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index 979ae04c62282c..e86ed9b59c62b2 100644 --- a/packages/block-library/src/block/edit.js +++ b/packages/block-library/src/block/edit.js @@ -6,11 +6,9 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { - useEntityBlockEditor, - useEntityProp, - useEntityRecord, -} from '@wordpress/core-data'; +import { useRegistry, useSelect, useDispatch } from '@wordpress/data'; +import { useRef, useMemo, useEffect } from '@wordpress/element'; +import { useEntityProp, useEntityRecord } from '@wordpress/core-data'; import { Placeholder, Spinner, @@ -27,8 +25,9 @@ import { useBlockProps, Warning, privateApis as blockEditorPrivateApis, + store as blockEditorStore, } from '@wordpress/block-editor'; -import { useRef, useMemo } from '@wordpress/element'; +import { getBlockSupport, parse } from '@wordpress/blocks'; /** * Internal dependencies @@ -36,6 +35,24 @@ import { useRef, useMemo } from '@wordpress/element'; import { unlock } from '../lock-unlock'; const { useLayoutClasses } = unlock( blockEditorPrivateApis ); + +function isPartiallySynced( block ) { + return ( + !! getBlockSupport( block.name, '__experimentalConnections', false ) && + !! block.attributes.connections?.attributes && + Object.values( block.attributes.connections.attributes ).some( + ( connection ) => connection.source === 'pattern_attributes' + ) + ); +} +function getPartiallySyncedAttributes( block ) { + return Object.entries( block.attributes.connections.attributes ) + .filter( + ( [ , connection ] ) => connection.source === 'pattern_attributes' + ) + .map( ( [ attributeKey ] ) => attributeKey ); +} + const fullAlignments = [ 'full', 'wide', 'left', 'right' ]; const useInferredLayout = ( blocks, parentLayout ) => { @@ -67,11 +84,61 @@ const useInferredLayout = ( blocks, parentLayout ) => { }, [ blocks, parentLayout ] ); }; +function applyInitialOverrides( blocks, overrides = {}, defaultValues ) { + return blocks.map( ( block ) => { + const innerBlocks = applyInitialOverrides( + block.innerBlocks, + overrides, + defaultValues + ); + const blockId = block.attributes.metadata?.id; + if ( ! isPartiallySynced( block ) || ! blockId ) + return { ...block, innerBlocks }; + const attributes = getPartiallySyncedAttributes( block ); + const newAttributes = { ...block.attributes }; + for ( const attributeKey of attributes ) { + defaultValues[ blockId ] = block.attributes[ attributeKey ]; + if ( overrides[ blockId ] ) { + newAttributes[ attributeKey ] = overrides[ blockId ]; + } + } + return { + ...block, + attributes: newAttributes, + innerBlocks, + }; + } ); +} + +function getOverridesFromBlocks( blocks, defaultValues ) { + /** @type {Record} */ + const overrides = {}; + for ( const block of blocks ) { + Object.assign( + overrides, + getOverridesFromBlocks( block.innerBlocks, defaultValues ) + ); + const blockId = block.attributes.metadata?.id; + if ( ! isPartiallySynced( block ) || ! blockId ) continue; + const attributes = getPartiallySyncedAttributes( block ); + for ( const attributeKey of attributes ) { + if ( + block.attributes[ attributeKey ] !== defaultValues[ blockId ] + ) { + overrides[ blockId ] = block.attributes[ attributeKey ]; + } + } + } + return Object.keys( overrides ).length > 0 ? overrides : undefined; +} + export default function ReusableBlockEdit( { name, - attributes: { ref }, + attributes: { ref, overrides }, __unstableParentLayout: parentLayout, + clientId: patternClientId, } ) { + const registry = useRegistry(); const hasAlreadyRendered = useHasRecursion( ref ); const { record, hasResolved } = useEntityRecord( 'postType', @@ -79,11 +146,46 @@ export default function ReusableBlockEdit( { ref ); const isMissing = hasResolved && ! record; + const initialOverrides = useRef( overrides ); + const defaultValuesRef = useRef( {} ); + const { + replaceInnerBlocks, + __unstableMarkNextChangeAsNotPersistent, + setBlockEditingMode, + } = useDispatch( blockEditorStore ); + const { getBlockEditingMode } = useSelect( blockEditorStore ); - const [ blocks, onInput, onChange ] = useEntityBlockEditor( - 'postType', - 'wp_block', - { id: ref } + useEffect( () => { + if ( ! record?.content?.raw ) return; + const initialBlocks = parse( record.content.raw ); + + const editingMode = getBlockEditingMode( patternClientId ); + registry.batch( () => { + setBlockEditingMode( patternClientId, 'default' ); + __unstableMarkNextChangeAsNotPersistent(); + replaceInnerBlocks( + patternClientId, + applyInitialOverrides( + initialBlocks, + initialOverrides.current, + defaultValuesRef.current + ) + ); + setBlockEditingMode( patternClientId, editingMode ); + } ); + }, [ + __unstableMarkNextChangeAsNotPersistent, + patternClientId, + record, + replaceInnerBlocks, + registry, + getBlockEditingMode, + setBlockEditingMode, + ] ); + + const innerBlocks = useSelect( + ( select ) => select( blockEditorStore ).getBlocks( patternClientId ), + [ patternClientId ] ); const [ title, setTitle ] = useEntityProp( @@ -93,7 +195,10 @@ export default function ReusableBlockEdit( { ref ); - const { alignment, layout } = useInferredLayout( blocks, parentLayout ); + const { alignment, layout } = useInferredLayout( + innerBlocks, + parentLayout + ); const layoutClasses = useLayoutClasses( { layout }, name ); const blockProps = useBlockProps( { @@ -105,16 +210,38 @@ export default function ReusableBlockEdit( { } ); const innerBlocksProps = useInnerBlocksProps( blockProps, { - value: blocks, layout, - onInput, - onChange, - renderAppender: blocks?.length + renderAppender: innerBlocks?.length ? undefined : InnerBlocks.ButtonBlockAppender, } ); + // Sync the `overrides` attribute from the updated blocks. + // `syncDerivedBlockAttributes` is an action that just like `updateBlockAttributes` + // but won't create an undo level. + // This can be abstracted into a `useSyncDerivedAttributes` hook if needed. + useEffect( () => { + const { getBlocks } = registry.select( blockEditorStore ); + const { syncDerivedBlockAttributes } = unlock( + registry.dispatch( blockEditorStore ) + ); + let prevBlocks = getBlocks( patternClientId ); + return registry.subscribe( () => { + const blocks = getBlocks( patternClientId ); + if ( blocks !== prevBlocks ) { + prevBlocks = blocks; + syncDerivedBlockAttributes( patternClientId, { + overrides: getOverridesFromBlocks( + blocks, + defaultValuesRef.current + ), + } ); + } + }, blockEditorStore ); + }, [ patternClientId, registry ] ); + let children = null; + if ( hasAlreadyRendered ) { children = ( diff --git a/packages/block-library/src/block/index.js b/packages/block-library/src/block/index.js index 95e090f0afd6ad..0d117e6f3938ab 100644 --- a/packages/block-library/src/block/index.js +++ b/packages/block-library/src/block/index.js @@ -8,14 +8,15 @@ import { symbol as icon } from '@wordpress/icons'; */ import initBlock from '../utils/init-block'; import metadata from './block.json'; -import edit from './edit'; +import editV1 from './v1/edit'; +import editV2 from './edit'; const { name } = metadata; export { metadata, name }; export const settings = { - edit, + edit: window.__experimentalPatternPartialSyncing ? editV2 : editV1, icon, }; diff --git a/packages/block-library/src/block/index.php b/packages/block-library/src/block/index.php index d51b35d68b23d9..54b54fad139ff3 100644 --- a/packages/block-library/src/block/index.php +++ b/packages/block-library/src/block/index.php @@ -46,8 +46,31 @@ function render_block_core_block( $attributes ) { $content = $wp_embed->run_shortcode( $reusable_block->post_content ); $content = $wp_embed->autoembed( $content ); + $gutenberg_experiments = get_option( 'gutenberg-experiments' ); + $has_partial_synced_overrides = $gutenberg_experiments + && array_key_exists( 'gutenberg-pattern-partial-syncing', $gutenberg_experiments ) + && isset( $attributes['overrides'] ); + + /** + * We set the `pattern/overrides` context through the `render_block_context` + * filter so that it is available when a pattern's inner blocks are + * rendering via do_blocks given it only receives the inner content. + */ + if ( $has_partial_synced_overrides ) { + $filter_block_context = static function ( $context ) use ( $attributes ) { + $context['pattern/overrides'] = $attributes['overrides']; + return $context; + }; + add_filter( 'render_block_context', $filter_block_context, 1 ); + } + $content = do_blocks( $content ); unset( $seen_refs[ $attributes['ref'] ] ); + + if ( $has_partial_synced_overrides ) { + remove_filter( 'render_block_context', $filter_block_context, 1 ); + } + return $content; } @@ -63,3 +86,28 @@ function register_block_core_block() { ); } add_action( 'init', 'register_block_core_block' ); + +$gutenberg_experiments = get_option( 'gutenberg-experiments' ); +if ( $gutenberg_experiments && array_key_exists( 'gutenberg-pattern-partial-syncing', $gutenberg_experiments ) ) { + /** + * Registers the overrides attribute for core/block. + * + * @param array $args Array of arguments for registering a block type. + * @param string $block_name Block name including namespace. + * @return array $args + */ + function register_block_core_block_args( $args, $block_name ) { + if ( 'core/block' === $block_name ) { + $args['attributes'] = array_merge( + $args['attributes'], + array( + 'overrides' => array( + 'type' => 'object', + ), + ) + ); + } + return $args; + } + add_filter( 'register_block_type_args', 'register_block_core_block_args', 10, 2 ); +} diff --git a/packages/block-library/src/block/v1/edit.js b/packages/block-library/src/block/v1/edit.js new file mode 100644 index 00000000000000..5975711376c650 --- /dev/null +++ b/packages/block-library/src/block/v1/edit.js @@ -0,0 +1,163 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { + useEntityBlockEditor, + useEntityProp, + useEntityRecord, +} from '@wordpress/core-data'; +import { + Placeholder, + Spinner, + TextControl, + PanelBody, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { + useInnerBlocksProps, + __experimentalRecursionProvider as RecursionProvider, + __experimentalUseHasRecursion as useHasRecursion, + InnerBlocks, + InspectorControls, + useBlockProps, + Warning, + privateApis as blockEditorPrivateApis, +} from '@wordpress/block-editor'; +import { useRef, useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; + +const { useLayoutClasses } = unlock( blockEditorPrivateApis ); +const fullAlignments = [ 'full', 'wide', 'left', 'right' ]; + +const useInferredLayout = ( blocks, parentLayout ) => { + const initialInferredAlignmentRef = useRef(); + + return useMemo( () => { + // Exit early if the pattern's blocks haven't loaded yet. + if ( ! blocks?.length ) { + return {}; + } + + let alignment = initialInferredAlignmentRef.current; + + // Only track the initial alignment so that temporarily removed + // alignments can be reapplied. + if ( alignment === undefined ) { + const isConstrained = parentLayout?.type === 'constrained'; + const hasFullAlignment = blocks.some( ( block ) => + fullAlignments.includes( block.attributes.align ) + ); + + alignment = isConstrained && hasFullAlignment ? 'full' : null; + initialInferredAlignmentRef.current = alignment; + } + + const layout = alignment ? parentLayout : undefined; + + return { alignment, layout }; + }, [ blocks, parentLayout ] ); +}; + +export default function ReusableBlockEdit( { + name, + attributes: { ref }, + __unstableParentLayout: parentLayout, +} ) { + const hasAlreadyRendered = useHasRecursion( ref ); + const { record, hasResolved } = useEntityRecord( + 'postType', + 'wp_block', + ref + ); + const isMissing = hasResolved && ! record; + + const [ blocks, onInput, onChange ] = useEntityBlockEditor( + 'postType', + 'wp_block', + { id: ref } + ); + + const [ title, setTitle ] = useEntityProp( + 'postType', + 'wp_block', + 'title', + ref + ); + + const { alignment, layout } = useInferredLayout( blocks, parentLayout ); + const layoutClasses = useLayoutClasses( { layout }, name ); + + const blockProps = useBlockProps( { + className: classnames( + 'block-library-block__reusable-block-container', + layout && layoutClasses, + { [ `align${ alignment }` ]: alignment } + ), + } ); + + const innerBlocksProps = useInnerBlocksProps( blockProps, { + value: blocks, + layout, + onInput, + onChange, + renderAppender: blocks?.length + ? undefined + : InnerBlocks.ButtonBlockAppender, + } ); + + let children = null; + + if ( hasAlreadyRendered ) { + children = ( + + { __( 'Block cannot be rendered inside itself.' ) } + + ); + } + + if ( isMissing ) { + children = ( + + { __( 'Block has been deleted or is unavailable.' ) } + + ); + } + + if ( ! hasResolved ) { + children = ( + + + + ); + } + + return ( + + + + + + + { children === null ? ( +
+ ) : ( +
{ children }
+ ) } + + ); +} diff --git a/packages/block-library/src/block/edit.native.js b/packages/block-library/src/block/v1/edit.native.js similarity index 98% rename from packages/block-library/src/block/edit.native.js rename to packages/block-library/src/block/v1/edit.native.js index 9ab6ccf86a1e19..3a649921b3dda1 100644 --- a/packages/block-library/src/block/edit.native.js +++ b/packages/block-library/src/block/v1/edit.native.js @@ -42,8 +42,8 @@ import { store as noticesStore } from '@wordpress/notices'; /** * Internal dependencies */ -import styles from './editor.scss'; -import EditTitle from './edit-title'; +import styles from '../editor.scss'; +import EditTitle from '../edit-title'; export default function ReusableBlockEdit( { attributes: { ref }, diff --git a/packages/editor/src/hooks/index.js b/packages/editor/src/hooks/index.js index 6e0934d63c0cfa..5a48ec1bf49566 100644 --- a/packages/editor/src/hooks/index.js +++ b/packages/editor/src/hooks/index.js @@ -3,3 +3,4 @@ */ import './custom-sources-backwards-compatibility'; import './default-autocompleters'; +import './pattern-partial-syncing'; diff --git a/packages/editor/src/hooks/pattern-partial-syncing.js b/packages/editor/src/hooks/pattern-partial-syncing.js new file mode 100644 index 00000000000000..40bd1e16dfc00d --- /dev/null +++ b/packages/editor/src/hooks/pattern-partial-syncing.js @@ -0,0 +1,73 @@ +/** + * WordPress dependencies + */ +import { addFilter } from '@wordpress/hooks'; +import { privateApis as patternsPrivateApis } from '@wordpress/patterns'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { useBlockEditingMode } from '@wordpress/block-editor'; +import { hasBlockSupport } from '@wordpress/blocks'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as editorStore } from '../store'; +import { unlock } from '../lock-unlock'; + +const { + PartialSyncingControls, + PATTERN_TYPES, + PARTIAL_SYNCING_SUPPORTED_BLOCKS, +} = unlock( patternsPrivateApis ); + +/** + * Override the default edit UI to include a new block inspector control for + * assigning a partial syncing controls to supported blocks in the pattern editor. + * Currently, only the `core/paragraph` block is supported. + * + * @param {Component} BlockEdit Original component. + * + * @return {Component} Wrapped component. + */ +const withPartialSyncingControls = createHigherOrderComponent( + ( BlockEdit ) => ( props ) => { + const blockEditingMode = useBlockEditingMode(); + const hasCustomFieldsSupport = hasBlockSupport( + props.name, + '__experimentalConnections', + false + ); + const isEditingPattern = useSelect( + ( select ) => + select( editorStore ).getCurrentPostType() === + PATTERN_TYPES.user, + [] + ); + + const shouldShowPartialSyncingControls = + hasCustomFieldsSupport && + props.isSelected && + isEditingPattern && + blockEditingMode === 'default' && + Object.keys( PARTIAL_SYNCING_SUPPORTED_BLOCKS ).includes( + props.name + ); + + return ( + <> + + { shouldShowPartialSyncingControls && ( + + ) } + + ); + } +); + +if ( window.__experimentalPatternPartialSyncing ) { + addFilter( + 'editor.BlockEdit', + 'core/editor/with-partial-syncing-controls', + withPartialSyncingControls + ); +} diff --git a/packages/patterns/package.json b/packages/patterns/package.json index 3b1cead6f71a15..2fa13bc3fdddfd 100644 --- a/packages/patterns/package.json +++ b/packages/patterns/package.json @@ -44,7 +44,8 @@ "@wordpress/icons": "file:../icons", "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", - "@wordpress/url": "file:../url" + "@wordpress/url": "file:../url", + "nanoid": "^3.3.4" }, "peerDependencies": { "react": "^18.0.0", diff --git a/packages/patterns/src/components/partial-syncing-controls.js b/packages/patterns/src/components/partial-syncing-controls.js new file mode 100644 index 00000000000000..42c39ce69e87bf --- /dev/null +++ b/packages/patterns/src/components/partial-syncing-controls.js @@ -0,0 +1,98 @@ +/** + * External dependencies + */ +import { nanoid } from 'nanoid'; + +/** + * WordPress dependencies + */ +import { InspectorControls } from '@wordpress/block-editor'; +import { BaseControl, CheckboxControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { PARTIAL_SYNCING_SUPPORTED_BLOCKS } from '../constants'; + +function PartialSyncingControls( { name, attributes, setAttributes } ) { + const syncedAttributes = PARTIAL_SYNCING_SUPPORTED_BLOCKS[ name ]; + + function updateConnections( attributeName, isChecked ) { + if ( ! isChecked ) { + let updatedConnections = { + ...attributes.connections, + attributes: { + ...attributes.connections?.attributes, + [ attributeName ]: undefined, + }, + }; + if ( Object.keys( updatedConnections.attributes ).length === 1 ) { + updatedConnections.attributes = undefined; + } + if ( + Object.keys( updatedConnections ).length === 1 && + updateConnections.attributes === undefined + ) { + updatedConnections = undefined; + } + setAttributes( { + connections: updatedConnections, + } ); + return; + } + + const updatedConnections = { + ...attributes.connections, + attributes: { + ...attributes.connections?.attributes, + [ attributeName ]: { + source: 'pattern_attributes', + }, + }, + }; + + if ( typeof attributes.metadata?.id === 'string' ) { + setAttributes( { connections: updatedConnections } ); + return; + } + + const id = nanoid( 6 ); + setAttributes( { + connections: updatedConnections, + metadata: { + ...attributes.metadata, + id, + }, + } ); + } + + return ( + + + + { __( 'Synced attributes' ) } + + { Object.entries( syncedAttributes ).map( + ( [ attributeName, label ] ) => ( + { + updateConnections( attributeName, isChecked ); + } } + /> + ) + ) } + + + ); +} + +export default PartialSyncingControls; diff --git a/packages/patterns/src/constants.js b/packages/patterns/src/constants.js index 465970b17b7aae..3e533d834fd75c 100644 --- a/packages/patterns/src/constants.js +++ b/packages/patterns/src/constants.js @@ -1,3 +1,8 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + export const PATTERN_TYPES = { theme: 'pattern', user: 'wp_block', @@ -14,3 +19,8 @@ export const PATTERN_SYNC_TYPES = { full: 'fully', unsynced: 'unsynced', }; + +// TODO: This should not be hardcoded. Maybe there should be a config and/or an UI. +export const PARTIAL_SYNCING_SUPPORTED_BLOCKS = { + 'core/paragraph': { content: __( 'Content' ) }, +}; diff --git a/packages/patterns/src/private-apis.js b/packages/patterns/src/private-apis.js index 770a78fd4fa9de..b357efb1bc107a 100644 --- a/packages/patterns/src/private-apis.js +++ b/packages/patterns/src/private-apis.js @@ -7,12 +7,14 @@ import DuplicatePatternModal from './components/duplicate-pattern-modal'; import RenamePatternModal from './components/rename-pattern-modal'; import PatternsMenuItems from './components'; import RenamePatternCategoryModal from './components/rename-pattern-category-modal'; +import PartialSyncingControls from './components/partial-syncing-controls'; import { PATTERN_TYPES, PATTERN_DEFAULT_CATEGORY, PATTERN_USER_CATEGORY, EXCLUDED_PATTERN_SOURCES, PATTERN_SYNC_TYPES, + PARTIAL_SYNCING_SUPPORTED_BLOCKS, } from './constants'; export const privateApis = {}; @@ -22,9 +24,11 @@ lock( privateApis, { RenamePatternModal, PatternsMenuItems, RenamePatternCategoryModal, + PartialSyncingControls, PATTERN_TYPES, PATTERN_DEFAULT_CATEGORY, PATTERN_USER_CATEGORY, EXCLUDED_PATTERN_SOURCES, PATTERN_SYNC_TYPES, + PARTIAL_SYNCING_SUPPORTED_BLOCKS, } ); diff --git a/test/unit/scripts/resolver.js b/test/unit/scripts/resolver.js index 2c359145f0b3e8..7672bb723e1248 100644 --- a/test/unit/scripts/resolver.js +++ b/test/unit/scripts/resolver.js @@ -24,7 +24,8 @@ module.exports = ( path, options ) => { pkg.name === 'uuid' || pkg.name === 'react-colorful' || pkg.name === '@eslint/eslintrc' || - pkg.name === 'expect' + pkg.name === 'expect' || + pkg.name === 'nanoid' ) { delete pkg.exports; delete pkg.module; From 8a8fbc57312f03e3cd51a7ffdad89863f5c9ef85 Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 1 Dec 2023 16:46:02 +1100 Subject: [PATCH 15/16] Removes POST_TYPE_ENTITIES_WITH_REVISIONS_SUPPORT and allows the server to determine revisions support (#56701) --- packages/core-data/src/entities.js | 20 ---- packages/core-data/src/reducer.js | 72 ++++++--------- packages/core-data/src/resolvers.js | 137 ++++++++++++++++------------ 3 files changed, 106 insertions(+), 123 deletions(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index f016336260ab16..a2c60c45aaa032 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -19,18 +19,6 @@ export const DEFAULT_ENTITY_KEY = 'id'; const POST_RAW_ATTRIBUTES = [ 'title', 'excerpt', 'content' ]; -// A hardcoded list of post types that support revisions. -// Reflects post types in Core's src/wp-includes/post.php. -// @TODO: Ideally this should be fetched from the `/types` REST API's view context. -const POST_TYPE_ENTITIES_WITH_REVISIONS_SUPPORT = [ - 'post', - 'page', - 'wp_block', - 'wp_navigation', - 'wp_template', - 'wp_template_part', -]; - export const rootEntitiesConfig = [ { label: __( 'Base' ), @@ -223,9 +211,6 @@ export const rootEntitiesConfig = [ `/wp/v2/global-styles/${ parentId }/revisions${ revisionId ? '/' + revisionId : '' }`, - supports: { - revisions: true, - }, supportsPagination: true, }, { @@ -315,11 +300,6 @@ async function loadPostTypeEntities() { selection: true, }, mergedEdits: { meta: true }, - supports: { - revisions: POST_TYPE_ENTITIES_WITH_REVISIONS_SUPPORT.includes( - postType?.slug - ), - }, rawAttributes: POST_RAW_ATTRIBUTES, getTitle: ( record ) => record?.title?.rendered || diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index a499b42f175438..8e6be425244687 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -355,51 +355,37 @@ function entity( entityConfig ) { return state; }, - // Add revisions to the state tree if the post type supports it. - ...( entityConfig?.supports?.revisions - ? { - revisions: ( state = {}, action ) => { - // Use the same queriedDataReducer shape for revisions. - if ( action.type === 'RECEIVE_ITEM_REVISIONS' ) { - const recordKey = action.recordKey; - delete action.recordKey; - const newState = queriedDataReducer( - state[ recordKey ], - { - ...action, - type: 'RECEIVE_ITEMS', - } - ); - return { - ...state, - [ recordKey ]: newState, - }; - } + revisions: ( state = {}, action ) => { + // Use the same queriedDataReducer shape for revisions. + if ( action.type === 'RECEIVE_ITEM_REVISIONS' ) { + const recordKey = action.recordKey; + delete action.recordKey; + const newState = queriedDataReducer( state[ recordKey ], { + ...action, + type: 'RECEIVE_ITEMS', + } ); + return { + ...state, + [ recordKey ]: newState, + }; + } - if ( action.type === 'REMOVE_ITEMS' ) { - return Object.fromEntries( - Object.entries( state ).filter( - ( [ id ] ) => - ! action.itemIds.some( - ( itemId ) => { - if ( - Number.isInteger( - itemId - ) - ) { - return itemId === +id; - } - return itemId === id; - } - ) - ) - ); - } + if ( action.type === 'REMOVE_ITEMS' ) { + return Object.fromEntries( + Object.entries( state ).filter( + ( [ id ] ) => + ! action.itemIds.some( ( itemId ) => { + if ( Number.isInteger( itemId ) ) { + return itemId === +id; + } + return itemId === id; + } ) + ) + ); + } - return state; - }, - } - : {} ), + return state; + }, } ) ); } diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 245d64d05d0649..807005ec4a6e8d 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -737,11 +737,7 @@ export const getRevisions = ( config ) => config.name === name && config.kind === kind ); - if ( - ! entityConfig || - entityConfig?.__experimentalNoFetch || - ! entityConfig?.supports?.revisions - ) { + if ( ! entityConfig || entityConfig?.__experimentalNoFetch ) { return; } @@ -766,60 +762,76 @@ export const getRevisions = query ); - let records, meta; - if ( entityConfig.supportsPagination && query.per_page !== -1 ) { - const response = await apiFetch( { path, parse: false } ); - records = Object.values( await response.json() ); - meta = { - totalItems: parseInt( response.headers.get( 'X-WP-Total' ) ), - }; - } else { - records = Object.values( await apiFetch( { path } ) ); + let records, response; + const meta = {}; + const isPaginated = + entityConfig.supportsPagination && query.per_page !== -1; + try { + response = await apiFetch( { path, parse: ! isPaginated } ); + } catch ( error ) { + // Do nothing if our request comes back with an API error. + return; } - // If we request fields but the result doesn't contain the fields, - // explicitly set these fields as "undefined" - // that way we consider the query "fulfilled". - if ( query._fields ) { - records = records.map( ( record ) => { - query._fields.split( ',' ).forEach( ( field ) => { - if ( ! record.hasOwnProperty( field ) ) { - record[ field ] = undefined; - } + if ( response ) { + if ( isPaginated ) { + records = Object.values( await response.json() ); + meta.totalItems = parseInt( + response.headers.get( 'X-WP-Total' ) + ); + } else { + records = Object.values( response ); + } + + // If we request fields but the result doesn't contain the fields, + // explicitly set these fields as "undefined" + // that way we consider the query "fulfilled". + if ( query._fields ) { + records = records.map( ( record ) => { + query._fields.split( ',' ).forEach( ( field ) => { + if ( ! record.hasOwnProperty( field ) ) { + record[ field ] = undefined; + } + } ); + + return record; } ); + } - return record; - } ); - } + dispatch.receiveRevisions( + kind, + name, + recordKey, + records, + query, + false, + meta + ); - dispatch.receiveRevisions( - kind, - name, - recordKey, - records, - query, - false, - meta - ); + // When requesting all fields, the list of results can be used to + // resolve the `getRevision` selector in addition to `getRevisions`. + if ( ! query?._fields && ! query.context ) { + const key = entityConfig.key || DEFAULT_ENTITY_KEY; + const resolutionsArgs = records + .filter( ( record ) => record[ key ] ) + .map( ( record ) => [ + kind, + name, + recordKey, + record[ key ], + ] ); - // When requesting all fields, the list of results can be used to - // resolve the `getRevision` selector in addition to `getRevisions`. - if ( ! query?._fields && ! query.context ) { - const key = entityConfig.key || DEFAULT_ENTITY_KEY; - const resolutionsArgs = records - .filter( ( record ) => record[ key ] ) - .map( ( record ) => [ kind, name, recordKey, record[ key ] ] ); - - dispatch( { - type: 'START_RESOLUTIONS', - selectorName: 'getRevision', - args: resolutionsArgs, - } ); - dispatch( { - type: 'FINISH_RESOLUTIONS', - selectorName: 'getRevision', - args: resolutionsArgs, - } ); + dispatch( { + type: 'START_RESOLUTIONS', + selectorName: 'getRevision', + args: resolutionsArgs, + } ); + dispatch( { + type: 'FINISH_RESOLUTIONS', + selectorName: 'getRevision', + args: resolutionsArgs, + } ); + } } }; @@ -850,11 +862,7 @@ export const getRevision = ( config ) => config.name === name && config.kind === kind ); - if ( - ! entityConfig || - entityConfig?.__experimentalNoFetch || - ! entityConfig?.supports?.revisions - ) { + if ( ! entityConfig || entityConfig?.__experimentalNoFetch ) { return; } @@ -878,6 +886,15 @@ export const getRevision = query ); - const record = await apiFetch( { path } ); - dispatch.receiveRevisions( kind, name, recordKey, record, query ); + let record; + try { + record = await apiFetch( { path } ); + } catch ( error ) { + // Do nothing if our request comes back with an API error. + return; + } + + if ( record ) { + dispatch.receiveRevisions( kind, name, recordKey, record, query ); + } }; From 6a793dee20c4f52315dbf190822a0b8da14137ea Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Fri, 1 Dec 2023 07:48:40 +0100 Subject: [PATCH 16/16] Documentation: Add the nested blocks chapter to the platform docs (#56689) Co-authored-by: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> --- .../docs/create-block/nested-blocks.md | 228 +++++++++++++++++- 1 file changed, 227 insertions(+), 1 deletion(-) diff --git a/platform-docs/docs/create-block/nested-blocks.md b/platform-docs/docs/create-block/nested-blocks.md index 78352ba3ebe43f..e5090d4f36113c 100644 --- a/platform-docs/docs/create-block/nested-blocks.md +++ b/platform-docs/docs/create-block/nested-blocks.md @@ -2,4 +2,230 @@ sidebar_position: 5 --- -# Nested blocks \ No newline at end of file +# Nested Blocks + +You can create a single block that nests other blocks using the [InnerBlocks](https://github.com/WordPress/gutenberg/tree/HEAD/packages/block-editor/src/components/inner-blocks/README.md) component. This is used in the Columns block, Social Links block, or any block you want to contain other blocks. + +**Note:** A single block can only contain one `InnerBlocks` component. + +Here is the basic InnerBlocks usage. + +```jsx +import { registerBlockType } from '@wordpress/blocks'; +import { InnerBlocks, useBlockProps } from '@wordpress/block-editor'; + +registerBlockType( 'create-block/gutenpride-container', { + // ... + + edit: () => { + const blockProps = useBlockProps(); + + return ( +
+ +
+ ); + }, + + save: () => { + const blockProps = useBlockProps.save(); + + return ( +
+ +
+ ); + }, +} ); +``` + +## Allowed Blocks + +Using the `allowedBlocks` property, you can define the set of blocks allowed in your InnerBlock. This restricts the blocks that can be included only to those listed, all other blocks will not show in the inserter. + +```jsx +const ALLOWED_BLOCKS = [ 'core/heading', 'core/paragraph' ]; +//... +; +``` + +## Default Block + +By default `InnerBlocks` opens a list of permitted blocks via `allowedBlocks` when the block appender is clicked. You can modify the default block and its attributes that are inserted when the initial block appender is clicked by using the `defaultBlock` property. For example: + +```jsx + +``` + +By default this behavior is disabled until the `directInsert` prop is set to `true`. This allows you to specify conditions for when the default block should or should not be inserted. + +## Template + +Use the template property to define a set of blocks that prefill the InnerBlocks component when inserted. You can set attributes on the blocks to define their use. The example below shows a book review template using the InnerBlocks component and setting placeholder values to show the block usage. + +```js +const MY_TEMPLATE = [ + [ 'core/image', {} ], + [ 'core/heading', { placeholder: 'Book Title' } ], + [ 'core/paragraph', { placeholder: 'Summary' } ], +]; + +//... + + edit: () => { + return ( + + ); + }, +``` + +Use the `templateLock` property to lock down the template. Using `all` locks the template completely so no changes can be made. Using `insert` prevents additional blocks from being inserted, but existing blocks can be reordered. See [templateLock documentation](https://github.com/WordPress/gutenberg/tree/HEAD/packages/block-editor/src/components/inner-blocks/README.md#templatelock) for additional information. + +## Using Parent and Ancestor Relationships in Blocks + +A common pattern for using InnerBlocks is to create a custom block that will only be available if its parent block is inserted. This allows builders to establish a relationship between blocks while limiting a nested block's discoverability. Currently, there are two relationships builders can use: `parent` and `ancestor`. The differences are: + +- If you assign a `parent` then you’re stating that the nested block can only be used and inserted as a **direct descendant of the parent**. +- If you assign an `ancestor` then you’re stating that the nested block can only be used and inserted as a **descendent of the parent**. + +The key difference between `parent` and `ancestor` is that `parent` has finer specificity, while an `ancestor` has greater flexibility in its nested hierarchy. + +### Defining Parent Block Relationship + +An example of this is the Column block, which is assigned the `parent` block setting. This allows the Column block to only be available as a nested direct descendant in its parent Columns block. Otherwise, the Column block will not be available as an option within the block inserter. See [Column code for reference](https://github.com/WordPress/gutenberg/tree/HEAD/packages/block-library/src/column). + +When defining a direct descendent block, use the `parent` block setting to define which block is the parent. This prevents the nested block from showing in the inserter outside of the InnerBlock it is defined for. + +```js +{ + title: 'Column', + parent: [ 'core/columns' ], + // ... +} +``` + +### Defining Ancestor Block Relationship + +An example of this is the Comment Author Name block, which is assigned the `ancestor` block setting. This allows the Comment Author Name block to only be available as a nested descendant in its ancestral Comment Template block. Otherwise, the Comment Author Name block will not be available as an option within the block inserter. See [Comment Author Name code for reference](https://github.com/WordPress/gutenberg/tree/HEAD/packages/block-library/src/comment-author-name). + +The `ancestor` relationship allows the Comment Author Name block to be anywhere in the hierarchical tree, and not _just_ a direct child of the parent Comment Template block, while still limiting its availability within the block inserter to only be visible an an option to insert if the Comment Template block is available. + +When defining a descendent block, use the `ancestor` block setting. This prevents the nested block from showing in the inserter outside of the InnerBlock it is defined for. + +```js +{ + title: 'Comment Author Name', + ancestor: [ 'core/comment-template' ] + // ... +} +``` + +## Using a React Hook + +You can use a react hook called `useInnerBlocksProps` instead of the `InnerBlocks` component. This hook allows you to take more control over the markup of inner blocks areas. + +The `useInnerBlocksProps` is exported from the `@wordpress/block-editor` package same as the `InnerBlocks` component itself and supports everything the component does. It also works like the `useBlockProps` hook. + +Here is the basic `useInnerBlocksProps` hook usage. + +```jsx +import { registerBlockType } from '@wordpress/blocks'; +import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor'; + +registerBlockType( 'create-block/gutenpride-container', { + // ... + + edit: () => { + const blockProps = useBlockProps(); + const innerBlocksProps = useInnerBlocksProps(); + + return ( +
+
+
+ ); + }, + + save: () => { + const blockProps = useBlockProps.save(); + const innerBlocksProps = useInnerBlocksProps.save(); + + return ( +
+
+
+ ); + }, +} ); +``` + +This hook can also pass objects returned from the `useBlockProps` hook to the `useInnerBlocksProps` hook. This reduces the number of elements we need to create. + +```jsx +import { registerBlockType } from '@wordpress/blocks'; +import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor'; + +registerBlockType( 'gcreate-block/gutenpride-container', { + // ... + + edit: () => { + const blockProps = useBlockProps(); + const innerBlocksProps = useInnerBlocksProps( blockProps ); + + return
; + }, + + save: () => { + const blockProps = useBlockProps.save(); + const innerBlocksProps = useInnerBlocksProps.save( blockProps ); + + return
; + }, +} ); +``` + +The above code will render to the following markup in the editor: + +```html +
+ +
+``` + +Another benefit of the hook approach is using the returned value, which is just an object, and deconstructing to get the react children from the object. This property contains the actual child inner blocks thus we can place elements on the same level as our inner blocks. + +```jsx +import { registerBlockType } from '@wordpress/blocks'; +import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor'; + +registerBlockType( 'gutenberg-examples/example-06', { + // ... + + edit: () => { + const blockProps = useBlockProps(); + const { children, ...innerBlocksProps } = useInnerBlocksProps( blockProps ); + + return ( +
+ { children } + +
+ ); + }, + + // ... +} ); +``` + +```html +
+ + +
+```