diff --git a/assets/src/js/bindings/block-editor.js b/assets/src/js/bindings/block-editor.js new file mode 100644 index 00000000..db2565b1 --- /dev/null +++ b/assets/src/js/bindings/block-editor.js @@ -0,0 +1,308 @@ +/** + * WordPress dependencies + */ +import { useState, useEffect, useCallback, useMemo } from '@wordpress/element'; +import { addFilter } from '@wordpress/hooks'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { + InspectorControls, + useBlockBindingsUtils, +} from '@wordpress/block-editor'; +import { + ComboboxControl, + __experimentalToolsPanel as ToolsPanel, + __experimentalToolsPanelItem as ToolsPanelItem, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useSelect } from '@wordpress/data'; +import { store as coreDataStore } from '@wordpress/core-data'; +import { store as editorStore } from '@wordpress/editor'; + +// These constant and the function above have been copied from Gutenberg. It should be public, eventually. + +const BLOCK_BINDINGS_CONFIG = { + 'core/paragraph': { + content: [ 'text', 'textarea', 'date_picker', 'number', 'range' ], + }, + 'core/heading': { + content: [ 'text', 'textarea', 'date_picker', 'number', 'range' ], + }, + 'core/image': { + id: [ 'image' ], + url: [ 'image' ], + title: [ 'image' ], + alt: [ 'image' ], + }, + 'core/button': { + url: [ 'url' ], + text: [ 'text', 'checkbox', 'select', 'date_picker' ], + linkTarget: [ 'text', 'checkbox', 'select' ], + rel: [ 'text', 'checkbox', 'select' ], + }, +}; + +/** + * Gets the bindable attributes for a given block. + * + * @param {string} blockName The name of the block. + * + * @return {string[]} The bindable attributes for the block. + */ +function getBindableAttributes( blockName ) { + const config = BLOCK_BINDINGS_CONFIG[ blockName ]; + return config ? Object.keys( config ) : []; +} + +/** + * Add custom controls to all blocks + */ +const withCustomControls = createHigherOrderComponent( ( BlockEdit ) => { + return ( props ) => { + const bindableAttributes = getBindableAttributes( props.name ); + const { updateBlockBindings, removeAllBlockBindings } = + useBlockBindingsUtils(); + + // Get ACF fields for current post + const fields = useSelect( ( select ) => { + const { getEditedEntityRecord } = select( coreDataStore ); + const { getCurrentPostType, getCurrentPostId } = + select( editorStore ); + + const postType = getCurrentPostType(); + const postId = getCurrentPostId(); + + if ( ! postType || ! postId ) return {}; + + const record = getEditedEntityRecord( + 'postType', + postType, + postId + ); + + // Extract fields that end with '_source' (simplified) + const sourcedFields = {}; + Object.entries( record?.acf || {} ).forEach( ( [ key, value ] ) => { + if ( key.endsWith( '_source' ) ) { + const baseFieldName = key.replace( '_source', '' ); + if ( record?.acf.hasOwnProperty( baseFieldName ) ) { + sourcedFields[ baseFieldName ] = value; + } + } + } ); + return sourcedFields; + }, [] ); + + // Get filtered field options for an attribute + const getFieldOptions = useCallback( + ( attribute = null ) => { + if ( ! fields || Object.keys( fields ).length === 0 ) return []; + + const blockConfig = BLOCK_BINDINGS_CONFIG[ props.name ]; + let allowedTypes = null; + + if ( blockConfig ) { + allowedTypes = attribute + ? blockConfig[ attribute ] + : Object.values( blockConfig ).flat(); + } + + return Object.entries( fields ) + .filter( + ( [ , fieldConfig ] ) => + ! allowedTypes || + allowedTypes.includes( fieldConfig.type ) + ) + .map( ( [ fieldName, fieldConfig ] ) => ( { + value: fieldName, + label: fieldConfig.label, + } ) ); + }, + [ fields, props.name ] + ); + + // Check if all attributes use the same field types (for "all attributes" mode) + const canUseAllAttributesMode = useMemo( () => { + if ( ! bindableAttributes || bindableAttributes.length <= 1 ) + return false; + + const blockConfig = BLOCK_BINDINGS_CONFIG[ props.name ]; + if ( ! blockConfig ) return false; + + const firstAttributeTypes = + blockConfig[ bindableAttributes[ 0 ] ] || []; + return bindableAttributes.every( ( attr ) => { + const attrTypes = blockConfig[ attr ] || []; + return ( + attrTypes.length === firstAttributeTypes.length && + attrTypes.every( ( type ) => + firstAttributeTypes.includes( type ) + ) + ); + } ); + }, [ bindableAttributes, props.name ] ); + + // Track bound fields + const [ boundFields, setBoundFields ] = useState( {} ); + + // Sync with current bindings + useEffect( () => { + const currentBindings = props.attributes?.metadata?.bindings || {}; + const newBoundFields = {}; + + Object.keys( currentBindings ).forEach( ( attribute ) => { + if ( currentBindings[ attribute ]?.args?.key ) { + newBoundFields[ attribute ] = + currentBindings[ attribute ].args.key; + } + } ); + + setBoundFields( newBoundFields ); + }, [ props.attributes?.metadata?.bindings ] ); + + // Handle field selection + const handleFieldChange = useCallback( + ( attribute, value ) => { + if ( Array.isArray( attribute ) ) { + // Handle multiple attributes at once + const newBoundFields = { ...boundFields }; + const bindings = {}; + + attribute.forEach( ( attr ) => { + newBoundFields[ attr ] = value; + bindings[ attr ] = value + ? { + source: 'acf/field', + args: { key: value }, + } + : undefined; + } ); + + setBoundFields( newBoundFields ); + updateBlockBindings( bindings ); + } else { + // Handle single attribute + setBoundFields( ( prev ) => ( { + ...prev, + [ attribute ]: value, + } ) ); + updateBlockBindings( { + [ attribute ]: value + ? { + source: 'acf/field', + args: { key: value }, + } + : undefined, + } ); + } + }, + [ boundFields, updateBlockBindings ] + ); + + // Handle reset + const handleReset = useCallback( () => { + removeAllBlockBindings(); + setBoundFields( {} ); + }, [ removeAllBlockBindings ] ); + + // Don't show if no fields or attributes + const fieldOptions = getFieldOptions(); + if ( fieldOptions.length === 0 || ! bindableAttributes ) { + return ; + } + + return ( + <> + + + + { canUseAllAttributesMode ? ( + + !! boundFields[ bindableAttributes[ 0 ] ] + } + label={ __( + 'All attributes', + 'secure-custom-fields' + ) } + onDeselect={ () => + handleFieldChange( + bindableAttributes, + null + ) + } + isShownByDefault={ true } + > + + handleFieldChange( + bindableAttributes, + value + ) + } + /> + + ) : ( + bindableAttributes.map( ( attribute ) => ( + + !! boundFields[ attribute ] + } + label={ attribute } + onDeselect={ () => + handleFieldChange( attribute, null ) + } + isShownByDefault={ true } + > + + handleFieldChange( + attribute, + value + ) + } + /> + + ) ) + ) } + + + + ); + }; +}, 'withCustomControls' ); + +if ( window.scf?.betaFeatures?.connect_fields ) { + addFilter( + 'editor.BlockEdit', + 'secure-custom-fields/with-custom-controls', + withCustomControls + ); +} diff --git a/assets/src/js/bindings/index.js b/assets/src/js/bindings/index.js index 278d4879..c81a6544 100644 --- a/assets/src/js/bindings/index.js +++ b/assets/src/js/bindings/index.js @@ -1 +1,2 @@ import './sources.js'; +import './block-editor.js'; diff --git a/assets/src/js/bindings/sources.js b/assets/src/js/bindings/sources.js index 0dcfced6..84269a1c 100644 --- a/assets/src/js/bindings/sources.js +++ b/assets/src/js/bindings/sources.js @@ -1,40 +1,105 @@ /** * WordPress dependencies. */ -import { __ } from '@wordpress/i18n'; import { registerBlockBindingsSource } from '@wordpress/blocks'; import { store as coreDataStore } from '@wordpress/core-data'; /** - * Get the value of a specific field from the ACF fields. + * Get the SCF fields from the post entity. * - * @param {Object} fields The ACF fields object. - * @param {string} fieldName The name of the field to retrieve. - * @returns {string} The value of the specified field, or undefined if not found. + * @param {Object} post The post entity object. + * @returns {Object} The SCF fields object with source data. */ -const getFieldValue = ( fields, fieldName ) => fields?.acf?.[ fieldName ]; +const getSCFFields = ( post ) => { + if ( ! post?.acf ) { + return {}; + } + + // Extract only the _source fields which contain the formatted data + const sourceFields = {}; + Object.entries( post.acf ).forEach( ( [ key, value ] ) => { + if ( key.endsWith( '_source' ) ) { + // Remove the _source suffix to get the field name + const fieldName = key.replace( '_source', '' ); + sourceFields[ fieldName ] = value; + } + } ); + + return sourceFields; +}; +/** + * Resolve image attribute values from an image object. + * + * @param {Object} imageObj The image object from SCF field data. + * @param {string} attribute The attribute to resolve. + * @returns {string} The resolved attribute value. + */ const resolveImageAttribute = ( imageObj, attribute ) => { if ( ! imageObj ) return ''; switch ( attribute ) { case 'url': - case 'content': - return imageObj.source_url; + return imageObj.url || ''; case 'alt': - return imageObj.alt_text || ''; + return imageObj.alt || ''; case 'title': - return imageObj.title?.rendered || ''; + return imageObj.title || ''; + case 'id': + return imageObj.id || imageObj.ID || ''; default: return ''; } }; +/** + * Process a single field binding and return its resolved value. + * + * @param {string} attribute The attribute being bound. + * @param {Object} args The binding arguments. + * @param {Object} scfFields The SCF fields object. + * @returns {string} The resolved field value. + */ +const processFieldBinding = ( attribute, args, scfFields ) => { + const fieldName = args?.key; + const fieldConfig = scfFields[ fieldName ]; + + if ( ! fieldConfig ) { + return ''; + } + + const fieldType = fieldConfig.type; + const fieldValue = fieldConfig.formatted_value; + + switch ( fieldType ) { + case 'image': + return resolveImageAttribute( fieldValue, attribute ); + case 'checkbox': + // For checkbox fields, join array values or return as string + if ( Array.isArray( fieldValue ) ) { + return fieldValue.join( ', ' ); + } + return fieldValue ? fieldValue.toString() : ''; + case 'number': + case 'range': + return fieldValue ? fieldValue.toString() : ''; + case 'date_picker': + case 'text': + case 'textarea': + case 'url': + case 'email': + case 'select': + default: + return fieldValue ? fieldValue.toString() : ''; + } +}; + registerBlockBindingsSource( { name: 'acf/field', label: 'SCF Fields', getValues( { context, bindings, select } ) { - const { getEditedEntityRecord, getMedia } = select( coreDataStore ); - let fields = + const { getEditedEntityRecord } = select( coreDataStore ); + + const post = context?.postType && context?.postId ? getEditedEntityRecord( 'postType', @@ -42,36 +107,15 @@ registerBlockBindingsSource( { context.postId ) : undefined; + + const scfFields = getSCFFields( post ); + const result = {}; Object.entries( bindings ).forEach( ( [ attribute, { args } = {} ] ) => { - const fieldName = args?.key; - - const fieldValue = getFieldValue( fields, fieldName ); - if ( typeof fieldValue === 'object' && fieldValue !== null ) { - let value = ''; - - if ( fieldValue[ attribute ] ) { - value = fieldValue[ attribute ]; - } else if ( attribute === 'content' && fieldValue.url ) { - value = fieldValue.url; - } - - result[ attribute ] = value; - } else if ( typeof fieldValue === 'number' ) { - if ( attribute === 'content' ) { - result[ attribute ] = fieldValue.toString() || ''; - } else { - const imageObj = getMedia( fieldValue ); - result[ attribute ] = resolveImageAttribute( - imageObj, - attribute - ); - } - } else { - result[ attribute ] = fieldValue || ''; - } + const value = processFieldBinding( attribute, args, scfFields ); + result[ attribute ] = value; } ); diff --git a/assets/src/sass/_forms.scss b/assets/src/sass/_forms.scss index 7cb7883d..c9430736 100644 --- a/assets/src/sass/_forms.scss +++ b/assets/src/sass/_forms.scss @@ -313,5 +313,4 @@ p.submit .acf-spinner { margin: 0; } } -} - +} \ No newline at end of file diff --git a/includes/admin/beta-features.php b/includes/admin/beta-features.php index 1051fd58..c5feb400 100644 --- a/includes/admin/beta-features.php +++ b/includes/admin/beta-features.php @@ -35,8 +35,7 @@ class SCF_Admin_Beta_Features { * @return void */ public function __construct() { - // Temporarily disabled - will be enabled when beta feature is ready - // add_action( 'admin_menu', array( $this, 'admin_menu' ), 20 ); + add_action( 'admin_menu', array( $this, 'admin_menu' ), 20 ); } /** @@ -78,26 +77,6 @@ public function get_beta_features() { return $this->beta_features; } - /** - * Localizes the beta features data. - * - * @since SCF 6.5.0 - * - * @return void - */ - public function localize_beta_features() { - $beta_features = array(); - foreach ( $this->get_beta_features() as $name => $beta_feature ) { - $beta_features[ $name ] = $beta_feature->is_enabled(); - } - - acf_localize_data( - array( - 'betaFeatures' => $beta_features, - ) - ); - } - /** * This function will add the SCF beta features menu item to the WP admin * @@ -115,7 +94,7 @@ public function admin_menu() { $page = add_submenu_page( 'edit.php?post_type=acf-field-group', __( 'Beta Features', 'secure-custom-fields' ), __( 'Beta Features', 'secure-custom-fields' ), acf_get_setting( 'capability' ), 'scf-beta-features', array( $this, 'html' ) ); add_action( 'load-' . $page, array( $this, 'load' ) ); - add_action( 'admin_enqueue_scripts', array( $this, 'localize_beta_features' ), 20 ); + add_action( 'admin_enqueue_scripts', array( $this, 'add_beta_features_script' ), 20 ); } /** @@ -155,7 +134,7 @@ public function admin_body_class( $classes ) { */ private function include_beta_features() { acf_include( 'includes/admin/beta-features/class-scf-beta-feature.php' ); - acf_include( 'includes/admin/beta-features/class-scf-beta-feature-editor-sidebar.php' ); + acf_include( 'includes/admin/beta-features/class-scf-beta-feature-connect-fields.php' ); add_action( 'scf/include_admin_beta_features', array( $this, 'register_beta_features' ) ); @@ -170,7 +149,7 @@ private function include_beta_features() { * @return void */ public function register_beta_features() { - scf_register_admin_beta_feature( 'SCF_Admin_Beta_Feature_Editor_Sidebar' ); + scf_register_admin_beta_feature( 'SCF_Admin_Beta_Feature_Connect_Fields' ); } /** @@ -255,6 +234,27 @@ public function metabox_html( $post, $metabox ) { acf_nonce_input( $beta_feature->name ); echo ''; } + + /** + * Adds the editor sidebar script to the page. + * + * @since SCF 6.5.0 + * + * @return void + */ + public function add_beta_features_script() { + // Check if the connected fields feature is enabled + + $script = 'window.scf = window.scf || {}; +window.scf.betaFeatures = window.scf.betaFeatures || {};'; + foreach ( $this->get_beta_features() as $name => $beta_feature ) { + if ( $beta_feature->is_enabled() ) { + $script .= sprintf( 'window.scf.betaFeatures.%s = true;', esc_js( $name ) ); + } + } + + wp_add_inline_script( 'wp-block-editor', $script, 'before' ); + } } // initialize diff --git a/includes/admin/beta-features/class-scf-beta-feature-connect-fields.php b/includes/admin/beta-features/class-scf-beta-feature-connect-fields.php new file mode 100644 index 00000000..bec9c6ad --- /dev/null +++ b/includes/admin/beta-features/class-scf-beta-feature-connect-fields.php @@ -0,0 +1,37 @@ +name = 'connect_fields'; + $this->title = __( 'Connect Fields', 'secure-custom-fields' ); + $this->description = __( 'Connects field to binding compatible blocks.', 'secure-custom-fields' ); + } + } +endif; diff --git a/includes/rest-api/class-acf-rest-api.php b/includes/rest-api/class-acf-rest-api.php index ef94a48a..40005b6b 100644 --- a/includes/rest-api/class-acf-rest-api.php +++ b/includes/rest-api/class-acf-rest-api.php @@ -234,7 +234,13 @@ public function load_fields( $object, $field_name, $request, $object_sub_type ) $format = $request->get_param( 'acf_format' ) ?: acf_get_setting( 'rest_api_format' ); $value = acf_format_value_for_rest( $value, $post_id, $field, $format ); - $fields[ $field['name'] ] = $value; + // We keep this one for backward compatibility with existing code that expects the field value to be. + $fields[ $field['name'] ] = $value; + $fields[ $field['name'] . '_source' ] = array( + 'label' => $field['label'], + 'type' => $field['type'], + 'formatted_value' => acf_format_value( $value, $post_id, $field ), + ); } } diff --git a/package-lock.json b/package-lock.json index 51abb472..6116c2ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "@wordpress/icons": "^10.26.0", "md5": "^2.3.0" }, "devDependencies": { diff --git a/package.json b/package.json index d2a2a547..48c2a22f 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "watch": "webpack --watch" }, "dependencies": { + "@wordpress/icons": "^10.26.0", "md5": "^2.3.0" }, "devDependencies": { diff --git a/secure-custom-fields.php b/secure-custom-fields.php index 6a98a943..e13ac475 100644 --- a/secure-custom-fields.php +++ b/secure-custom-fields.php @@ -879,6 +879,7 @@ function scf_plugin_uninstall() { // List of known beta features. $beta_features = array( 'editor_sidebar', + 'connect_fields', ); foreach ( $beta_features as $beta_feature ) {