diff --git a/assets/src/js/bindings/block-editor.js b/assets/src/js/bindings/block-editor.js
index e0ad5c23..8b8464b2 100644
--- a/assets/src/js/bindings/block-editor.js
+++ b/assets/src/js/bindings/block-editor.js
@@ -1,7 +1,7 @@
/**
* WordPress dependencies
*/
-import { useState, useEffect, useCallback, useMemo } from '@wordpress/element';
+import { useCallback, useMemo } from '@wordpress/element';
import { addFilter } from '@wordpress/hooks';
import { createHigherOrderComponent } from '@wordpress/compose';
import {
@@ -14,47 +14,28 @@ import {
__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.
+ * Internal dependencies
*/
-function getBindableAttributes( blockName ) {
- const config = BLOCK_BINDINGS_CONFIG[ blockName ];
- return config ? Object.keys( config ) : [];
-}
+import { BINDING_SOURCE } from './constants';
+import {
+ getBindableAttributes,
+ getFilteredFieldOptions,
+ canUseUnifiedBinding,
+ fieldsToOptions,
+} from './utils';
+import {
+ useSiteEditorContext,
+ usePostEditorFields,
+ useSiteEditorFields,
+ useBoundFields,
+} from './hooks';
/**
- * Add custom controls to all blocks
+ * Add custom block binding controls to supported blocks.
+ *
+ * @since 6.5.0
*/
const withCustomControls = createHigherOrderComponent( ( BlockEdit ) => {
return ( props ) => {
@@ -62,104 +43,47 @@ const withCustomControls = createHigherOrderComponent( ( BlockEdit ) => {
const { updateBlockBindings, removeAllBlockBindings } =
useBlockBindingsUtils();
- // Get ACF fields for current post
- const fields = useSelect( ( select ) => {
- const { getEditedEntityRecord } = select( coreDataStore );
- const { getCurrentPostType, getCurrentPostId } =
- select( editorStore );
+ // Get editor context
+ const { isSiteEditor, templatePostType } = useSiteEditorContext();
- const postType = getCurrentPostType();
- const postId = getCurrentPostId();
+ // Get fields based on editor context
+ const postEditorFields = usePostEditorFields();
+ const { fields: siteEditorFields } =
+ useSiteEditorFields( templatePostType );
- if ( ! postType || ! postId ) return {};
+ // Use appropriate fields based on context
+ const activeFields = isSiteEditor ? siteEditorFields : postEditorFields;
- const record = getEditedEntityRecord(
- 'postType',
- postType,
- postId
- );
+ // Convert fields to options format
+ const allFieldOptions = useMemo(
+ () => fieldsToOptions( activeFields ),
+ [ activeFields ]
+ );
- // 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;
- }, [] );
+ // Track bound fields
+ const { boundFields, setBoundFields } = useBoundFields(
+ props.attributes
+ );
- // Get filtered field options for an attribute
- const getFieldOptions = useCallback(
+ // Get filtered field options for a specific attribute
+ const getAttributeFieldOptions = 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,
- } ) );
+ return getFilteredFieldOptions(
+ allFieldOptions,
+ props.name,
+ attribute
+ );
},
- [ fields, props.name ]
+ [ allFieldOptions, 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 ] );
+ // Check if all attributes can use unified binding mode
+ const canUseAllAttributesMode = useMemo(
+ () => canUseUnifiedBinding( props.name, bindableAttributes ),
+ [ props.name, bindableAttributes ]
+ );
- // Handle field selection
+ // Handle field selection changes
const handleFieldChange = useCallback(
( attribute, value ) => {
if ( Array.isArray( attribute ) ) {
@@ -171,7 +95,7 @@ const withCustomControls = createHigherOrderComponent( ( BlockEdit ) => {
newBoundFields[ attr ] = value;
bindings[ attr ] = value
? {
- source: 'acf/field',
+ source: BINDING_SOURCE,
args: { key: value },
}
: undefined;
@@ -188,29 +112,37 @@ const withCustomControls = createHigherOrderComponent( ( BlockEdit ) => {
updateBlockBindings( {
[ attribute ]: value
? {
- source: 'acf/field',
+ source: BINDING_SOURCE,
args: { key: value },
}
: undefined,
} );
}
},
- [ boundFields, updateBlockBindings ]
+ [ boundFields, setBoundFields, updateBlockBindings ]
);
- // Handle reset
+ // Handle reset all bindings
const handleReset = useCallback( () => {
removeAllBlockBindings();
setBoundFields( {} );
- }, [ removeAllBlockBindings ] );
-
- // Don't show if no fields or attributes
- const fieldOptions = getFieldOptions();
- if ( fieldOptions.length === 0 || ! bindableAttributes ) {
- return ;
- }
+ }, [ removeAllBlockBindings, setBoundFields ] );
+
+ // Determine if we should show the panel
+ const shouldShowPanel = useMemo( () => {
+ // In site editor, show panel if block has bindable attributes
+ if ( isSiteEditor ) {
+ return bindableAttributes && bindableAttributes.length > 0;
+ }
+ // In post editor, only show if we have fields available
+ return (
+ allFieldOptions.length > 0 &&
+ bindableAttributes &&
+ bindableAttributes.length > 0
+ );
+ }, [ isSiteEditor, allFieldOptions, bindableAttributes ] );
- if ( bindableAttributes.length === 0 ) {
+ if ( ! shouldShowPanel ) {
return ;
}
@@ -239,7 +171,7 @@ const withCustomControls = createHigherOrderComponent( ( BlockEdit ) => {
null
)
}
- isShownByDefault={ true }
+ isShownByDefault
>
{
'Select a field',
'secure-custom-fields'
) }
- options={ getFieldOptions() }
+ options={ getAttributeFieldOptions() }
value={
boundFields[
bindableAttributes[ 0 ]
@@ -269,7 +201,7 @@ const withCustomControls = createHigherOrderComponent( ( BlockEdit ) => {
) : (
bindableAttributes.map( ( attribute ) => (
!! boundFields[ attribute ]
}
@@ -277,7 +209,7 @@ const withCustomControls = createHigherOrderComponent( ( BlockEdit ) => {
onDeselect={ () =>
handleFieldChange( attribute, null )
}
- isShownByDefault={ true }
+ isShownByDefault
>
{
'Select a field',
'secure-custom-fields'
) }
- options={ getFieldOptions( attribute ) }
+ options={ getAttributeFieldOptions(
+ attribute
+ ) }
value={ boundFields[ attribute ] || '' }
onChange={ ( value ) =>
handleFieldChange(
diff --git a/assets/src/js/bindings/constants.js b/assets/src/js/bindings/constants.js
new file mode 100644
index 00000000..1ff3192f
--- /dev/null
+++ b/assets/src/js/bindings/constants.js
@@ -0,0 +1,30 @@
+/**
+ * Block binding configuration
+ *
+ * Defines which SCF field types can be bound to specific block attributes.
+ */
+export 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' ],
+ },
+};
+
+/**
+ * Binding source identifier
+ */
+export const BINDING_SOURCE = 'acf/field';
diff --git a/assets/src/js/bindings/field-processing.js b/assets/src/js/bindings/field-processing.js
new file mode 100644
index 00000000..f54f141d
--- /dev/null
+++ b/assets/src/js/bindings/field-processing.js
@@ -0,0 +1,152 @@
+/**
+ * Field processing utilities for block bindings
+ *
+ * @since 6.5.0
+ */
+
+/**
+ * Gets the SCF fields from the post entity.
+ *
+ * @since 6.5.0
+ *
+ * @param {Object} post The post entity object.
+ * @return {Object} The SCF fields object with source data.
+ */
+export function 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;
+}
+
+/**
+ * Resolves image attribute values from an image object.
+ *
+ * @since 6.5.0
+ *
+ * @param {Object} imageObj The image object from SCF field data.
+ * @param {string} attribute The attribute to resolve (url, alt, title, id).
+ * @return {string|number} The resolved attribute value.
+ */
+export function resolveImageAttribute( imageObj, attribute ) {
+ if ( ! imageObj ) {
+ return '';
+ }
+
+ switch ( attribute ) {
+ case 'url':
+ return imageObj.url || '';
+ case 'alt':
+ return imageObj.alt || '';
+ case 'title':
+ return imageObj.title || '';
+ case 'id':
+ return imageObj.id || imageObj.ID || '';
+ default:
+ return '';
+ }
+}
+
+/**
+ * Processes a single field binding and returns its resolved value.
+ *
+ * @since 6.7.0
+ *
+ * @param {string} attribute The attribute being bound.
+ * @param {Object} args The binding arguments.
+ * @param {Object} scfFields The SCF fields object.
+ * @return {string} The resolved field value.
+ */
+export function 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 ? String( fieldValue ) : '';
+
+ case 'number':
+ case 'range':
+ return fieldValue ? String( fieldValue ) : '';
+
+ case 'date_picker':
+ case 'text':
+ case 'textarea':
+ case 'url':
+ case 'email':
+ case 'select':
+ default:
+ return fieldValue ? String( fieldValue ) : '';
+ }
+}
+
+/**
+ * Formats a field key into a human-readable label.
+ *
+ * @since 6.7.0
+ *
+ * @param {string} fieldKey The field key (e.g., 'my_field_name').
+ * @return {string} Formatted label (e.g., 'My Field Name').
+ */
+export function formatFieldLabel( fieldKey ) {
+ if ( ! fieldKey ) {
+ return '';
+ }
+
+ return fieldKey
+ .split( '_' )
+ .map( ( word ) => word.charAt( 0 ).toUpperCase() + word.slice( 1 ) )
+ .join( ' ' );
+}
+
+/**
+ * Gets the field label from metadata or formats the field key.
+ *
+ * @since 6.7.0
+ * @param {string} fieldKey The field key.
+ * @param {Object} fieldMetadata Optional field metadata object.
+ * @param {string} defaultLabel Optional default label to use.
+ * @return {string} The field label.
+ */
+export function getFieldLabel(
+ fieldKey,
+ fieldMetadata = null,
+ defaultLabel = ''
+) {
+ if ( ! fieldKey ) {
+ return defaultLabel;
+ }
+
+ // Try to get the label from the provided metadata
+ if ( fieldMetadata && fieldMetadata[ fieldKey ]?.label ) {
+ return fieldMetadata[ fieldKey ].label;
+ }
+
+ // Fallback: format field key as a label
+ return formatFieldLabel( fieldKey );
+}
diff --git a/assets/src/js/bindings/fieldMetadataCache.js b/assets/src/js/bindings/fieldMetadataCache.js
new file mode 100644
index 00000000..8d94f20e
--- /dev/null
+++ b/assets/src/js/bindings/fieldMetadataCache.js
@@ -0,0 +1,62 @@
+/**
+ * SCF Field Metadata Cache
+ *
+ * @since 6.7.0
+ * Simple cache for field metadata used in block bindings.
+ */
+
+let fieldMetadataCache = {};
+
+/**
+ * Set field metadata, replacing all existing data.
+ *
+ * @param {Object} fields - Field metadata object keyed by field key.
+ */
+export const setFieldMetadata = ( fields ) => {
+ fieldMetadataCache = fields || {};
+};
+
+/**
+ * Add field metadata, merging with existing data.
+ *
+ * @param {Object} fields - Field metadata object keyed by field key.
+ */
+export const addFieldMetadata = ( fields ) => {
+ fieldMetadataCache = { ...fieldMetadataCache, ...fields };
+};
+
+/**
+ * Clear all field metadata.
+ */
+export const clearFieldMetadata = () => {
+ fieldMetadataCache = {};
+};
+
+/**
+ * Get field metadata for a specific field.
+ *
+ * @param {string} fieldKey - The field key to retrieve metadata for.
+ * @return {Object|null} Field metadata object or null if not found.
+ */
+export const getFieldMetadata = ( fieldKey ) => {
+ return fieldMetadataCache[ fieldKey ] || null;
+};
+
+/**
+ * Get all field metadata.
+ *
+ * @return {Object} All field metadata.
+ */
+export const getAllFieldMetadata = () => {
+ return fieldMetadataCache;
+};
+
+/**
+ * Check if field metadata exists for a given key.
+ *
+ * @param {string} fieldKey - The field key to check.
+ * @return {boolean} True if metadata exists, false otherwise.
+ */
+export const hasFieldMetadata = ( fieldKey ) => {
+ return !! fieldMetadataCache[ fieldKey ];
+};
diff --git a/assets/src/js/bindings/hooks.js b/assets/src/js/bindings/hooks.js
new file mode 100644
index 00000000..62232f57
--- /dev/null
+++ b/assets/src/js/bindings/hooks.js
@@ -0,0 +1,181 @@
+/**
+ * Custom hooks for block bindings
+ */
+
+import { useState, useEffect } from '@wordpress/element';
+import { useSelect } from '@wordpress/data';
+import { store as coreDataStore } from '@wordpress/core-data';
+import { store as editorStore } from '@wordpress/editor';
+import apiFetch from '@wordpress/api-fetch';
+import { addQueryArgs } from '@wordpress/url';
+
+import { extractPostTypeFromTemplate, formatFieldGroupsData } from './utils';
+import { addFieldMetadata } from './fieldMetadataCache';
+
+/**
+ * Custom hook to detect if we're in the site editor and get the template info.
+ *
+ * @since 6.7.0
+ * @return {Object} Object containing isSiteEditor flag and templatePostType.
+ */
+export function useSiteEditorContext() {
+ return useSelect( ( select ) => {
+ const { getCurrentPostType, getCurrentPostId } = select( editorStore );
+ const { getEditedEntityRecord } = select( coreDataStore );
+
+ const postType = getCurrentPostType();
+ const postId = getCurrentPostId();
+
+ const isSiteEditor = postType === 'wp_template';
+
+ if ( ! isSiteEditor ) {
+ return {
+ isSiteEditor: false,
+ templatePostType: null,
+ };
+ }
+
+ const template = getEditedEntityRecord(
+ 'postType',
+ 'wp_template',
+ postId
+ );
+
+ const templatePostType = extractPostTypeFromTemplate(
+ template?.slug || ''
+ );
+
+ return {
+ isSiteEditor: true,
+ templatePostType,
+ };
+ }, [] );
+}
+
+/**
+ * Custom hook to get SCF fields for the current post editor context.
+ *
+ * @since 6.7.0
+ * @return {Object} Object containing the fields map.
+ */
+export function usePostEditorFields() {
+ return useSelect( ( select ) => {
+ const { getCurrentPostType, getCurrentPostId } = select( editorStore );
+ const { getEditedEntityRecord } = select( coreDataStore );
+
+ const postType = getCurrentPostType();
+ const postId = getCurrentPostId();
+
+ if ( ! postType || ! postId || postType === 'wp_template' ) {
+ return {};
+ }
+
+ const record = getEditedEntityRecord( 'postType', postType, postId );
+
+ // Extract fields that have '_source' counterparts
+ const sourcedFields = {};
+ if ( record?.acf ) {
+ Object.entries( record.acf ).forEach( ( [ key, value ] ) => {
+ if ( key.endsWith( '_source' ) ) {
+ const baseFieldName = key.replace( '_source', '' );
+ if ( Object.hasOwn( record.acf, baseFieldName ) ) {
+ sourcedFields[ baseFieldName ] = value;
+ }
+ }
+ } );
+ }
+
+ return sourcedFields;
+ }, [] );
+}
+
+/**
+ * Custom hook to fetch and manage SCF field groups from the REST API.
+ *
+ * @since 6.7.0
+ * @param {string|null} postType The post type to fetch fields for.
+ * @return {Object} Object containing fields, isLoading, and error.
+ */
+export function useSiteEditorFields( postType ) {
+ const [ fields, setFields ] = useState( {} );
+ const [ isLoading, setIsLoading ] = useState( false );
+ const [ error, setError ] = useState( null );
+
+ useEffect( () => {
+ if ( ! postType ) {
+ setFields( {} );
+ setIsLoading( false );
+ setError( null );
+ return;
+ }
+
+ let isCancelled = false;
+ setIsLoading( true );
+ setError( null );
+
+ const fetchFields = async () => {
+ try {
+ const path = addQueryArgs( `/wp/v2/types/${ postType }`, {
+ context: 'edit',
+ } );
+
+ const postTypeData = await apiFetch( { path } );
+
+ if ( isCancelled ) {
+ return;
+ }
+
+ const fieldsMap = formatFieldGroupsData(
+ postTypeData.scf_field_groups
+ );
+
+ // Store field metadata in the data store
+ addFieldMetadata( fieldsMap );
+
+ setFields( fieldsMap );
+ setIsLoading( false );
+ } catch ( err ) {
+ if ( ! isCancelled ) {
+ setError( err );
+ setIsLoading( false );
+ }
+ }
+ };
+
+ fetchFields();
+
+ // Cleanup function to prevent state updates after unmount
+ return () => {
+ isCancelled = true;
+ };
+ }, [ postType ] );
+
+ return { fields, isLoading, error };
+}
+
+/**
+ * Custom hook to manage block bindings state.
+ *
+ * @since 6.7.0
+ * @param {Object} blockAttributes The block attributes object.
+ * @return {Object} Object containing boundFields and sync function.
+ */
+export function useBoundFields( blockAttributes ) {
+ const [ boundFields, setBoundFields ] = useState( {} );
+
+ useEffect( () => {
+ const currentBindings = blockAttributes?.metadata?.bindings || {};
+ const newBoundFields = {};
+
+ Object.keys( currentBindings ).forEach( ( attribute ) => {
+ if ( currentBindings[ attribute ]?.args?.key ) {
+ newBoundFields[ attribute ] =
+ currentBindings[ attribute ].args.key;
+ }
+ } );
+
+ setBoundFields( newBoundFields );
+ }, [ blockAttributes?.metadata?.bindings ] );
+
+ return { boundFields, setBoundFields };
+}
diff --git a/assets/src/js/bindings/sources.js b/assets/src/js/bindings/sources.js
index 84269a1c..e48dc6e9 100644
--- a/assets/src/js/bindings/sources.js
+++ b/assets/src/js/bindings/sources.js
@@ -1,102 +1,67 @@
/**
- * WordPress dependencies.
+ * WordPress dependencies
*/
import { registerBlockBindingsSource } from '@wordpress/blocks';
import { store as coreDataStore } from '@wordpress/core-data';
+import { store as editorStore } from '@wordpress/editor';
+import { __ } from '@wordpress/i18n';
/**
- * Get the SCF fields from the post entity.
- *
- * @param {Object} post The post entity object.
- * @returns {Object} The SCF fields object with source data.
+ * Internal dependencies
*/
-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':
- return imageObj.url || '';
- case 'alt':
- return imageObj.alt || '';
- case 'title':
- return imageObj.title || '';
- case 'id':
- return imageObj.id || imageObj.ID || '';
- default:
- return '';
- }
-};
+import {
+ getSCFFields,
+ processFieldBinding,
+ formatFieldLabel,
+} from './field-processing';
+import { getFieldMetadata } from './fieldMetadataCache';
/**
- * 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.
+ * Register the SCF field binding source.
*/
-const processFieldBinding = ( attribute, args, scfFields ) => {
- const fieldName = args?.key;
- const fieldConfig = scfFields[ fieldName ];
+registerBlockBindingsSource( {
+ name: 'acf/field',
+ label: __( 'SCF Fields', 'secure-custom-fields' ),
+ getLabel( { args, select } ) {
+ const fieldKey = args?.key;
- if ( ! fieldConfig ) {
- return '';
- }
+ if ( ! fieldKey ) {
+ return __( 'SCF Fields', 'secure-custom-fields' );
+ }
- const fieldType = fieldConfig.type;
- const fieldValue = fieldConfig.formatted_value;
+ const fieldMetadata = getFieldMetadata( fieldKey );
- 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() : '';
- }
-};
+ if ( fieldMetadata?.label ) {
+ return fieldMetadata.label;
+ }
-registerBlockBindingsSource( {
- name: 'acf/field',
- label: 'SCF Fields',
+ return formatFieldLabel( fieldKey );
+ },
getValues( { context, bindings, select } ) {
+ const { getCurrentPostType } = select( editorStore );
+ const currentPostType = getCurrentPostType();
+ const isSiteEditor = currentPostType === 'wp_template';
+
+ // In site editor, return field labels as placeholder values
+ if ( isSiteEditor ) {
+ const result = {};
+ Object.entries( bindings ).forEach(
+ ( [ attribute, { args } = {} ] ) => {
+ const fieldKey = args?.key;
+ if ( ! fieldKey ) {
+ result[ attribute ] = '';
+ return;
+ }
+
+ const fieldMetadata = getFieldMetadata( fieldKey );
+ result[ attribute ] =
+ fieldMetadata?.label || formatFieldLabel( fieldKey );
+ }
+ );
+ return result;
+ }
+
+ // Regular post editor - get actual field values
const { getEditedEntityRecord } = select( coreDataStore );
const post =
@@ -109,7 +74,6 @@ registerBlockBindingsSource( {
: undefined;
const scfFields = getSCFFields( post );
-
const result = {};
Object.entries( bindings ).forEach(
diff --git a/assets/src/js/bindings/utils.js b/assets/src/js/bindings/utils.js
new file mode 100644
index 00000000..1b10b3aa
--- /dev/null
+++ b/assets/src/js/bindings/utils.js
@@ -0,0 +1,177 @@
+/**
+ * Utility functions for block bindings
+ */
+
+import { BLOCK_BINDINGS_CONFIG } from './constants';
+
+/**
+ * Gets the bindable attributes for a given block.
+ *
+ * @since 6.7.0
+ * @param {string} blockName The name of the block.
+ * @return {string[]} The bindable attributes for the block.
+ */
+export function getBindableAttributes( blockName ) {
+ const config = BLOCK_BINDINGS_CONFIG[ blockName ];
+ return config ? Object.keys( config ) : [];
+}
+
+/**
+ * Gets the allowed field types for a specific block attribute.
+ *
+ * @since 6.7.0
+ * @param {string} blockName The name of the block.
+ * @param {string|null} attribute The attribute name, or null for all types.
+ * @return {string[]|null} The allowed field types, or null if no restrictions.
+ */
+export function getAllowedFieldTypes( blockName, attribute = null ) {
+ const blockConfig = BLOCK_BINDINGS_CONFIG[ blockName ];
+
+ if ( ! blockConfig ) {
+ return null;
+ }
+
+ if ( attribute ) {
+ return blockConfig[ attribute ] || null;
+ }
+
+ // Get all unique field types for the block
+ return [ ...new Set( Object.values( blockConfig ).flat() ) ];
+}
+
+/**
+ * Filters field options based on allowed field types.
+ *
+ * @since 6.7.0
+ * @param {Array} fieldOptions Array of field option objects with value, label, and type.
+ * @param {string} blockName The name of the block.
+ * @param {string|null} attribute The attribute name, or null for all types.
+ * @return {Array} Filtered array of field options.
+ */
+export function getFilteredFieldOptions(
+ fieldOptions,
+ blockName,
+ attribute = null
+) {
+ if ( ! fieldOptions || fieldOptions.length === 0 ) {
+ return [];
+ }
+
+ const allowedTypes = getAllowedFieldTypes( blockName, attribute );
+
+ if ( ! allowedTypes ) {
+ return fieldOptions;
+ }
+
+ return fieldOptions.filter( ( option ) =>
+ allowedTypes.includes( option.type )
+ );
+}
+
+/**
+ * Checks if all bindable attributes for a block support the same field types.
+ *
+ * @since 6.7.0
+ * @param {string} blockName The name of the block.
+ * @param {string[]} bindableAttributes Array of bindable attribute names.
+ * @return {boolean} True if all attributes support the same field types.
+ */
+export function canUseUnifiedBinding( blockName, bindableAttributes ) {
+ if ( ! bindableAttributes || bindableAttributes.length <= 1 ) {
+ return false;
+ }
+
+ const blockConfig = BLOCK_BINDINGS_CONFIG[ blockName ];
+ 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 ) )
+ );
+ } );
+}
+
+/**
+ * Extracts the post type from a template slug.
+ *
+ * @since 6.7.0
+ * @param {string} templateSlug The template slug (e.g., 'single-product', 'archive-post').
+ * @return {string|null} The extracted post type, or null if not detected.
+ */
+export function extractPostTypeFromTemplate( templateSlug ) {
+ if ( ! templateSlug ) {
+ return null;
+ }
+
+ // Handle single templates
+ if ( templateSlug.startsWith( 'single-' ) ) {
+ return templateSlug.replace( 'single-', '' );
+ }
+
+ // Handle archive templates
+ if ( templateSlug.startsWith( 'archive-' ) ) {
+ return templateSlug.replace( 'archive-', '' );
+ }
+
+ // Default single template maps to 'post'
+ if ( templateSlug === 'single' ) {
+ return 'post';
+ }
+
+ return null;
+}
+
+/**
+ * Formats field data from API response into a usable structure.
+ *
+ * @since 6.7.0
+ * @param {Array} fieldGroups Array of field group objects from the API.
+ * @return {Object} Formatted fields map with field name as key.
+ */
+export function formatFieldGroupsData( fieldGroups ) {
+ const fieldsMap = {};
+
+ if ( ! Array.isArray( fieldGroups ) ) {
+ return fieldsMap;
+ }
+
+ fieldGroups.forEach( ( group ) => {
+ if ( Array.isArray( group.fields ) ) {
+ group.fields.forEach( ( field ) => {
+ fieldsMap[ field.name ] = {
+ label: field.label,
+ type: field.type,
+ };
+ } );
+ }
+ } );
+
+ return fieldsMap;
+}
+
+/**
+ * Converts fields map to options array for ComboboxControl.
+ *
+ * @since 6.7.0
+ * @param {Object} fieldsMap Object with field data.
+ * @return {Array} Array of option objects with value, label, and type.
+ */
+export function fieldsToOptions( fieldsMap ) {
+ if ( ! fieldsMap || Object.keys( fieldsMap ).length === 0 ) {
+ return [];
+ }
+
+ return Object.entries( fieldsMap ).map(
+ ( [ fieldName, fieldConfig ] ) => ( {
+ value: fieldName,
+ label: fieldConfig.label,
+ type: fieldConfig.type,
+ } )
+ );
+}
diff --git a/assets/src/js/pro/blocks-v3/components/error-boundary.js b/assets/src/js/pro/blocks-v3/components/error-boundary.js
index ecc35cb3..c49d96fc 100644
--- a/assets/src/js/pro/blocks-v3/components/error-boundary.js
+++ b/assets/src/js/pro/blocks-v3/components/error-boundary.js
@@ -1,4 +1,6 @@
import { Component, createContext } from '@wordpress/element';
+import { Placeholder, Button, Icon } from '@wordpress/components';
+import { blockDefault as blockIcon } from '@wordpress/icons';
// Create context outside the class
export const ErrorBoundaryContext = createContext( null );
diff --git a/includes/rest-api/class-acf-rest-types-endpoint.php b/includes/rest-api/class-acf-rest-types-endpoint.php
index 53499ffb..ff8805d6 100644
--- a/includes/rest-api/class-acf-rest-types-endpoint.php
+++ b/includes/rest-api/class-acf-rest-types-endpoint.php
@@ -21,37 +21,52 @@
class SCF_Rest_Types_Endpoint {
/**
- * Initialize the class.
+ * Valid source types for filtering post types.
+ *
+ * @since 6.7.0
+ * @var array
+ */
+ const VALID_SOURCES = array( 'core', 'scf', 'other' );
+
+ /**
+ * Initialize the class and register hooks.
*
* @since SCF 6.5.0
+ * @since 6.7.0 Simplified hook registration.
*/
public function __construct() {
add_action( 'rest_api_init', array( $this, 'register_extra_fields' ) );
add_action( 'rest_api_init', array( $this, 'register_parameters' ) );
-
- // Add filter to process REST API requests by route
add_filter( 'rest_request_before_callbacks', array( $this, 'filter_types_request' ), 10, 3 );
-
- // Add filter to process each post type individually
add_filter( 'rest_prepare_post_type', array( $this, 'filter_post_type' ), 10, 3 );
-
- // Clean up null entries from the response
add_filter( 'rest_pre_echo_response', array( $this, 'clean_types_response' ), 10, 3 );
}
/**
- * Filter post types requests, fires for both collection and individual requests.
- * We only want to handle individual requets to ensure the post type requested matches the source.
+ * Validate source parameter.
+ *
+ * @since 6.7.0
+ *
+ * @param string $source The source value to validate.
+ * @return bool True if valid, false otherwise.
+ */
+ private function is_valid_source( $source ) {
+ return in_array( $source, self::VALID_SOURCES, true );
+ }
+
+ /**
+ * Filter post types requests for individual post type requests.
*
* @since SCF 6.5.0
+ * @since 6.7.0 Use is_valid_source() helper method.
*
- * @param mixed $response The current response, either response or null.
+ * @param mixed $response The current response.
* @param array $handler The handler for the route.
* @param WP_REST_Request $request The request object.
- * @return mixed The response or null.
+ * @return mixed The response or WP_Error.
*/
public function filter_types_request( $response, $handler, $request ) {
- // We only want to handle individual requests
+ // Only handle individual post type requests
$route = $request->get_route();
if ( ! preg_match( '#^/wp/v2/types/([^/]+)$#', $route, $matches ) ) {
return $response;
@@ -60,14 +75,14 @@ public function filter_types_request( $response, $handler, $request ) {
$source = $request->get_param( 'source' );
// Only proceed if source parameter is provided and valid
- if ( ! $source || ! in_array( $source, array( 'core', 'scf', 'other' ), true ) ) {
+ if ( ! $source || ! $this->is_valid_source( $source ) ) {
return $response;
}
$source_post_types = $this->get_source_post_types( $source );
+ $requested_type = $matches[1];
// Check if the requested type matches the source
- $requested_type = $matches[1];
if ( ! in_array( $requested_type, $source_post_types, true ) ) {
return new WP_Error(
'rest_post_type_invalid',
@@ -83,17 +98,18 @@ public function filter_types_request( $response, $handler, $request ) {
* Filter individual post type in the response.
*
* @since SCF 6.5.0
+ * @since 6.7.0 Use is_valid_source() helper method.
*
- * @param WP_REST_Response $response The response object.
+ * @param WP_REST_Response $response The response object.
* @param WP_Post_Type $post_type The post type object.
- * @param WP_REST_Request $request The request object.
- * @return WP_REST_Response|null The filtered response or null to filter it out.
+ * @param WP_REST_Request $request The request object.
+ * @return WP_REST_Response|null The filtered response or null.
*/
public function filter_post_type( $response, $post_type, $request ) {
$source = $request->get_param( 'source' );
// Only apply filtering if source parameter is provided and valid
- if ( ! $source || ! in_array( $source, array( 'core', 'scf', 'other' ), true ) ) {
+ if ( ! $source || ! $this->is_valid_source( $source ) ) {
return $response;
}
@@ -107,18 +123,18 @@ public function filter_post_type( $response, $post_type, $request ) {
}
/**
- * Get an array of post types for each source.
+ * Get post types for a specific source.
*
* @since SCF 6.5.0
*
- * @param string $source The source to get post types for.
+ * @param string $source The source to get post types for (core, scf, other).
* @return array An array of post type names for the specified source.
*/
private function get_source_post_types( $source ) {
-
$core_types = array();
$scf_types = array();
+ // Get core post types
if ( 'core' === $source || 'other' === $source ) {
$all_post_types = get_post_types( array( '_builtin' => true ), 'objects' );
foreach ( $all_post_types as $post_type ) {
@@ -126,8 +142,8 @@ private function get_source_post_types( $source ) {
}
}
+ // Get SCF-managed post types
if ( 'scf' === $source || 'other' === $source ) {
- // Get SCF-managed post types
if ( function_exists( 'acf_get_internal_post_type_posts' ) ) {
$scf_managed_post_types = acf_get_internal_post_type_posts( 'acf-post-type' );
foreach ( $scf_managed_post_types as $scf_post_type ) {
@@ -138,22 +154,17 @@ private function get_source_post_types( $source ) {
switch ( $source ) {
case 'core':
- $result = $core_types;
- break;
+ return $core_types;
case 'scf':
- $result = $scf_types;
- break;
+ return $scf_types;
case 'other':
- $result = array_diff(
+ return array_diff(
array_keys( get_post_types( array(), 'objects' ) ),
array_merge( $core_types, $scf_types )
);
- break;
default:
- $result = array();
+ return array();
}
-
- return $result;
}
/**
@@ -164,10 +175,6 @@ private function get_source_post_types( $source ) {
* @return void
*/
public function register_extra_fields() {
- if ( ! (bool) get_option( 'scf_beta_feature_editor_sidebar_enabled', false ) ) {
- return;
- }
-
register_rest_field(
'type',
'scf_field_groups',
@@ -195,11 +202,14 @@ public function get_scf_fields( $post_type_object ) {
$fields = acf_get_fields( $field_group );
$group_fields = array();
- foreach ( $fields as $field ) {
- $group_fields[] = array(
- 'label' => $field['label'],
- 'type' => $field['type'],
- );
+ if ( is_array( $fields ) ) {
+ foreach ( $fields as $field ) {
+ $group_fields[] = array(
+ 'name' => $field['name'],
+ 'label' => $field['label'],
+ 'type' => $field['type'],
+ );
+ }
}
$field_groups_data[] = array(
@@ -220,28 +230,32 @@ public function get_scf_fields( $post_type_object ) {
*/
private function get_field_schema() {
return array(
- 'description' => 'Field groups attached to this post type.',
+ 'description' => __( 'Field groups attached to this post type.', 'secure-custom-fields' ),
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'title' => array(
'type' => 'string',
- 'description' => 'The field group title.',
+ 'description' => __( 'The field group title.', 'secure-custom-fields' ),
),
'fields' => array(
'type' => 'array',
- 'description' => 'The fields in this field group.',
+ 'description' => __( 'The fields in this field group.', 'secure-custom-fields' ),
'items' => array(
'type' => 'object',
'properties' => array(
+ 'name' => array(
+ 'type' => 'string',
+ 'description' => __( 'The field name.', 'secure-custom-fields' ),
+ ),
'label' => array(
'type' => 'string',
- 'description' => 'The field label.',
+ 'description' => __( 'The field label.', 'secure-custom-fields' ),
),
'type' => array(
'type' => 'string',
- 'description' => 'The field type.',
+ 'description' => __( 'The field type.', 'secure-custom-fields' ),
),
),
),
@@ -262,11 +276,8 @@ public function register_parameters() {
return;
}
- // Register the query parameter with the REST API
add_filter( 'rest_type_collection_params', array( $this, 'add_collection_params' ) );
add_filter( 'rest_types_collection_params', array( $this, 'add_collection_params' ) );
-
- // Direct registration for OpenAPI documentation
add_filter( 'rest_endpoints', array( $this, 'add_parameter_to_endpoints' ) );
}
@@ -274,6 +285,7 @@ public function register_parameters() {
* Get the source parameter definition
*
* @since SCF 6.5.0
+ * @since 6.7.0 Use VALID_SOURCES constant.
*
* @return array Parameter definition
*/
@@ -281,12 +293,11 @@ private function get_source_param_definition() {
return array(
'description' => __( 'Filter post types by their source.', 'secure-custom-fields' ),
'type' => 'string',
- 'enum' => array( 'core', 'scf', 'other' ),
+ 'enum' => self::VALID_SOURCES,
'required' => false,
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'sanitize_text_field',
'default' => null,
- 'in' => 'query',
);
}
diff --git a/tests/e2e/block-bindings-site-editor.spec.ts b/tests/e2e/block-bindings-site-editor.spec.ts
new file mode 100644
index 00000000..348fd946
--- /dev/null
+++ b/tests/e2e/block-bindings-site-editor.spec.ts
@@ -0,0 +1,116 @@
+/**
+ * E2E tests for block bindings in the site editor
+ */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+const { test, expect } = require( './fixtures' );
+
+const PLUGIN_SLUG = 'secure-custom-fields';
+const TEST_PLUGIN_SLUG = 'scf-test-setup-post-types';
+const FIELD_GROUP_LABEL = 'Product Details';
+const TEXT_FIELD_LABEL = 'Product Name';
+const IMAGE_FIELD_LABEL = 'Product Image';
+
+test.describe( 'Block Bindings in Site Editor', () => {
+ test.beforeAll( async ( { requestUtils }: any ) => {
+ await requestUtils.activatePlugin( PLUGIN_SLUG );
+ await requestUtils.activatePlugin( TEST_PLUGIN_SLUG );
+ } );
+
+ test.afterAll( async ( { requestUtils } ) => {
+ await requestUtils.deactivatePlugin( TEST_PLUGIN_SLUG );
+ await requestUtils.deactivatePlugin( PLUGIN_SLUG );
+ await requestUtils.deleteAllPosts();
+ } );
+
+ test( 'should bind text field to paragraph block in site editor', async ( {
+ page,
+ admin,
+ } ) => {
+ // Navigate to Field Groups and create new.
+ await admin.visitAdminPage( 'edit.php', 'post_type=acf-field-group' );
+ const addNewButton = page.locator( 'a.acf-btn:has-text("Add New")' );
+ await addNewButton.click();
+
+ // Fill field group title.
+ await page.waitForSelector( '#title' );
+ await page.fill( '#title', FIELD_GROUP_LABEL );
+
+ // Add text field.
+ const fieldLabel = page.locator(
+ 'input[id^="acf_fields-field_"][id$="-label"]'
+ );
+ await fieldLabel.fill( TEXT_FIELD_LABEL );
+ // The field name is generated automatically.
+
+ // Select field type as text (it's default, but let's be explicit).
+ const fieldType = page.locator(
+ 'select[id^="acf_fields-field_"][id$="-type"]'
+ );
+ await fieldType.selectOption( 'text' );
+
+ // Select Group Settings > Enable REST API
+ const groupSettingsTab = page.getByRole( 'link', { name: 'Group Settings' } );
+ await groupSettingsTab.click();
+
+ // Enable Show in REST API
+ const showInRestCheckbox = page.locator( '#acf_field_group-show_in_rest' );
+ await showInRestCheckbox.check( { force: true } );
+
+ // Submit form.
+ const publishButton = page.locator(
+ 'button.acf-btn.acf-publish[type="submit"]'
+ );
+ await publishButton.click();
+
+ // Verify success message.
+ const successNotice = page.locator( '.updated.notice' );
+ await expect( successNotice ).toBeVisible();
+ await expect( successNotice ).toContainText( 'Field group published' );
+
+ // Navigate to site editor
+ await admin.visitAdminPage( 'site-editor.php?p=%2Fwp_template%2Ftwentytwentyfive%2F%2Fsingle&canvas=edit' );
+
+ // Wait for the site editor to load - wait for the iframe or editor container
+ await page.waitForSelector('iframe[name="editor-canvas"]', { timeout: 10000 });
+ await page.waitForTimeout(2000); // Give the editor a moment to fully load
+
+ // Close the welcome guide modal if it appears by pressing Escape
+ await page.keyboard.press( 'Escape' );
+ await page.waitForTimeout( 500 );
+
+ // Get the iframe and work within it
+ const frameLocator = page.frameLocator('iframe[name="editor-canvas"]');
+
+ // Click on the content block within the iframe
+ const contentBlock = frameLocator.locator('[data-type="core/post-content"]').first();
+ await contentBlock.click();
+
+ // Press Enter to add a new paragraph block
+ await page.keyboard.press('Enter');
+
+ // Wait for the newly created empty paragraph block
+ await frameLocator.locator('[data-type="core/paragraph"][data-empty="true"]').waitFor({ timeout: 5000 });
+
+ // Click on the empty paragraph block to select it
+ const emptyParagraph = frameLocator.locator('[data-type="core/paragraph"][data-empty="true"]');
+ await emptyParagraph.click();
+
+ // Wait for the "Connect to a field" panel to appear in the block inspector
+ await page.waitForSelector('input[id^="components-form-token-input-combobox-control-"]', { timeout: 5000 });
+
+ // Click on the combobox input to open suggestions
+ const comboboxInput = page.locator('input[id^="components-form-token-input-combobox-control-"]').first();
+ await comboboxInput.click();
+
+ // Wait for suggestions to appear
+ await page.waitForSelector('ul[id^="components-form-token-suggestions-combobox-control-"]', { timeout: 5000 });
+
+ // Select "Product Name" from the dropdown
+ await page.getByRole('option', { name: 'Product Name' }).click();
+
+ // Verify the paragraph block now contains "Product Name"
+ const boundParagraph = frameLocator.locator('[data-type="core/paragraph"]:has-text("Product Name")');
+ await expect( boundParagraph ).toBeVisible();
+
+ } );
+} );
diff --git a/tests/e2e/plugins/scf-test-setup-post-types.php b/tests/e2e/plugins/scf-test-setup-post-types.php
index 331c5c58..61590a1e 100644
--- a/tests/e2e/plugins/scf-test-setup-post-types.php
+++ b/tests/e2e/plugins/scf-test-setup-post-types.php
@@ -25,6 +25,7 @@
/**
* Register post types for testing
* - One regular WordPress post type that will show up in the "other" source
+ * - Product post type for block bindings testing
*/
function scf_test_register_post_types() {
// Register a standard WordPress post type that will show up in the "other" source
@@ -42,6 +43,21 @@ function scf_test_register_post_types() {
'supports' => array( 'title', 'editor' ),
)
);
+
+ // Register product post type for block bindings testing
+ register_post_type(
+ 'product',
+ array(
+ 'labels' => array(
+ 'name' => 'Products',
+ 'singular_name' => 'Product',
+ ),
+ 'public' => true,
+ 'show_in_rest' => true,
+ 'has_archive' => true,
+ 'supports' => array( 'title', 'editor', 'custom-fields' ),
+ )
+ );
}
/**
diff --git a/tests/js/bindings/field-processing.test.js b/tests/js/bindings/field-processing.test.js
new file mode 100644
index 00000000..c6c8f07b
--- /dev/null
+++ b/tests/js/bindings/field-processing.test.js
@@ -0,0 +1,309 @@
+/**
+ * Unit tests for field processing utilities
+ */
+
+import {
+ getSCFFields,
+ resolveImageAttribute,
+ processFieldBinding,
+ formatFieldLabel,
+ getFieldLabel,
+} from '../../../assets/src/js/bindings/field-processing';
+
+describe( 'Field Processing Utils', () => {
+ describe( 'getSCFFields', () => {
+ it( 'should extract source fields from post entity', () => {
+ const post = {
+ acf: {
+ field1: 'value1',
+ field1_source: { type: 'text', formatted_value: 'value1' },
+ field2: 'value2',
+ field2_source: {
+ type: 'textarea',
+ formatted_value: 'value2',
+ },
+ },
+ };
+
+ const result = getSCFFields( post );
+ expect( result ).toEqual( {
+ field1: { type: 'text', formatted_value: 'value1' },
+ field2: { type: 'textarea', formatted_value: 'value2' },
+ } );
+ } );
+
+ it( 'should return empty object when post has no acf property', () => {
+ const post = { id: 1, title: 'Test' };
+ const result = getSCFFields( post );
+ expect( result ).toEqual( {} );
+ } );
+
+ it( 'should return empty object when post is null', () => {
+ const result = getSCFFields( null );
+ expect( result ).toEqual( {} );
+ } );
+
+ it( 'should return empty object when acf is null', () => {
+ const post = { acf: null };
+ const result = getSCFFields( post );
+ expect( result ).toEqual( {} );
+ } );
+
+ it( 'should only include fields with _source counterparts', () => {
+ const post = {
+ acf: {
+ field1: 'value1',
+ field1_source: { type: 'text', formatted_value: 'value1' },
+ field2: 'value2',
+ // No field2_source
+ },
+ };
+
+ const result = getSCFFields( post );
+ expect( result ).toEqual( {
+ field1: { type: 'text', formatted_value: 'value1' },
+ } );
+ } );
+ } );
+
+ describe( 'resolveImageAttribute', () => {
+ const imageObj = {
+ id: 123,
+ ID: 123,
+ url: 'https://example.com/image.jpg',
+ alt: 'Test image',
+ title: 'Test Title',
+ };
+
+ it( 'should resolve url attribute', () => {
+ expect( resolveImageAttribute( imageObj, 'url' ) ).toBe(
+ 'https://example.com/image.jpg'
+ );
+ } );
+
+ it( 'should resolve alt attribute', () => {
+ expect( resolveImageAttribute( imageObj, 'alt' ) ).toBe(
+ 'Test image'
+ );
+ } );
+
+ it( 'should resolve title attribute', () => {
+ expect( resolveImageAttribute( imageObj, 'title' ) ).toBe(
+ 'Test Title'
+ );
+ } );
+
+ it( 'should resolve id attribute', () => {
+ expect( resolveImageAttribute( imageObj, 'id' ) ).toBe( 123 );
+ } );
+
+ it( 'should fallback to ID property for id attribute', () => {
+ const img = { ID: 456 };
+ expect( resolveImageAttribute( img, 'id' ) ).toBe( 456 );
+ } );
+
+ it( 'should return empty string for missing attributes', () => {
+ const img = {};
+ expect( resolveImageAttribute( img, 'url' ) ).toBe( '' );
+ expect( resolveImageAttribute( img, 'alt' ) ).toBe( '' );
+ expect( resolveImageAttribute( img, 'title' ) ).toBe( '' );
+ } );
+
+ it( 'should return empty string for unknown attribute', () => {
+ expect( resolveImageAttribute( imageObj, 'unknown' ) ).toBe( '' );
+ } );
+
+ it( 'should return empty string for null imageObj', () => {
+ expect( resolveImageAttribute( null, 'url' ) ).toBe( '' );
+ } );
+ } );
+
+ describe( 'processFieldBinding', () => {
+ const scfFields = {
+ text_field: {
+ type: 'text',
+ formatted_value: 'Hello World',
+ },
+ image_field: {
+ type: 'image',
+ formatted_value: {
+ url: 'https://example.com/image.jpg',
+ alt: 'Test image',
+ title: 'Test Title',
+ id: 123,
+ },
+ },
+ checkbox_field: {
+ type: 'checkbox',
+ formatted_value: [ 'option1', 'option2' ],
+ },
+ number_field: {
+ type: 'number',
+ formatted_value: 42,
+ },
+ textarea_field: {
+ type: 'textarea',
+ formatted_value: 'Long text content',
+ },
+ };
+
+ it( 'should process text field', () => {
+ const result = processFieldBinding(
+ 'content',
+ { key: 'text_field' },
+ scfFields
+ );
+ expect( result ).toBe( 'Hello World' );
+ } );
+
+ it( 'should process image field url attribute', () => {
+ const result = processFieldBinding(
+ 'url',
+ { key: 'image_field' },
+ scfFields
+ );
+ expect( result ).toBe( 'https://example.com/image.jpg' );
+ } );
+
+ it( 'should process image field alt attribute', () => {
+ const result = processFieldBinding(
+ 'alt',
+ { key: 'image_field' },
+ scfFields
+ );
+ expect( result ).toBe( 'Test image' );
+ } );
+
+ it( 'should process checkbox field as joined string', () => {
+ const result = processFieldBinding(
+ 'content',
+ { key: 'checkbox_field' },
+ scfFields
+ );
+ expect( result ).toBe( 'option1, option2' );
+ } );
+
+ it( 'should process number field as string', () => {
+ const result = processFieldBinding(
+ 'content',
+ { key: 'number_field' },
+ scfFields
+ );
+ expect( result ).toBe( '42' );
+ } );
+
+ it( 'should return empty string for missing field', () => {
+ const result = processFieldBinding(
+ 'content',
+ { key: 'nonexistent_field' },
+ scfFields
+ );
+ expect( result ).toBe( '' );
+ } );
+
+ it( 'should return empty string when args is null', () => {
+ const result = processFieldBinding( 'content', null, scfFields );
+ expect( result ).toBe( '' );
+ } );
+
+ it( 'should return empty string when key is missing', () => {
+ const result = processFieldBinding( 'content', {}, scfFields );
+ expect( result ).toBe( '' );
+ } );
+
+ it( 'should handle empty field value', () => {
+ const fields = {
+ empty_field: {
+ type: 'text',
+ formatted_value: '',
+ },
+ };
+ const result = processFieldBinding(
+ 'content',
+ { key: 'empty_field' },
+ fields
+ );
+ expect( result ).toBe( '' );
+ } );
+
+ it( 'should handle checkbox with string value', () => {
+ const fields = {
+ checkbox_string: {
+ type: 'checkbox',
+ formatted_value: 'single-value',
+ },
+ };
+ const result = processFieldBinding(
+ 'content',
+ { key: 'checkbox_string' },
+ fields
+ );
+ expect( result ).toBe( 'single-value' );
+ } );
+ } );
+
+ describe( 'formatFieldLabel', () => {
+ it( 'should format field key with underscores', () => {
+ expect( formatFieldLabel( 'my_field_name' ) ).toBe(
+ 'My Field Name'
+ );
+ } );
+
+ it( 'should format single word', () => {
+ expect( formatFieldLabel( 'field' ) ).toBe( 'Field' );
+ } );
+
+ it( 'should handle already capitalized words', () => {
+ expect( formatFieldLabel( 'My_Field' ) ).toBe( 'My Field' );
+ } );
+
+ it( 'should return empty string for empty input', () => {
+ expect( formatFieldLabel( '' ) ).toBe( '' );
+ } );
+
+ it( 'should return empty string for null input', () => {
+ expect( formatFieldLabel( null ) ).toBe( '' );
+ } );
+
+ it( 'should handle multiple consecutive underscores', () => {
+ expect( formatFieldLabel( 'my__field' ) ).toBe( 'My Field' );
+ } );
+ } );
+
+ describe( 'getFieldLabel', () => {
+ const fieldMetadata = {
+ field1: { label: 'Custom Label 1', type: 'text' },
+ field2: { label: 'Custom Label 2', type: 'textarea' },
+ };
+
+ it( 'should return label from metadata', () => {
+ const result = getFieldLabel( 'field1', fieldMetadata );
+ expect( result ).toBe( 'Custom Label 1' );
+ } );
+
+ it( 'should format field key when metadata not provided', () => {
+ const result = getFieldLabel( 'my_field', null );
+ expect( result ).toBe( 'My Field' );
+ } );
+
+ it( 'should format field key when field not in metadata', () => {
+ const result = getFieldLabel( 'unknown_field', fieldMetadata );
+ expect( result ).toBe( 'Unknown Field' );
+ } );
+
+ it( 'should return default label when field key is empty', () => {
+ const result = getFieldLabel( '', fieldMetadata, 'Default' );
+ expect( result ).toBe( 'Default' );
+ } );
+
+ it( 'should return default label when field key is null', () => {
+ const result = getFieldLabel( null, fieldMetadata, 'Default' );
+ expect( result ).toBe( 'Default' );
+ } );
+
+ it( 'should format field key when no default provided', () => {
+ const result = getFieldLabel( 'my_field' );
+ expect( result ).toBe( 'My Field' );
+ } );
+ } );
+} );
diff --git a/tests/js/bindings/fieldMetadataCache.test.js b/tests/js/bindings/fieldMetadataCache.test.js
new file mode 100644
index 00000000..ac0f9ab9
--- /dev/null
+++ b/tests/js/bindings/fieldMetadataCache.test.js
@@ -0,0 +1,164 @@
+/**
+ * Unit tests for SCF Field Metadata Cache
+ */
+
+import {
+ setFieldMetadata,
+ addFieldMetadata,
+ clearFieldMetadata,
+ getFieldMetadata,
+ getAllFieldMetadata,
+ hasFieldMetadata,
+} from '../../../assets/src/js/bindings/fieldMetadataCache';
+
+describe( 'Field Metadata Cache', () => {
+ beforeEach( () => {
+ clearFieldMetadata();
+ } );
+
+ describe( 'setFieldMetadata', () => {
+ it( 'should set field metadata', () => {
+ const fields = {
+ field_1: { label: 'Field 1', type: 'text' },
+ field_2: { label: 'Field 2', type: 'image' },
+ };
+
+ setFieldMetadata( fields );
+
+ expect( getFieldMetadata( 'field_1' ) ).toEqual( {
+ label: 'Field 1',
+ type: 'text',
+ } );
+ expect( getFieldMetadata( 'field_2' ) ).toEqual( {
+ label: 'Field 2',
+ type: 'image',
+ } );
+ } );
+
+ it( 'should replace all existing metadata', () => {
+ setFieldMetadata( {
+ field_1: { label: 'Field 1', type: 'text' },
+ } );
+
+ setFieldMetadata( {
+ field_2: { label: 'Field 2', type: 'image' },
+ } );
+
+ expect( getFieldMetadata( 'field_1' ) ).toBeNull();
+ expect( getFieldMetadata( 'field_2' ) ).toEqual( {
+ label: 'Field 2',
+ type: 'image',
+ } );
+ } );
+ } );
+
+ describe( 'addFieldMetadata', () => {
+ it( 'should add field metadata', () => {
+ addFieldMetadata( {
+ field_1: { label: 'Field 1', type: 'text' },
+ } );
+
+ expect( getFieldMetadata( 'field_1' ) ).toEqual( {
+ label: 'Field 1',
+ type: 'text',
+ } );
+ } );
+
+ it( 'should merge with existing metadata', () => {
+ setFieldMetadata( {
+ field_1: { label: 'Field 1', type: 'text' },
+ } );
+
+ addFieldMetadata( {
+ field_2: { label: 'Field 2', type: 'image' },
+ } );
+
+ expect( getFieldMetadata( 'field_1' ) ).toEqual( {
+ label: 'Field 1',
+ type: 'text',
+ } );
+ expect( getFieldMetadata( 'field_2' ) ).toEqual( {
+ label: 'Field 2',
+ type: 'image',
+ } );
+ } );
+
+ it( 'should overwrite fields with same key', () => {
+ setFieldMetadata( {
+ field_1: { label: 'Old Label', type: 'text' },
+ } );
+
+ addFieldMetadata( {
+ field_1: { label: 'New Label', type: 'textarea' },
+ } );
+
+ expect( getFieldMetadata( 'field_1' ) ).toEqual( {
+ label: 'New Label',
+ type: 'textarea',
+ } );
+ } );
+ } );
+
+ describe( 'clearFieldMetadata', () => {
+ it( 'should clear all metadata', () => {
+ setFieldMetadata( {
+ field_1: { label: 'Field 1', type: 'text' },
+ field_2: { label: 'Field 2', type: 'image' },
+ } );
+
+ clearFieldMetadata();
+
+ expect( getFieldMetadata( 'field_1' ) ).toBeNull();
+ expect( getFieldMetadata( 'field_2' ) ).toBeNull();
+ expect( getAllFieldMetadata() ).toEqual( {} );
+ } );
+ } );
+
+ describe( 'getFieldMetadata', () => {
+ it( 'should return metadata for existing field', () => {
+ setFieldMetadata( {
+ field_1: { label: 'Field 1', type: 'text' },
+ } );
+
+ expect( getFieldMetadata( 'field_1' ) ).toEqual( {
+ label: 'Field 1',
+ type: 'text',
+ } );
+ } );
+
+ it( 'should return null for non-existent field', () => {
+ expect( getFieldMetadata( 'nonexistent' ) ).toBeNull();
+ } );
+ } );
+
+ describe( 'getAllFieldMetadata', () => {
+ it( 'should return all metadata', () => {
+ const fields = {
+ field_1: { label: 'Field 1', type: 'text' },
+ field_2: { label: 'Field 2', type: 'image' },
+ };
+
+ setFieldMetadata( fields );
+
+ expect( getAllFieldMetadata() ).toEqual( fields );
+ } );
+
+ it( 'should return empty object when no metadata', () => {
+ expect( getAllFieldMetadata() ).toEqual( {} );
+ } );
+ } );
+
+ describe( 'hasFieldMetadata', () => {
+ it( 'should return true for existing field', () => {
+ setFieldMetadata( {
+ field_1: { label: 'Field 1', type: 'text' },
+ } );
+
+ expect( hasFieldMetadata( 'field_1' ) ).toBe( true );
+ } );
+
+ it( 'should return false for non-existent field', () => {
+ expect( hasFieldMetadata( 'nonexistent' ) ).toBe( false );
+ } );
+ } );
+} );
diff --git a/tests/js/bindings/hooks.test.js b/tests/js/bindings/hooks.test.js
new file mode 100644
index 00000000..18271709
--- /dev/null
+++ b/tests/js/bindings/hooks.test.js
@@ -0,0 +1,476 @@
+/**
+ * Unit tests for custom hooks
+ */
+
+import { renderHook, waitFor } from '@testing-library/react';
+import { useSelect } from '@wordpress/data';
+import apiFetch from '@wordpress/api-fetch';
+
+// Mock the fieldMetadataCache module
+jest.mock( '../../../assets/src/js/bindings/fieldMetadataCache', () => ( {
+ addFieldMetadata: jest.fn(),
+ getFieldMetadata: jest.fn(),
+ setFieldMetadata: jest.fn(),
+ clearFieldMetadata: jest.fn(),
+ getAllFieldMetadata: jest.fn(),
+ hasFieldMetadata: jest.fn(),
+} ) );
+
+import {
+ useSiteEditorContext,
+ usePostEditorFields,
+ useSiteEditorFields,
+ useBoundFields,
+} from '../../../assets/src/js/bindings/hooks';
+import * as fieldMetadataCache from '../../../assets/src/js/bindings/fieldMetadataCache';
+
+describe( 'Custom Hooks', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ describe( 'useSiteEditorContext', () => {
+ it( 'should detect site editor when post type is wp_template', () => {
+ useSelect.mockImplementation( ( callback ) => {
+ const select = ( store ) => {
+ if ( store === 'core/editor' ) {
+ return {
+ getCurrentPostType: () => 'wp_template',
+ getCurrentPostId: () => 123,
+ };
+ }
+ if ( store === 'core' ) {
+ return {
+ getEditedEntityRecord: () => ( {
+ slug: 'single-product',
+ } ),
+ };
+ }
+ return {};
+ };
+ return callback( select );
+ } );
+
+ const { result } = renderHook( () => useSiteEditorContext() );
+
+ expect( result.current.isSiteEditor ).toBe( true );
+ expect( result.current.templatePostType ).toBe( 'product' );
+ } );
+
+ it( 'should not detect site editor for regular post editor', () => {
+ useSelect.mockImplementation( ( callback ) => {
+ const select = ( store ) => {
+ if ( store === 'core/editor' ) {
+ return {
+ getCurrentPostType: () => 'post',
+ getCurrentPostId: () => 456,
+ };
+ }
+ if ( store === 'core' ) {
+ return {
+ getEditedEntityRecord: () => null,
+ };
+ }
+ return {};
+ };
+ return callback( select );
+ } );
+
+ const { result } = renderHook( () => useSiteEditorContext() );
+
+ expect( result.current.isSiteEditor ).toBe( false );
+ expect( result.current.templatePostType ).toBeNull();
+ } );
+
+ it( 'should extract post type from archive template', () => {
+ useSelect.mockImplementation( ( callback ) => {
+ const select = ( store ) => {
+ if ( store === 'core/editor' ) {
+ return {
+ getCurrentPostType: () => 'wp_template',
+ getCurrentPostId: () => 789,
+ };
+ }
+ if ( store === 'core' ) {
+ return {
+ getEditedEntityRecord: () => ( {
+ slug: 'archive-book',
+ } ),
+ };
+ }
+ return {};
+ };
+ return callback( select );
+ } );
+
+ const { result } = renderHook( () => useSiteEditorContext() );
+
+ expect( result.current.isSiteEditor ).toBe( true );
+ expect( result.current.templatePostType ).toBe( 'book' );
+ } );
+
+ it( 'should handle missing template', () => {
+ useSelect.mockImplementation( ( callback ) => {
+ const select = ( store ) => {
+ if ( store === 'core/editor' ) {
+ return {
+ getCurrentPostType: () => 'wp_template',
+ getCurrentPostId: () => 999,
+ };
+ }
+ if ( store === 'core' ) {
+ return {
+ getEditedEntityRecord: () => null,
+ };
+ }
+ return {};
+ };
+ return callback( select );
+ } );
+
+ const { result } = renderHook( () => useSiteEditorContext() );
+
+ expect( result.current.isSiteEditor ).toBe( true );
+ expect( result.current.templatePostType ).toBeNull();
+ } );
+ } );
+
+ describe( 'usePostEditorFields', () => {
+ it( 'should extract sourced fields from post entity', () => {
+ useSelect.mockImplementation( ( callback ) => {
+ const select = ( store ) => {
+ if ( store === 'core/editor' ) {
+ return {
+ getCurrentPostType: () => 'post',
+ getCurrentPostId: () => 123,
+ };
+ }
+ if ( store === 'core' ) {
+ return {
+ getEditedEntityRecord: () => ( {
+ acf: {
+ field1: 'value1',
+ field1_source: {
+ type: 'text',
+ formatted_value: 'value1',
+ },
+ field2: 'value2',
+ field2_source: {
+ type: 'textarea',
+ formatted_value: 'value2',
+ },
+ },
+ } ),
+ };
+ }
+ return {};
+ };
+ return callback( select );
+ } );
+
+ const { result } = renderHook( () => usePostEditorFields() );
+
+ expect( result.current ).toEqual( {
+ field1: { type: 'text', formatted_value: 'value1' },
+ field2: { type: 'textarea', formatted_value: 'value2' },
+ } );
+ } );
+
+ it( 'should return empty object for wp_template post type', () => {
+ useSelect.mockImplementation( ( callback ) => {
+ const select = ( store ) => {
+ if ( store === 'core/editor' ) {
+ return {
+ getCurrentPostType: () => 'wp_template',
+ getCurrentPostId: () => 123,
+ };
+ }
+ if ( store === 'core' ) {
+ return {
+ getEditedEntityRecord: () => null,
+ };
+ }
+ return {};
+ };
+ return callback( select );
+ } );
+
+ const { result } = renderHook( () => usePostEditorFields() );
+
+ expect( result.current ).toEqual( {} );
+ } );
+
+ it( 'should return empty object when no post type', () => {
+ useSelect.mockImplementation( ( callback ) => {
+ const select = ( store ) => {
+ if ( store === 'core/editor' ) {
+ return {
+ getCurrentPostType: () => null,
+ getCurrentPostId: () => null,
+ };
+ }
+ if ( store === 'core' ) {
+ return {
+ getEditedEntityRecord: () => null,
+ };
+ }
+ return {};
+ };
+ return callback( select );
+ } );
+
+ const { result } = renderHook( () => usePostEditorFields() );
+
+ expect( result.current ).toEqual( {} );
+ } );
+
+ it( 'should only include fields with both value and source', () => {
+ useSelect.mockImplementation( ( callback ) => {
+ const select = ( store ) => {
+ if ( store === 'core/editor' ) {
+ return {
+ getCurrentPostType: () => 'post',
+ getCurrentPostId: () => 123,
+ };
+ }
+ if ( store === 'core' ) {
+ return {
+ getEditedEntityRecord: () => ( {
+ acf: {
+ field1: 'value1',
+ field1_source: {
+ type: 'text',
+ formatted_value: 'value1',
+ },
+ field2_source: {
+ type: 'text',
+ formatted_value: 'value2',
+ },
+ // field2 is missing
+ },
+ } ),
+ };
+ }
+ return {};
+ };
+ return callback( select );
+ } );
+
+ const { result } = renderHook( () => usePostEditorFields() );
+
+ expect( result.current ).toEqual( {
+ field1: { type: 'text', formatted_value: 'value1' },
+ } );
+ } );
+ } );
+
+ describe( 'useSiteEditorFields', () => {
+ const mockFieldGroups = [
+ {
+ title: 'Group 1',
+ fields: [
+ { name: 'field1', label: 'Field 1', type: 'text' },
+ { name: 'field2', label: 'Field 2', type: 'textarea' },
+ ],
+ },
+ ];
+
+ it( 'should fetch fields for a post type', async () => {
+ apiFetch.mockResolvedValue( {
+ scf_field_groups: mockFieldGroups,
+ } );
+
+ const { result } = renderHook( () =>
+ useSiteEditorFields( 'product' )
+ );
+
+ expect( result.current.isLoading ).toBe( true );
+
+ await waitFor( () => {
+ expect( result.current.isLoading ).toBe( false );
+ } );
+
+ expect( result.current.fields ).toEqual( {
+ field1: { label: 'Field 1', type: 'text' },
+ field2: { label: 'Field 2', type: 'textarea' },
+ } );
+
+ expect( result.current.error ).toBeNull();
+ expect( fieldMetadataCache.addFieldMetadata ).toHaveBeenCalled();
+ } );
+
+ it( 'should handle API errors', async () => {
+ const error = new Error( 'API Error' );
+ apiFetch.mockRejectedValue( error );
+
+ const { result } = renderHook( () =>
+ useSiteEditorFields( 'product' )
+ );
+
+ await waitFor( () => {
+ expect( result.current.isLoading ).toBe( false );
+ } );
+
+ expect( result.current.fields ).toEqual( {} );
+ expect( result.current.error ).toBe( error );
+ } );
+
+ it( 'should reset when post type is null', () => {
+ const { result } = renderHook( () => useSiteEditorFields( null ) );
+
+ expect( result.current.fields ).toEqual( {} );
+ expect( result.current.isLoading ).toBe( false );
+ expect( result.current.error ).toBeNull();
+ } );
+
+ it( 'should call apiFetch with correct parameters', async () => {
+ apiFetch.mockResolvedValue( {
+ scf_field_groups: mockFieldGroups,
+ } );
+
+ renderHook( () => useSiteEditorFields( 'product' ) );
+
+ await waitFor( () => {
+ expect( apiFetch ).toHaveBeenCalledWith( {
+ path: '/wp/v2/types/product?context=edit',
+ } );
+ } );
+ } );
+
+ it( 'should update when post type changes', async () => {
+ apiFetch.mockResolvedValue( {
+ scf_field_groups: mockFieldGroups,
+ } );
+
+ const { rerender } = renderHook(
+ ( { postType } ) => useSiteEditorFields( postType ),
+ { initialProps: { postType: 'product' } }
+ );
+
+ await waitFor( () => {
+ expect( apiFetch ).toHaveBeenCalledWith( {
+ path: '/wp/v2/types/product?context=edit',
+ } );
+ } );
+
+ apiFetch.mockClear();
+
+ rerender( { postType: 'book' } );
+
+ await waitFor( () => {
+ expect( apiFetch ).toHaveBeenCalledWith( {
+ path: '/wp/v2/types/book?context=edit',
+ } );
+ } );
+ } );
+ } );
+
+ describe( 'useBoundFields', () => {
+ it( 'should extract bound fields from block attributes', () => {
+ const blockAttributes = {
+ metadata: {
+ bindings: {
+ content: {
+ source: 'acf/field',
+ args: { key: 'field1' },
+ },
+ url: {
+ source: 'acf/field',
+ args: { key: 'field2' },
+ },
+ },
+ },
+ };
+
+ const { result } = renderHook( () =>
+ useBoundFields( blockAttributes )
+ );
+
+ expect( result.current.boundFields ).toEqual( {
+ content: 'field1',
+ url: 'field2',
+ } );
+ } );
+
+ it( 'should return empty object when no bindings', () => {
+ const blockAttributes = {
+ metadata: {},
+ };
+
+ const { result } = renderHook( () =>
+ useBoundFields( blockAttributes )
+ );
+
+ expect( result.current.boundFields ).toEqual( {} );
+ } );
+
+ it( 'should ignore bindings without key', () => {
+ const blockAttributes = {
+ metadata: {
+ bindings: {
+ content: {
+ source: 'acf/field',
+ args: { key: 'field1' },
+ },
+ url: {
+ source: 'acf/field',
+ args: {}, // No key
+ },
+ },
+ },
+ };
+
+ const { result } = renderHook( () =>
+ useBoundFields( blockAttributes )
+ );
+
+ expect( result.current.boundFields ).toEqual( {
+ content: 'field1',
+ } );
+ } );
+
+ it( 'should update when bindings change', () => {
+ const initialAttributes = {
+ metadata: {
+ bindings: {
+ content: {
+ source: 'acf/field',
+ args: { key: 'field1' },
+ },
+ },
+ },
+ };
+
+ const { result, rerender } = renderHook(
+ ( { attrs } ) => useBoundFields( attrs ),
+ { initialProps: { attrs: initialAttributes } }
+ );
+
+ expect( result.current.boundFields ).toEqual( {
+ content: 'field1',
+ } );
+
+ const updatedAttributes = {
+ metadata: {
+ bindings: {
+ content: {
+ source: 'acf/field',
+ args: { key: 'field2' },
+ },
+ url: {
+ source: 'acf/field',
+ args: { key: 'field3' },
+ },
+ },
+ },
+ };
+
+ rerender( { attrs: updatedAttributes } );
+
+ expect( result.current.boundFields ).toEqual( {
+ content: 'field2',
+ url: 'field3',
+ } );
+ } );
+ } );
+} );
diff --git a/tests/js/bindings/sources.test.js b/tests/js/bindings/sources.test.js
new file mode 100644
index 00000000..58c837b8
--- /dev/null
+++ b/tests/js/bindings/sources.test.js
@@ -0,0 +1,224 @@
+/**
+ * Unit tests for block binding sources
+ */
+
+import { registerBlockBindingsSource } from '@wordpress/blocks';
+import { __ } from '@wordpress/i18n';
+import * as fieldMetadataCache from '../../../assets/src/js/bindings/fieldMetadataCache';
+
+// Mock field processing module
+jest.mock( '../../../assets/src/js/bindings/field-processing', () => ( {
+ getSCFFields: jest.fn( ( post ) => {
+ if ( ! post?.acf ) {
+ return {};
+ }
+ const sourceFields = {};
+ Object.entries( post.acf ).forEach( ( [ key, value ] ) => {
+ if ( key.endsWith( '_source' ) ) {
+ const fieldName = key.replace( '_source', '' );
+ sourceFields[ fieldName ] = value;
+ }
+ } );
+ return sourceFields;
+ } ),
+ processFieldBinding: jest.fn( ( attribute, args, scfFields ) => {
+ const fieldName = args?.key;
+ const fieldConfig = scfFields[ fieldName ];
+ if ( ! fieldConfig ) {
+ return '';
+ }
+ return fieldConfig.formatted_value || '';
+ } ),
+ formatFieldLabel: jest.fn( ( fieldKey ) => {
+ if ( ! fieldKey ) {
+ return '';
+ }
+ return fieldKey
+ .split( '_' )
+ .map( ( word ) => word.charAt( 0 ).toUpperCase() + word.slice( 1 ) )
+ .join( ' ' );
+ } ),
+} ) );
+
+// Mock the field metadata cache
+jest.mock( '../../../assets/src/js/bindings/fieldMetadataCache' );
+
+describe( 'Block Binding Sources', () => {
+ let registeredConfig;
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ registeredConfig = null;
+
+ // Capture the configuration passed to registerBlockBindingsSource
+ registerBlockBindingsSource.mockImplementation( ( config ) => {
+ registeredConfig = config;
+ } );
+
+ // Re-require the module to trigger registration
+ jest.isolateModules( () => {
+ require( '../../../assets/src/js/bindings/sources' );
+ } );
+ } );
+
+ describe( 'Registration', () => {
+ it( 'should register the SCF field binding source', () => {
+ expect( registerBlockBindingsSource ).toHaveBeenCalled();
+ expect( registeredConfig ).not.toBeNull();
+ expect( registeredConfig.name ).toBe( 'acf/field' );
+ } );
+ } );
+
+ describe( 'getLabel', () => {
+ it( 'should return default label when no field key', () => {
+ const label = registeredConfig.getLabel( {
+ args: {},
+ select: jest.fn(),
+ } );
+
+ expect( label ).toBe( 'SCF Fields' );
+ } );
+
+ it( 'should return field metadata label when available', () => {
+ fieldMetadataCache.getFieldMetadata.mockReturnValue( {
+ label: 'My Custom Field',
+ type: 'text',
+ } );
+
+ const label = registeredConfig.getLabel( {
+ args: { key: 'my_field' },
+ select: jest.fn(),
+ } );
+
+ expect( label ).toBe( 'My Custom Field' );
+ } );
+
+ it( 'should use formatFieldLabel when no metadata available', () => {
+ fieldMetadataCache.getFieldMetadata.mockReturnValue( null );
+
+ const {
+ formatFieldLabel,
+ } = require( '../../../assets/src/js/bindings/field-processing' );
+ formatFieldLabel.mockReturnValue( 'My Field' );
+
+ const label = registeredConfig.getLabel( {
+ args: { key: 'my_field' },
+ select: jest.fn(),
+ } );
+
+ expect( label ).toBe( 'My Field' );
+ } );
+ } );
+
+ describe( 'getValues', () => {
+ it( 'should return field labels in site editor', () => {
+ fieldMetadataCache.getFieldMetadata.mockImplementation( ( key ) => {
+ const metadata = {
+ product_name: { label: 'Product Name', type: 'text' },
+ product_image: { label: 'Product Image', type: 'image' },
+ };
+ return metadata[ key ] || null;
+ } );
+
+ const mockSelect = jest.fn( ( storeName ) => {
+ if ( storeName === 'core/editor' ) {
+ return {
+ getCurrentPostType: () => 'wp_template',
+ };
+ }
+ return {};
+ } );
+
+ const values = registeredConfig.getValues( {
+ select: mockSelect,
+ context: {},
+ bindings: {
+ content: { args: { key: 'product_name' } },
+ url: { args: { key: 'product_image' } },
+ },
+ } );
+
+ expect( values ).toEqual( {
+ content: 'Product Name',
+ url: 'Product Image',
+ } );
+ } );
+
+ it( 'should use formatFieldLabel for fields without metadata in site editor', () => {
+ fieldMetadataCache.getFieldMetadata.mockReturnValue( null );
+
+ const {
+ formatFieldLabel,
+ } = require( '../../../assets/src/js/bindings/field-processing' );
+ formatFieldLabel.mockImplementation( ( key ) => {
+ return key
+ .split( '_' )
+ .map(
+ ( word ) =>
+ word.charAt( 0 ).toUpperCase() + word.slice( 1 )
+ )
+ .join( ' ' );
+ } );
+
+ const mockSelect = jest.fn( ( storeName ) => {
+ if ( storeName === 'core/editor' ) {
+ return {
+ getCurrentPostType: () => 'wp_template',
+ };
+ }
+ return {};
+ } );
+
+ const values = registeredConfig.getValues( {
+ select: mockSelect,
+ context: {},
+ bindings: {
+ content: { args: { key: 'unknown_field' } },
+ },
+ } );
+
+ expect( values ).toEqual( {
+ content: 'Unknown Field',
+ } );
+ } );
+
+ it( 'should return processed field values in post editor', () => {
+ const mockSelect = jest.fn( ( storeName ) => {
+ if ( storeName === 'core/editor' ) {
+ return {
+ getCurrentPostType: () => 'post',
+ };
+ }
+ if ( storeName === 'core' ) {
+ return {
+ getEditedEntityRecord: () => ( {
+ acf: {
+ title_source: {
+ formatted_value: 'Test Title',
+ },
+ },
+ } ),
+ };
+ }
+ return {};
+ } );
+
+ const {
+ processFieldBinding,
+ } = require( '../../../assets/src/js/bindings/field-processing' );
+ processFieldBinding.mockReturnValue( 'Test Title' );
+
+ const values = registeredConfig.getValues( {
+ select: mockSelect,
+ context: { postType: 'post', postId: 123 },
+ bindings: {
+ content: { args: { key: 'title' } },
+ },
+ } );
+
+ expect( values ).toEqual( {
+ content: 'Test Title',
+ } );
+ } );
+ } );
+} );
diff --git a/tests/js/bindings/utils.test.js b/tests/js/bindings/utils.test.js
new file mode 100644
index 00000000..63fcebf8
--- /dev/null
+++ b/tests/js/bindings/utils.test.js
@@ -0,0 +1,335 @@
+/**
+ * Unit tests for block bindings utility functions
+ */
+
+import {
+ getBindableAttributes,
+ getAllowedFieldTypes,
+ getFilteredFieldOptions,
+ canUseUnifiedBinding,
+ extractPostTypeFromTemplate,
+ formatFieldGroupsData,
+ fieldsToOptions,
+} from '../../../assets/src/js/bindings/utils';
+
+describe( 'Block Bindings Utils', () => {
+ describe( 'getBindableAttributes', () => {
+ it( 'should return bindable attributes for core/paragraph', () => {
+ const attributes = getBindableAttributes( 'core/paragraph' );
+ expect( attributes ).toEqual( [ 'content' ] );
+ } );
+
+ it( 'should return bindable attributes for core/heading', () => {
+ const attributes = getBindableAttributes( 'core/heading' );
+ expect( attributes ).toEqual( [ 'content' ] );
+ } );
+
+ it( 'should return multiple bindable attributes for core/image', () => {
+ const attributes = getBindableAttributes( 'core/image' );
+ expect( attributes ).toEqual( [ 'id', 'url', 'title', 'alt' ] );
+ } );
+
+ it( 'should return multiple bindable attributes for core/button', () => {
+ const attributes = getBindableAttributes( 'core/button' );
+ expect( attributes ).toEqual( [
+ 'url',
+ 'text',
+ 'linkTarget',
+ 'rel',
+ ] );
+ } );
+
+ it( 'should return empty array for unsupported block', () => {
+ const attributes = getBindableAttributes( 'core/unknown' );
+ expect( attributes ).toEqual( [] );
+ } );
+
+ it( 'should return empty array for null block name', () => {
+ const attributes = getBindableAttributes( null );
+ expect( attributes ).toEqual( [] );
+ } );
+ } );
+
+ describe( 'getAllowedFieldTypes', () => {
+ it( 'should return allowed field types for paragraph content', () => {
+ const types = getAllowedFieldTypes( 'core/paragraph', 'content' );
+ expect( types ).toEqual( [
+ 'text',
+ 'textarea',
+ 'date_picker',
+ 'number',
+ 'range',
+ ] );
+ } );
+
+ it( 'should return allowed field types for image url', () => {
+ const types = getAllowedFieldTypes( 'core/image', 'url' );
+ expect( types ).toEqual( [ 'image' ] );
+ } );
+
+ it( 'should return all unique field types when attribute is null', () => {
+ const types = getAllowedFieldTypes( 'core/paragraph', null );
+ expect( types ).toEqual( [
+ 'text',
+ 'textarea',
+ 'date_picker',
+ 'number',
+ 'range',
+ ] );
+ } );
+
+ it( 'should return all unique field types for image block', () => {
+ const types = getAllowedFieldTypes( 'core/image', null );
+ expect( types ).toEqual( [ 'image' ] );
+ } );
+
+ it( 'should return null for unsupported block', () => {
+ const types = getAllowedFieldTypes( 'core/unknown', 'content' );
+ expect( types ).toBeNull();
+ } );
+
+ it( 'should return null for unsupported attribute', () => {
+ const types = getAllowedFieldTypes(
+ 'core/paragraph',
+ 'unknownAttr'
+ );
+ expect( types ).toBeNull();
+ } );
+ } );
+
+ describe( 'getFilteredFieldOptions', () => {
+ const fieldOptions = [
+ { value: 'field1', label: 'Field 1', type: 'text' },
+ { value: 'field2', label: 'Field 2', type: 'textarea' },
+ { value: 'field3', label: 'Field 3', type: 'image' },
+ { value: 'field4', label: 'Field 4', type: 'number' },
+ { value: 'field5', label: 'Field 5', type: 'url' },
+ ];
+
+ it( 'should filter options for paragraph content', () => {
+ const filtered = getFilteredFieldOptions(
+ fieldOptions,
+ 'core/paragraph',
+ 'content'
+ );
+ expect( filtered ).toHaveLength( 3 );
+ expect( filtered.map( ( o ) => o.value ) ).toEqual( [
+ 'field1',
+ 'field2',
+ 'field4',
+ ] );
+ } );
+
+ it( 'should filter options for image attributes', () => {
+ const filtered = getFilteredFieldOptions(
+ fieldOptions,
+ 'core/image',
+ 'url'
+ );
+ expect( filtered ).toHaveLength( 1 );
+ expect( filtered[ 0 ].value ).toBe( 'field3' );
+ } );
+
+ it( 'should return all options when no restrictions', () => {
+ const filtered = getFilteredFieldOptions(
+ fieldOptions,
+ 'core/unknown',
+ 'content'
+ );
+ expect( filtered ).toEqual( fieldOptions );
+ } );
+
+ it( 'should return empty array for empty input', () => {
+ const filtered = getFilteredFieldOptions(
+ [],
+ 'core/paragraph',
+ 'content'
+ );
+ expect( filtered ).toEqual( [] );
+ } );
+
+ it( 'should return empty array for null input', () => {
+ const filtered = getFilteredFieldOptions(
+ null,
+ 'core/paragraph',
+ 'content'
+ );
+ expect( filtered ).toEqual( [] );
+ } );
+
+ it( 'should handle attribute parameter as null', () => {
+ const filtered = getFilteredFieldOptions(
+ fieldOptions,
+ 'core/paragraph',
+ null
+ );
+ expect( filtered ).toHaveLength( 3 );
+ } );
+ } );
+
+ describe( 'canUseUnifiedBinding', () => {
+ it( 'should return true when all attributes support same types', () => {
+ const result = canUseUnifiedBinding( 'core/image', [
+ 'url',
+ 'alt',
+ 'title',
+ ] );
+ expect( result ).toBe( true );
+ } );
+
+ it( 'should return false for single attribute', () => {
+ const result = canUseUnifiedBinding( 'core/paragraph', [
+ 'content',
+ ] );
+ expect( result ).toBe( false );
+ } );
+
+ it( 'should return false for empty attributes array', () => {
+ const result = canUseUnifiedBinding( 'core/paragraph', [] );
+ expect( result ).toBe( false );
+ } );
+
+ it( 'should return false for null attributes', () => {
+ const result = canUseUnifiedBinding( 'core/paragraph', null );
+ expect( result ).toBe( false );
+ } );
+
+ it( 'should return false for unsupported block', () => {
+ const result = canUseUnifiedBinding( 'core/unknown', [
+ 'attr1',
+ 'attr2',
+ ] );
+ expect( result ).toBe( false );
+ } );
+
+ it( 'should return false for button with different attribute types', () => {
+ // Button has url:[url] and text:[text, checkbox, select, date_picker]
+ const result = canUseUnifiedBinding( 'core/button', [
+ 'url',
+ 'text',
+ ] );
+ expect( result ).toBe( false );
+ } );
+ } );
+
+ describe( 'extractPostTypeFromTemplate', () => {
+ it( 'should extract post type from single template', () => {
+ expect( extractPostTypeFromTemplate( 'single-product' ) ).toBe(
+ 'product'
+ );
+ } );
+
+ it( 'should extract post type from archive template', () => {
+ expect( extractPostTypeFromTemplate( 'archive-product' ) ).toBe(
+ 'product'
+ );
+ } );
+
+ it( 'should return "post" for default single template', () => {
+ expect( extractPostTypeFromTemplate( 'single' ) ).toBe( 'post' );
+ } );
+
+ it( 'should return null for non-matching template', () => {
+ expect( extractPostTypeFromTemplate( 'page' ) ).toBeNull();
+ } );
+
+ it( 'should return null for empty string', () => {
+ expect( extractPostTypeFromTemplate( '' ) ).toBeNull();
+ } );
+
+ it( 'should return null for null input', () => {
+ expect( extractPostTypeFromTemplate( null ) ).toBeNull();
+ } );
+
+ it( 'should handle complex post type names', () => {
+ expect(
+ extractPostTypeFromTemplate( 'single-custom-post-type' )
+ ).toBe( 'custom-post-type' );
+ } );
+ } );
+
+ describe( 'formatFieldGroupsData', () => {
+ const mockFieldGroups = [
+ {
+ title: 'Group 1',
+ fields: [
+ { name: 'field1', label: 'Field 1', type: 'text' },
+ { name: 'field2', label: 'Field 2', type: 'textarea' },
+ ],
+ },
+ {
+ title: 'Group 2',
+ fields: [ { name: 'field3', label: 'Field 3', type: 'image' } ],
+ },
+ ];
+
+ it( 'should format field groups data correctly', () => {
+ const result = formatFieldGroupsData( mockFieldGroups );
+ expect( result ).toEqual( {
+ field1: { label: 'Field 1', type: 'text' },
+ field2: { label: 'Field 2', type: 'textarea' },
+ field3: { label: 'Field 3', type: 'image' },
+ } );
+ } );
+
+ it( 'should return empty object for empty array', () => {
+ const result = formatFieldGroupsData( [] );
+ expect( result ).toEqual( {} );
+ } );
+
+ it( 'should return empty object for non-array input', () => {
+ const result = formatFieldGroupsData( null );
+ expect( result ).toEqual( {} );
+ } );
+
+ it( 'should handle field groups without fields', () => {
+ const result = formatFieldGroupsData( [
+ { title: 'Empty Group' },
+ ] );
+ expect( result ).toEqual( {} );
+ } );
+
+ it( 'should handle field groups with null fields', () => {
+ const result = formatFieldGroupsData( [
+ { title: 'Group', fields: null },
+ ] );
+ expect( result ).toEqual( {} );
+ } );
+ } );
+
+ describe( 'fieldsToOptions', () => {
+ const mockFieldsMap = {
+ field1: { label: 'Field 1', type: 'text' },
+ field2: { label: 'Field 2', type: 'textarea' },
+ field3: { label: 'Field 3', type: 'image' },
+ };
+
+ it( 'should convert fields map to options array', () => {
+ const result = fieldsToOptions( mockFieldsMap );
+ expect( result ).toEqual( [
+ { value: 'field1', label: 'Field 1', type: 'text' },
+ { value: 'field2', label: 'Field 2', type: 'textarea' },
+ { value: 'field3', label: 'Field 3', type: 'image' },
+ ] );
+ } );
+
+ it( 'should return empty array for empty object', () => {
+ const result = fieldsToOptions( {} );
+ expect( result ).toEqual( [] );
+ } );
+
+ it( 'should return empty array for null input', () => {
+ const result = fieldsToOptions( null );
+ expect( result ).toEqual( [] );
+ } );
+
+ it( 'should handle single field', () => {
+ const result = fieldsToOptions( {
+ field1: { label: 'Field 1', type: 'text' },
+ } );
+ expect( result ).toEqual( [
+ { value: 'field1', label: 'Field 1', type: 'text' },
+ ] );
+ } );
+ } );
+} );
diff --git a/tests/js/blocks-v3/block-edit-error-boundary.test.js b/tests/js/blocks-v3/block-edit-error-boundary.test.js
index b294c4f7..066fe8d0 100644
--- a/tests/js/blocks-v3/block-edit-error-boundary.test.js
+++ b/tests/js/blocks-v3/block-edit-error-boundary.test.js
@@ -7,21 +7,6 @@ import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
-// Mock BlockPlaceholder before importing the components that use it
-jest.mock(
- '../../../assets/src/js/pro/blocks-v3/components/block-placeholder',
- () => ( {
- BlockPlaceholder: ( { blockLabel, instructions } ) => (
-
-
{ blockLabel }
- { instructions && (
-
{ instructions }
- ) }
-
- ),
- } )
-);
-
import {
ErrorBoundary,
BlockPreviewErrorFallback,
@@ -90,13 +75,13 @@ describe( 'ErrorBoundary Component', () => {
);
// Should render the error placeholder
- expect( screen.getByTestId( 'block-placeholder' ) ).toBeInTheDocument();
- expect( screen.getByTestId( 'block-label' ) ).toHaveTextContent(
+ expect( screen.getByTestId( 'placeholder' ) ).toBeInTheDocument();
+ expect( screen.getByTestId( 'placeholder-label' ) ).toHaveTextContent(
'Test Block'
);
- expect( screen.getByTestId( 'error-message' ) ).toHaveTextContent(
- "The preview for this block couldn't be loaded"
- );
+ expect(
+ screen.getByTestId( 'placeholder-instructions' )
+ ).toHaveTextContent( "The preview for this block couldn't be loaded" );
} );
test( 'calls acf.debug when error is caught', () => {
@@ -118,9 +103,8 @@ describe( 'ErrorBoundary Component', () => {
// Verify debug was called
expect( global.acf.debug ).toHaveBeenCalledWith(
- 'Block preview error caught:',
- expect.any( Error ),
- expect.any( Object )
+ 'Block preview error:',
+ expect.any( Error )
);
expect( global.acf.debug ).toHaveBeenCalledWith(
@@ -152,7 +136,9 @@ describe( 'ErrorBoundary Component', () => {
);
// Verify the translated message appears
- expect( screen.getByTestId( 'error-message' ) ).toHaveTextContent(
+ expect(
+ screen.getByTestId( 'placeholder-instructions' )
+ ).toHaveTextContent(
"The preview for this block couldn't be loaded. Review its content or settings for issues."
);
} );
@@ -174,7 +160,7 @@ describe( 'ErrorBoundary Component', () => {
);
- expect( screen.getByTestId( 'block-label' ) ).toHaveTextContent(
+ expect( screen.getByTestId( 'placeholder-label' ) ).toHaveTextContent(
'ACF Block'
);
} );
@@ -193,8 +179,10 @@ describe( 'BlockPreviewErrorFallback Component', () => {
/>
);
- expect( screen.getByTestId( 'block-placeholder' ) ).toBeInTheDocument();
- expect( screen.getByTestId( 'error-message' ) ).toBeInTheDocument();
+ expect( screen.getByTestId( 'placeholder' ) ).toBeInTheDocument();
+ expect(
+ screen.getByTestId( 'placeholder-instructions' )
+ ).toBeInTheDocument();
} );
test( 'does not render error message when error is null', () => {
@@ -208,9 +196,9 @@ describe( 'BlockPreviewErrorFallback Component', () => {
/>
);
- expect( screen.getByTestId( 'block-placeholder' ) ).toBeInTheDocument();
+ expect( screen.getByTestId( 'placeholder' ) ).toBeInTheDocument();
expect(
- screen.queryByTestId( 'error-message' )
+ screen.queryByTestId( 'placeholder-instructions' )
).not.toBeInTheDocument();
} );
} );
@@ -246,10 +234,10 @@ describe( 'Invalid HTML Scenarios', () => {
);
- expect( screen.getByTestId( 'block-placeholder' ) ).toBeInTheDocument();
- expect( screen.getByTestId( 'error-message' ) ).toHaveTextContent(
- "The preview for this block couldn't be loaded"
- );
+ expect( screen.getByTestId( 'placeholder' ) ).toBeInTheDocument();
+ expect(
+ screen.getByTestId( 'placeholder-instructions' )
+ ).toHaveTextContent( "The preview for this block couldn't be loaded" );
} );
test( 'handles error from parseJSX with script injection attempt', () => {
@@ -281,7 +269,7 @@ describe( 'Invalid HTML Scenarios', () => {
);
- expect( screen.getByTestId( 'block-placeholder' ) ).toBeInTheDocument();
+ expect( screen.getByTestId( 'placeholder' ) ).toBeInTheDocument();
expect( global.acf.debug ).toHaveBeenCalled();
} );
@@ -310,8 +298,6 @@ describe( 'Invalid HTML Scenarios', () => {
);
// Should not show error placeholder
- expect(
- screen.queryByTestId( 'block-placeholder' )
- ).not.toBeInTheDocument();
+ expect( screen.queryByTestId( 'placeholder' ) ).not.toBeInTheDocument();
} );
} );
diff --git a/tests/js/setup-tests.js b/tests/js/setup-tests.js
index 81f07e0e..2c57f35d 100644
--- a/tests/js/setup-tests.js
+++ b/tests/js/setup-tests.js
@@ -16,3 +16,168 @@ global.jQuery = jest.fn( ( html ) => {
return [];
} );
global.$ = global.jQuery;
+
+// Mock WordPress packages that are externalized
+jest.mock(
+ '@wordpress/data',
+ () => ( {
+ useSelect: jest.fn(),
+ useDispatch: jest.fn(),
+ createReduxStore: jest.fn( ( name, config ) => ( {
+ name,
+ ...config,
+ } ) ),
+ register: jest.fn(),
+ } ),
+ { virtual: true }
+);
+
+jest.mock(
+ '@wordpress/blocks',
+ () => ( {
+ registerBlockBindingsSource: jest.fn(),
+ } ),
+ { virtual: true }
+);
+
+jest.mock(
+ '@wordpress/i18n',
+ () => ( {
+ __: jest.fn( ( text ) => text ),
+ _x: jest.fn( ( text ) => text ),
+ _n: jest.fn( ( single ) => single ),
+ } ),
+ { virtual: true }
+);
+
+jest.mock( '@wordpress/api-fetch', () => jest.fn(), { virtual: true } );
+
+jest.mock(
+ '@wordpress/url',
+ () => ( {
+ addQueryArgs: jest.fn( ( path, params ) => {
+ const query = new URLSearchParams( params ).toString();
+ return `${ path }?${ query }`;
+ } ),
+ } ),
+ { virtual: true }
+);
+
+jest.mock(
+ '@wordpress/element',
+ () => ( {
+ ...jest.requireActual( 'react' ),
+ useState: jest.requireActual( 'react' ).useState,
+ useEffect: jest.requireActual( 'react' ).useEffect,
+ useCallback: jest.requireActual( 'react' ).useCallback,
+ useMemo: jest.requireActual( 'react' ).useMemo,
+ } ),
+ { virtual: true }
+);
+
+jest.mock(
+ '@wordpress/compose',
+ () => ( {
+ createHigherOrderComponent: jest.fn( ( fn ) => fn ),
+ } ),
+ { virtual: true }
+);
+
+jest.mock(
+ '@wordpress/block-editor',
+ () => ( {
+ InspectorControls: 'InspectorControls',
+ useBlockBindingsUtils: jest.fn(),
+ } ),
+ { virtual: true }
+);
+
+jest.mock(
+ '@wordpress/components',
+ () => {
+ const mockReact = require( 'react' );
+ return {
+ ComboboxControl: 'ComboboxControl',
+ __experimentalToolsPanel: 'ToolsPanel',
+ __experimentalToolsPanelItem: 'ToolsPanelItem',
+ Placeholder: ( { icon, label, instructions, children } ) =>
+ mockReact.createElement(
+ 'div',
+ { 'data-testid': 'placeholder' },
+ [
+ icon &&
+ mockReact.createElement(
+ 'div',
+ { key: 'icon' },
+ icon
+ ),
+ label &&
+ mockReact.createElement(
+ 'div',
+ {
+ key: 'label',
+ 'data-testid': 'placeholder-label',
+ },
+ label
+ ),
+ instructions &&
+ mockReact.createElement(
+ 'div',
+ {
+ key: 'instructions',
+ 'data-testid': 'placeholder-instructions',
+ },
+ instructions
+ ),
+ children &&
+ mockReact.createElement(
+ 'div',
+ {
+ key: 'children',
+ 'data-testid': 'placeholder-actions',
+ },
+ children
+ ),
+ ]
+ ),
+ Button: ( { children, onClick, variant } ) =>
+ mockReact.createElement(
+ 'button',
+ { onClick, 'data-variant': variant },
+ children
+ ),
+ Icon: ( { icon } ) =>
+ mockReact.createElement(
+ 'span',
+ { 'data-testid': 'icon' },
+ icon
+ ),
+ };
+ },
+ { virtual: true }
+);
+
+jest.mock(
+ '@wordpress/hooks',
+ () => ( {
+ addFilter: jest.fn(),
+ applyFilters: jest.fn( ( hook, value ) => value ),
+ } ),
+ { virtual: true }
+);
+
+jest.mock(
+ '@wordpress/core-data',
+ () => ( {
+ store: 'core',
+ } ),
+ { virtual: true }
+);
+
+jest.mock(
+ '@wordpress/editor',
+ () => ( {
+ store: 'core/editor',
+ } ),
+ { virtual: true }
+);
diff --git a/tests/php/includes/rest-api/test-rest-types-endpoint.php b/tests/php/includes/rest-api/test-rest-types-endpoint.php
index cdd7af0f..e13be5cb 100644
--- a/tests/php/includes/rest-api/test-rest-types-endpoint.php
+++ b/tests/php/includes/rest-api/test-rest-types-endpoint.php
@@ -187,25 +187,250 @@ public function test_source_parameter_definition() {
$param_method = $this->reflection->getMethod( 'get_source_param_definition' );
$param_method->setAccessible( true );
- // Test without validation callbacks.
- $param_def = $param_method->invoke( $this->endpoint, false );
+ $param_def = $param_method->invoke( $this->endpoint );
$this->assertEquals( 'string', $param_def['type'] );
$this->assertFalse( $param_def['required'] );
$this->assertContains( 'core', $param_def['enum'] );
$this->assertContains( 'scf', $param_def['enum'] );
$this->assertContains( 'other', $param_def['enum'] );
$this->assertCount( 3, $param_def['enum'] );
+ $this->assertArrayHasKey( 'validate_callback', $param_def );
+ $this->assertArrayHasKey( 'sanitize_callback', $param_def );
+ $this->assertEquals( 'rest_validate_request_arg', $param_def['validate_callback'] );
+ $this->assertEquals( 'sanitize_text_field', $param_def['sanitize_callback'] );
+ }
+
+ /**
+ * Test the is_valid_source method.
+ */
+ public function test_is_valid_source() {
+ $is_valid_method = $this->reflection->getMethod( 'is_valid_source' );
+ $is_valid_method->setAccessible( true );
+
+ // Valid sources
+ $this->assertTrue( $is_valid_method->invoke( $this->endpoint, 'core' ) );
+ $this->assertTrue( $is_valid_method->invoke( $this->endpoint, 'scf' ) );
+ $this->assertTrue( $is_valid_method->invoke( $this->endpoint, 'other' ) );
+
+ // Invalid sources
+ $this->assertFalse( $is_valid_method->invoke( $this->endpoint, 'invalid' ) );
+ $this->assertFalse( $is_valid_method->invoke( $this->endpoint, '' ) );
+ $this->assertFalse( $is_valid_method->invoke( $this->endpoint, null ) );
+ $this->assertFalse( $is_valid_method->invoke( $this->endpoint, 'CORE' ) );
+ }
+
+ /**
+ * Test filter_types_request with invalid source.
+ */
+ public function test_filter_types_request_invalid_source() {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/types/post' );
+ $request->set_param( 'source', 'invalid' );
+
+ $response = $this->endpoint->filter_types_request( null, array(), $request );
+
+ // Should not modify response for invalid source
+ $this->assertNull( $response );
+ }
+
+ /**
+ * Test filter_types_request without source parameter.
+ */
+ public function test_filter_types_request_no_source() {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/types/post' );
+
+ $response = $this->endpoint->filter_types_request( null, array(), $request );
+
+ // Should not modify response when no source parameter
+ $this->assertNull( $response );
+ }
+
+ /**
+ * Test filter_types_request for collection endpoint.
+ */
+ public function test_filter_types_request_collection() {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/types' );
+ $request->set_param( 'source', 'core' );
+
+ $response = $this->endpoint->filter_types_request( null, array(), $request );
+
+ // Should not modify collection requests
+ $this->assertNull( $response );
+ }
+
+ /**
+ * Test filter_types_request with matching source.
+ */
+ public function test_filter_types_request_matching_source() {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/types/post' );
+ $request->set_param( 'source', 'core' );
+
+ $response = $this->endpoint->filter_types_request( null, array(), $request );
+
+ // Should not modify response for matching source
+ $this->assertNull( $response );
+ }
+
+ /**
+ * Test filter_types_request with non-matching source.
+ */
+ public function test_filter_types_request_non_matching_source() {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/types/post' );
+ $request->set_param( 'source', 'scf' );
+
+ $response = $this->endpoint->filter_types_request( null, array(), $request );
+
+ // Should return error for non-matching source
+ $this->assertInstanceOf( 'WP_Error', $response );
+ $this->assertEquals( 'rest_post_type_invalid', $response->get_error_code() );
+ $this->assertEquals( 404, $response->get_error_data()['status'] );
+ }
+
+ /**
+ * Test filter_post_type without source parameter.
+ */
+ public function test_filter_post_type_no_source() {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/types' );
+ $response = new WP_REST_Response( array( 'slug' => 'post' ) );
+ $post_type = get_post_type_object( 'post' );
+
+ $filtered = $this->endpoint->filter_post_type( $response, $post_type, $request );
+
+ // Should not filter when no source parameter
+ $this->assertEquals( $response, $filtered );
+ }
+
+ /**
+ * Test filter_post_type with invalid source.
+ */
+ public function test_filter_post_type_invalid_source() {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/types' );
+ $request->set_param( 'source', 'invalid' );
+ $response = new WP_REST_Response( array( 'slug' => 'post' ) );
+ $post_type = get_post_type_object( 'post' );
+
+ $filtered = $this->endpoint->filter_post_type( $response, $post_type, $request );
+
+ // Should not filter with invalid source
+ $this->assertEquals( $response, $filtered );
+ }
+
+ /**
+ * Test filter_post_type with matching source.
+ */
+ public function test_filter_post_type_matching_source() {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/types' );
+ $request->set_param( 'source', 'core' );
+ $response = new WP_REST_Response( array( 'slug' => 'post' ) );
+ $post_type = get_post_type_object( 'post' );
+
+ $filtered = $this->endpoint->filter_post_type( $response, $post_type, $request );
+
+ // Should return response for matching source
+ $this->assertEquals( $response, $filtered );
+ }
+
+ /**
+ * Test filter_post_type with non-matching source.
+ */
+ public function test_filter_post_type_non_matching_source() {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/types' );
+ $request->set_param( 'source', 'scf' );
+ $response = new WP_REST_Response( array( 'slug' => 'post' ) );
+ $post_type = get_post_type_object( 'post' );
+
+ $filtered = $this->endpoint->filter_post_type( $response, $post_type, $request );
+
+ // Should return null for non-matching source
+ $this->assertNull( $filtered );
+ }
+
+ /**
+ * Test clean_types_response with collection.
+ */
+ public function test_clean_types_response_collection() {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/types' );
+ $server = rest_get_server();
+ $response = array(
+ 'post' => array( 'slug' => 'post' ),
+ 'page' => array( 'slug' => 'page' ),
+ null,
+ );
+
+ $cleaned = $this->endpoint->clean_types_response( $response, $server, $request );
+
+ // Should remove null entries
+ $this->assertCount( 2, $cleaned );
+ $this->assertArrayHasKey( 'post', $cleaned );
+ $this->assertArrayHasKey( 'page', $cleaned );
+ $this->assertArrayNotHasKey( 2, $cleaned );
+ }
+
+ /**
+ * Test clean_types_response with single post type.
+ */
+ public function test_clean_types_response_single() {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/types/post' );
+ $server = rest_get_server();
+ $response = array(
+ 'slug' => 'post',
+ 'name' => 'Posts',
+ );
+
+ $cleaned = $this->endpoint->clean_types_response( $response, $server, $request );
+
+ // Should not modify single post type response
+ $this->assertEquals( $response, $cleaned );
+ }
+
+ /**
+ * Test clean_types_response for non-types endpoint.
+ */
+ public function test_clean_types_response_other_endpoint() {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/posts' );
+ $server = rest_get_server();
+ $response = array( 'data' => 'value' );
+
+ $cleaned = $this->endpoint->clean_types_response( $response, $server, $request );
+
+ // Should not modify other endpoints
+ $this->assertEquals( $response, $cleaned );
+ }
+
+ /**
+ * Test get_scf_fields with mock data.
+ */
+ public function test_get_scf_fields() {
+ $post_type_object = array( 'slug' => 'post' );
+
+ $fields = $this->endpoint->get_scf_fields( $post_type_object );
+
+ $this->assertIsArray( $fields );
+ }
+
+ /**
+ * Test add_parameter_to_endpoints.
+ */
+ public function test_add_parameter_to_endpoints() {
+ $endpoints = array(
+ '/wp/v2/types' => array(
+ array(
+ 'methods' => 'GET',
+ 'callback' => 'test_callback',
+ 'args' => array(),
+ ),
+ ),
+ '/wp/v2/types/(?P[\w-]+)' => array(
+ array(
+ 'methods' => 'GET',
+ 'callback' => 'test_callback',
+ 'args' => array(),
+ ),
+ ),
+ );
+
+ $modified = $this->endpoint->add_parameter_to_endpoints( $endpoints );
- // Test with validation callbacks.
- $param_def_with_validation = $param_method->invoke( $this->endpoint, true );
- $this->assertEquals( 'string', $param_def_with_validation['type'] );
- $this->assertFalse( $param_def_with_validation['required'] );
- $this->assertContains( 'core', $param_def_with_validation['enum'] );
- $this->assertContains( 'scf', $param_def_with_validation['enum'] );
- $this->assertContains( 'other', $param_def_with_validation['enum'] );
- $this->assertArrayHasKey( 'validate_callback', $param_def_with_validation );
- $this->assertArrayHasKey( 'sanitize_callback', $param_def_with_validation );
- $this->assertEquals( 'rest_validate_request_arg', $param_def_with_validation['validate_callback'] );
- $this->assertEquals( 'sanitize_text_field', $param_def_with_validation['sanitize_callback'] );
+ $this->assertArrayHasKey( 'source', $modified['/wp/v2/types'][0]['args'] );
+ $this->assertArrayHasKey( 'source', $modified['/wp/v2/types/(?P[\w-]+)'][0]['args'] );
}
}