diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index 2a0afcf24f0ddc..cc22c9b804130a 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -11,6 +11,7 @@ import { useCallback, forwardRef, createContext, + useContext, } from '@wordpress/element'; import { useDispatch, useRegistry, useSelect } from '@wordpress/data'; import { useMergeRefs, useInstanceId } from '@wordpress/compose'; @@ -39,6 +40,7 @@ import { Content, valueToHTMLString } from './content'; import { withDeprecations } from './with-deprecations'; import { unlock } from '../../lock-unlock'; import { canBindBlock } from '../../hooks/use-bindings-attributes'; +import BlockContext from '../block-context'; export const keyboardShortcutContext = createContext(); export const inputEventContext = createContext(); @@ -121,6 +123,7 @@ export function RichTextWrapper( const context = useBlockEditContext(); const { clientId, isSelected: isBlockSelected, name: blockName } = context; const blockBindings = context[ blockBindingsKey ]; + const blockContext = useContext( BlockContext ); const selector = ( select ) => { // Avoid subscribing to the block editor store if the block is not // selected. @@ -170,7 +173,7 @@ export function RichTextWrapper( const { getBlockBindingsSource } = unlock( select( blocksStore ) ); - for ( const [ attribute, args ] of Object.entries( + for ( const [ attribute, binding ] of Object.entries( blockBindings ) ) { if ( @@ -180,13 +183,16 @@ export function RichTextWrapper( break; } - // If the source is not defined, or if its value of `lockAttributesEditing` is `true`, disable it. + // If the source is not defined, or if its value of `canUserEditValue` is `false`, disable it. const blockBindingsSource = getBlockBindingsSource( - args.source + binding.source ); if ( - ! blockBindingsSource || - blockBindingsSource.lockAttributesEditing() + ! blockBindingsSource?.canUserEditValue( { + select, + context: blockContext, + args: binding.args, + } ) ) { _disableBoundBlocks = true; break; diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index 74814cf22746bc..db2c615dd5d6ca 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -18,6 +18,7 @@ import { } from '@wordpress/blocks'; import { speak } from '@wordpress/a11y'; import { __, _n, sprintf } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; import { create, insert, remove, toHTMLString } from '@wordpress/rich-text'; import deprecated from '@wordpress/deprecated'; @@ -872,6 +873,30 @@ export const __unstableSplitSelection = typeof selectionB.attributeKey === 'string' ? selectionB.attributeKey : findRichTextAttributeKey( blockBType ); + const blockAttributes = select.getBlockAttributes( + selectionA.clientId + ); + const bindings = blockAttributes?.metadata?.bindings; + + // If the attribute is bound, don't split the selection and insert a new block instead. + if ( bindings?.[ attributeKeyA ] ) { + // Show warning if user tries to insert a block into another block with bindings. + if ( blocks.length ) { + const { createWarningNotice } = + registry.dispatch( noticesStore ); + createWarningNotice( + __( + "Blocks can't be inserted into other blocks with bindings" + ), + { + type: 'snackbar', + } + ); + return; + } + dispatch.insertAfterBlock( selectionA.clientId ); + return; + } // Can't split if the selection is not set. if ( @@ -918,9 +943,7 @@ export const __unstableSplitSelection = ); } - const length = select.getBlockAttributes( selectionA.clientId )[ - attributeKeyA - ].length; + const length = blockAttributes[ attributeKeyA ].length; if ( selectionA.offset === 0 && length ) { dispatch.insertBlocks( diff --git a/packages/block-library/src/button/edit.js b/packages/block-library/src/button/edit.js index b6c4095ca82a6a..e5bd5e6b5f0643 100644 --- a/packages/block-library/src/button/edit.js +++ b/packages/block-library/src/button/edit.js @@ -156,6 +156,7 @@ function ButtonEdit( props ) { onReplace, mergeBlocks, clientId, + context, } = props; const { tagName, @@ -246,8 +247,11 @@ function ButtonEdit( props ) { return { lockUrlControls: !! metadata?.bindings?.url && - ( ! blockBindingsSource || - blockBindingsSource?.lockAttributesEditing() ), + ! blockBindingsSource?.canUserEditValue( { + select, + context, + args: metadata?.bindings?.url?.args, + } ), }; }, [ isSelected, metadata?.bindings?.url ] diff --git a/packages/block-library/src/image/edit.js b/packages/block-library/src/image/edit.js index 6ff392555a93ae..673ab44a8c28aa 100644 --- a/packages/block-library/src/image/edit.js +++ b/packages/block-library/src/image/edit.js @@ -317,8 +317,11 @@ export function ImageEdit( { return { lockUrlControls: !! metadata?.bindings?.url && - ( ! blockBindingsSource || - blockBindingsSource?.lockAttributesEditing() ), + ! blockBindingsSource?.canUserEditValue( { + select, + context, + args: metadata?.bindings?.url?.args, + } ), lockUrlControlsMessage: blockBindingsSource?.label ? sprintf( /* translators: %s: Label of the bindings source. */ diff --git a/packages/block-library/src/image/image.js b/packages/block-library/src/image/image.js index fc1199bb40fb01..dcd67c53b18690 100644 --- a/packages/block-library/src/image/image.js +++ b/packages/block-library/src/image/image.js @@ -462,8 +462,11 @@ export default function Image( { return { lockUrlControls: !! urlBinding && - ( ! urlBindingSource || - urlBindingSource?.lockAttributesEditing() ), + ! urlBindingSource?.canUserEditValue( { + select, + context, + args: urlBinding?.args, + } ), lockHrefControls: // Disable editing the link of the URL if the image is inside a pattern instance. // This is a temporary solution until we support overriding the link on the frontend. @@ -474,8 +477,11 @@ export default function Image( { hasParentPattern, lockAltControls: !! altBinding && - ( ! altBindingSource || - altBindingSource?.lockAttributesEditing() ), + ! altBindingSource?.canUserEditValue( { + select, + context, + args: altBinding?.args, + } ), lockAltControlsMessage: altBindingSource?.label ? sprintf( /* translators: %s: Label of the bindings source. */ @@ -485,8 +491,11 @@ export default function Image( { : __( 'Connected to dynamic data' ), lockTitleControls: !! titleBinding && - ( ! titleBindingSource || - titleBindingSource?.lockAttributesEditing() ), + ! titleBindingSource?.canUserEditValue( { + select, + context, + args: titleBinding?.args, + } ), lockTitleControlsMessage: titleBindingSource?.label ? sprintf( /* translators: %s: Label of the bindings source. */ diff --git a/packages/blocks/src/store/private-actions.js b/packages/blocks/src/store/private-actions.js index a47d9aacab37ae..dd6650338d9d1a 100644 --- a/packages/blocks/src/store/private-actions.js +++ b/packages/blocks/src/store/private-actions.js @@ -55,6 +55,6 @@ export function registerBlockBindingsSource( source ) { setValue: source.setValue, setValues: source.setValues, getPlaceholder: source.getPlaceholder, - lockAttributesEditing: source.lockAttributesEditing, + canUserEditValue: source.canUserEditValue, }; } diff --git a/packages/blocks/src/store/reducer.js b/packages/blocks/src/store/reducer.js index 1d0d8cb2e968fc..c00810c534d55d 100644 --- a/packages/blocks/src/store/reducer.js +++ b/packages/blocks/src/store/reducer.js @@ -381,10 +381,7 @@ export function blockBindingsSources( state = {}, action ) { setValue: action.setValue, setValues: action.setValues, getPlaceholder: action.getPlaceholder, - lockAttributesEditing: () => - action.lockAttributesEditing - ? action.lockAttributesEditing() - : true, + canUserEditValue: action.canUserEditValue || ( () => false ), }, }; } diff --git a/packages/editor/src/bindings/pattern-overrides.js b/packages/editor/src/bindings/pattern-overrides.js index 4065cefe362808..107ed72e722ba5 100644 --- a/packages/editor/src/bindings/pattern-overrides.js +++ b/packages/editor/src/bindings/pattern-overrides.js @@ -89,7 +89,5 @@ export default { }, } ); }, - lockAttributesEditing() { - return false; - }, + canUserEditValue: () => true, }; diff --git a/packages/editor/src/bindings/post-meta.js b/packages/editor/src/bindings/post-meta.js index f5b3b526dbfd4a..aec890c5ceff87 100644 --- a/packages/editor/src/bindings/post-meta.js +++ b/packages/editor/src/bindings/post-meta.js @@ -16,13 +16,53 @@ export default { return args.key; }, getValue( { registry, context, args } ) { - const postType = context.postType - ? context.postType - : registry.select( editorStore ).getCurrentPostType(); - return registry .select( coreDataStore ) - .getEditedEntityRecord( 'postType', postType, context.postId ) - .meta?.[ args.key ]; + .getEditedEntityRecord( + 'postType', + context?.postType, + context?.postId + ).meta?.[ args.key ]; + }, + setValue( { registry, context, args, value } ) { + registry + .dispatch( coreDataStore ) + .editEntityRecord( 'postType', context?.postType, context?.postId, { + meta: { + [ args.key ]: value, + }, + } ); + }, + canUserEditValue( { select, context, args } ) { + const postType = + context?.postType || select( editorStore ).getCurrentPostType(); + + // Check that editing is happening in the post editor and not a template. + if ( postType === 'wp_template' ) { + return false; + } + + // Check that the custom field is not protected and available in the REST API. + const isFieldExposed = !! select( coreDataStore ).getEntityRecord( + 'postType', + postType, + context?.postId + )?.meta?.[ args.key ]; + + if ( ! isFieldExposed ) { + return false; + } + + // Check that the user has the capability to edit post meta. + const canUserEdit = select( coreDataStore ).canUserEditEntityRecord( + 'postType', + context?.postType, + context?.postId + ); + if ( ! canUserEdit ) { + return false; + } + + return true; }, }; diff --git a/test/e2e/specs/editor/various/block-bindings.spec.js b/test/e2e/specs/editor/various/block-bindings.spec.js index 97b8579bb07ba6..87e5b2f2e10b11 100644 --- a/test/e2e/specs/editor/various/block-bindings.spec.js +++ b/test/e2e/specs/editor/various/block-bindings.spec.js @@ -1193,11 +1193,6 @@ test.describe( 'Block bindings', () => { await expect( paragraphBlock ).toHaveText( 'Value of the text_custom_field' ); - // Paragraph is not editable. - await expect( paragraphBlock ).toHaveAttribute( - 'contenteditable', - 'false' - ); // Check the frontend shows the value of the custom field. const postId = await editor.publishPost(); @@ -1331,6 +1326,12 @@ test.describe( 'Block bindings', () => { }, }, } ); + // Select the paragraph and press Enter at the end of it. + const paragraph = editor.canvas.getByRole( 'document', { + name: 'Block: Paragraph', + } ); + await editor.selectBlocks( paragraph ); + await page.keyboard.press( 'End' ); await page.keyboard.press( 'Enter' ); const [ initialParagraph, newEmptyParagraph ] = await editor.canvas @@ -1342,6 +1343,70 @@ test.describe( 'Block bindings', () => { await expect( newEmptyParagraph ).toHaveText( '' ); await expect( newEmptyParagraph ).toBeEditable(); } ); + + test( 'should NOT be possible to edit the value of the custom field when it is protected', async ( { + editor, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + anchor: 'protected-field-binding', + content: 'fallback value', + metadata: { + bindings: { + content: { + source: 'core/post-meta', + args: { key: '_protected_field' }, + }, + }, + }, + }, + } ); + + const protectedFieldBlock = editor.canvas.getByRole( + 'document', + { + name: 'Block: Paragraph', + } + ); + + await expect( protectedFieldBlock ).toHaveAttribute( + 'contenteditable', + 'false' + ); + } ); + + test( 'should NOT be possible to edit the value of the custom field when it is not shown in the REST API', async ( { + editor, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + anchor: 'show-in-rest-false-binding', + content: 'fallback value', + metadata: { + bindings: { + content: { + source: 'core/post-meta', + args: { key: 'show_in_rest_false_field' }, + }, + }, + }, + }, + } ); + + const showInRestFalseBlock = editor.canvas.getByRole( + 'document', + { + name: 'Block: Paragraph', + } + ); + + await expect( showInRestFalseBlock ).toHaveAttribute( + 'contenteditable', + 'false' + ); + } ); } ); test.describe( 'Heading', () => { @@ -1370,11 +1435,6 @@ test.describe( 'Block bindings', () => { await expect( headingBlock ).toHaveText( 'Value of the text_custom_field' ); - // Heading is not editable. - await expect( headingBlock ).toHaveAttribute( - 'contenteditable', - 'false' - ); // Check the frontend shows the value of the custom field. const postId = await editor.publishPost(); @@ -1406,6 +1466,13 @@ test.describe( 'Block bindings', () => { }, }, } ); + + // Select the heading and press Enter at the end of it. + const heading = editor.canvas.getByRole( 'document', { + name: 'Block: Heading', + } ); + await editor.selectBlocks( heading ); + await page.keyboard.press( 'End' ); await page.keyboard.press( 'Enter' ); // Can't use `editor.getBlocks` because it doesn't return the meta value shown in the editor. const [ initialHeading, newEmptyParagraph ] = @@ -1465,12 +1532,6 @@ test.describe( 'Block bindings', () => { 'Value of the text_custom_field' ); - // Button is not editable. - await expect( buttonBlock ).toHaveAttribute( - 'contenteditable', - 'false' - ); - // Check the frontend shows the value of the custom field. const postId = await editor.publishPost(); await page.goto( `/?p=${ postId }` ); @@ -1599,6 +1660,7 @@ test.describe( 'Block bindings', () => { } ) .getByRole( 'textbox' ) .click(); + await page.keyboard.press( 'End' ); await page.keyboard.press( 'Enter' ); const [ initialButton, newEmptyButton ] = await editor.canvas .locator( '[data-type="core/button"]' ) @@ -1723,12 +1785,7 @@ test.describe( 'Block bindings', () => { imagePlaceholderSrc ); - // Alt textarea is disabled and with the custom field value. - await expect( - page - .getByRole( 'tabpanel', { name: 'Settings' } ) - .getByLabel( 'Alternative text' ) - ).toHaveAttribute( 'readonly' ); + // Alt textarea should have the custom field value. const altValue = await page .getByRole( 'tabpanel', { name: 'Settings' } ) .getByLabel( 'Alternative text' ) @@ -1789,7 +1846,7 @@ test.describe( 'Block bindings', () => { imagePlaceholderSrc ); - // Title input is disabled and with the custom field value. + // Title input should have the custom field value. const advancedButton = page .getByRole( 'tabpanel', { name: 'Settings' } ) .getByRole( 'button', { @@ -1800,11 +1857,6 @@ test.describe( 'Block bindings', () => { if ( isAdvancedPanelOpen === 'false' ) { await advancedButton.click(); } - await expect( - page - .getByRole( 'tabpanel', { name: 'Settings' } ) - .getByLabel( 'Title attribute' ) - ).toHaveAttribute( 'readonly' ); const titleValue = await page .getByRole( 'tabpanel', { name: 'Settings' } ) .getByLabel( 'Title attribute' ) @@ -1869,19 +1921,14 @@ test.describe( 'Block bindings', () => { imageCustomFieldSrc ); - // Alt textarea is disabled and with the custom field value. - await expect( - page - .getByRole( 'tabpanel', { name: 'Settings' } ) - .getByLabel( 'Alternative text' ) - ).toHaveAttribute( 'readonly' ); + // Alt textarea should have the custom field value. const altValue = await page .getByRole( 'tabpanel', { name: 'Settings' } ) .getByLabel( 'Alternative text' ) .inputValue(); expect( altValue ).toBe( 'Value of the text_custom_field' ); - // Title input is enabled and with the original value. + // Title input should have the original value. const advancedButton = page .getByRole( 'tabpanel', { name: 'Settings' } ) .getByRole( 'button', { @@ -1892,11 +1939,6 @@ test.describe( 'Block bindings', () => { if ( isAdvancedPanelOpen === 'false' ) { await advancedButton.click(); } - await expect( - page - .getByRole( 'tabpanel', { name: 'Settings' } ) - .getByLabel( 'Title attribute' ) - ).toBeEnabled(); const titleValue = await page .getByRole( 'tabpanel', { name: 'Settings' } ) .getByLabel( 'Title attribute' ) @@ -1922,6 +1964,208 @@ test.describe( 'Block bindings', () => { ); } ); } ); + + test.describe( 'Edit custom fields', () => { + test( 'should be possible to edit the value of the custom field from the paragraph', async ( { + editor, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + anchor: 'paragraph-binding', + content: 'paragraph default content', + metadata: { + bindings: { + content: { + source: 'core/post-meta', + args: { key: 'text_custom_field' }, + }, + }, + }, + }, + } ); + const paragraphBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Paragraph', + } ); + + await expect( paragraphBlock ).toHaveAttribute( + 'contenteditable', + 'true' + ); + await paragraphBlock.fill( 'new value' ); + // Check that the paragraph content attribute didn't change. + const [ paragraphBlockObject ] = await editor.getBlocks(); + expect( paragraphBlockObject.attributes.content ).toBe( + 'paragraph default content' + ); + // Check the value of the custom field is being updated by visiting the frontend. + const previewPage = await editor.openPreviewPage(); + await expect( + previewPage.locator( '#paragraph-binding' ) + ).toHaveText( 'new value' ); + } ); + + test( 'should be possible to edit the value of the url custom field from the button', async ( { + editor, + page, + pageUtils, + } ) => { + await editor.insertBlock( { + name: 'core/buttons', + innerBlocks: [ + { + name: 'core/button', + attributes: { + anchor: 'button-url-binding', + text: 'button default text', + url: '#default-url', + metadata: { + bindings: { + url: { + source: 'core/post-meta', + args: { key: 'url_custom_field' }, + }, + }, + }, + }, + }, + ], + } ); + + // Edit the url. + const buttonBlock = editor.canvas + .getByRole( 'document', { + name: 'Block: Button', + exact: true, + } ) + .getByRole( 'textbox' ); + await buttonBlock.click(); + await page + .getByRole( 'button', { name: 'Edit link', exact: true } ) + .click(); + await page + .getByPlaceholder( 'Search or type url' ) + .fill( '#url-custom-field-modified' ); + await pageUtils.pressKeys( 'Enter' ); + + // Check that the button url attribute didn't change. + const [ buttonsObject ] = await editor.getBlocks(); + expect( buttonsObject.innerBlocks[ 0 ].attributes.url ).toBe( + '#default-url' + ); + // Check the value of the custom field is being updated by visiting the frontend. + const previewPage = await editor.openPreviewPage(); + await expect( + previewPage.locator( '#button-url-binding a' ) + ).toHaveAttribute( 'href', '#url-custom-field-modified' ); + } ); + + test( 'should be possible to edit the value of the url custom field from the image', async ( { + editor, + page, + pageUtils, + requestUtils, + } ) => { + const customFieldMedia = await requestUtils.uploadMedia( + path.join( + './test/e2e/assets', + '1024x768_e2e_test_image_size.jpeg' + ) + ); + imageCustomFieldSrc = customFieldMedia.source_url; + + await editor.insertBlock( { + name: 'core/image', + attributes: { + anchor: 'image-url-binding', + url: imagePlaceholderSrc, + alt: 'default alt value', + title: 'default title value', + metadata: { + bindings: { + url: { + source: 'core/post-meta', + args: { key: 'url_custom_field' }, + }, + }, + }, + }, + } ); + + // Edit image url. + await page + .getByRole( 'toolbar', { name: 'Block tools' } ) + .getByRole( 'button', { + name: 'Replace', + } ) + .click(); + await page + .getByRole( 'button', { name: 'Edit link', exact: true } ) + .click(); + await page + .getByPlaceholder( 'Search or type url' ) + .fill( imageCustomFieldSrc ); + await pageUtils.pressKeys( 'Enter' ); + + // Check that the image url attribute didn't change and still uses the placeholder. + const [ imageBlockObject ] = await editor.getBlocks(); + expect( imageBlockObject.attributes.url ).toBe( + imagePlaceholderSrc + ); + + // Check the value of the custom field is being updated by visiting the frontend. + const previewPage = await editor.openPreviewPage(); + await expect( + previewPage.locator( '#image-url-binding img' ) + ).toHaveAttribute( 'src', imageCustomFieldSrc ); + } ); + + test( 'should be possible to edit the value of the text custom field from the image alt', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/image', + attributes: { + anchor: 'image-alt-binding', + url: imagePlaceholderSrc, + alt: 'default alt value', + metadata: { + bindings: { + alt: { + source: 'core/post-meta', + args: { key: 'text_custom_field' }, + }, + }, + }, + }, + } ); + const imageBlockImg = editor.canvas + .getByRole( 'document', { + name: 'Block: Image', + } ) + .locator( 'img' ); + await imageBlockImg.click(); + + // Edit the custom field value in the alt textarea. + const altInputArea = page + .getByRole( 'tabpanel', { name: 'Settings' } ) + .getByLabel( 'Alternative text' ); + await expect( altInputArea ).not.toHaveAttribute( 'readonly' ); + await altInputArea.fill( 'new value' ); + + // Check that the image alt attribute didn't change. + const [ imageBlockObject ] = await editor.getBlocks(); + expect( imageBlockObject.attributes.alt ).toBe( + 'default alt value' + ); + // Check the value of the custom field is being updated by visiting the frontend. + const previewPage = await editor.openPreviewPage(); + await expect( + previewPage.locator( '#image-alt-binding img' ) + ).toHaveAttribute( 'alt', 'new value' ); + } ); + } ); } ); } );