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 ) {