From 6435a8de81ceda25344af4267be403c3808fb178 Mon Sep 17 00:00:00 2001 From: Carlos Bravo <37012961+cbravobernal@users.noreply.github.com> Date: Fri, 21 Nov 2025 18:02:09 +0100 Subject: [PATCH 01/10] Initial vibe coded commit --- assets/src/js/bindings/block-editor.js | 220 ++++++------------ assets/src/js/bindings/constants.js | 30 +++ assets/src/js/bindings/field-processing.js | 151 ++++++++++++ assets/src/js/bindings/hooks.js | 178 ++++++++++++++ assets/src/js/bindings/index.js | 1 + assets/src/js/bindings/sources.js | 181 +++++++------- assets/src/js/bindings/store.js | 78 +++++++ assets/src/js/bindings/utils.js | 170 ++++++++++++++ .../class-acf-rest-types-endpoint.php | 137 ++++++----- 9 files changed, 853 insertions(+), 293 deletions(-) create mode 100644 assets/src/js/bindings/constants.js create mode 100644 assets/src/js/bindings/field-processing.js create mode 100644 assets/src/js/bindings/hooks.js create mode 100644 assets/src/js/bindings/store.js create mode 100644 assets/src/js/bindings/utils.js diff --git a/assets/src/js/bindings/block-editor.js b/assets/src/js/bindings/block-editor.js index e0ad5c23..b1586169 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,35 +112,43 @@ 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 ; } return ( <> - + { 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..69438b03 --- /dev/null +++ b/assets/src/js/bindings/field-processing.js @@ -0,0 +1,151 @@ +/** + * 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.5.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.5.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. + * + * @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/hooks.js b/assets/src/js/bindings/hooks.js new file mode 100644 index 00000000..73b3afac --- /dev/null +++ b/assets/src/js/bindings/hooks.js @@ -0,0 +1,178 @@ +/** + * Custom hooks for block bindings + */ + +import { useState, useEffect } from '@wordpress/element'; +import { useSelect, useDispatch } 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 { STORE_NAME } from './store'; + +/** + * Custom hook to detect if we're in the site editor and get the template info. + * + * @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. + * + * @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 ( record.acf.hasOwnProperty( baseFieldName ) ) { + sourcedFields[ baseFieldName ] = value; + } + } + } ); + } + + return sourcedFields; + }, [] ); +} + +/** + * Custom hook to fetch and manage SCF field groups from the REST API. + * + * @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 ); + const { addFieldMetadata } = useDispatch( STORE_NAME ); + + 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, addFieldMetadata ] ); + + return { fields, isLoading, error }; +} + +/** + * Custom hook to manage block bindings state. + * + * @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/index.js b/assets/src/js/bindings/index.js index c81a6544..ca0614fa 100644 --- a/assets/src/js/bindings/index.js +++ b/assets/src/js/bindings/index.js @@ -1,2 +1,3 @@ +import './store.js'; import './sources.js'; import './block-editor.js'; diff --git a/assets/src/js/bindings/sources.js b/assets/src/js/bindings/sources.js index 84269a1c..d2477051 100644 --- a/assets/src/js/bindings/sources.js +++ b/assets/src/js/bindings/sources.js @@ -1,102 +1,68 @@ /** - * 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 { STORE_NAME } from './store'; /** - * 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 ]; - - if ( ! fieldConfig ) { - return ''; - } - - const fieldType = fieldConfig.type; - const fieldValue = fieldConfig.formatted_value; - - switch ( fieldType ) { - case 'image': - return resolveImageAttribute( fieldValue, attribute ); - case 'checkbox': - // For checkbox fields, join array values or return as string - if ( Array.isArray( fieldValue ) ) { - return fieldValue.join( ', ' ); - } - return fieldValue ? fieldValue.toString() : ''; - case 'number': - case 'range': - return fieldValue ? fieldValue.toString() : ''; - case 'date_picker': - case 'text': - case 'textarea': - case 'url': - case 'email': - case 'select': - default: - return fieldValue ? fieldValue.toString() : ''; - } -}; - registerBlockBindingsSource( { name: 'acf/field', - label: 'SCF Fields', + label: __( 'SCF Fields', 'secure-custom-fields' ), + getLabel( { args, select } ) { + const fieldKey = args?.key; + + if ( ! fieldKey ) { + return __( 'SCF Fields', 'secure-custom-fields' ); + } + + const fieldMetadata = select( STORE_NAME ).getFieldMetadata( fieldKey ); + + if ( fieldMetadata?.label ) { + return fieldMetadata.label; + } + + 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 = + select( STORE_NAME ).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 +75,6 @@ registerBlockBindingsSource( { : undefined; const scfFields = getSCFFields( post ); - const result = {}; Object.entries( bindings ).forEach( @@ -121,6 +86,48 @@ registerBlockBindingsSource( { return result; }, + getPlaceholder( { context, bindings, select } ) { + // Get the first binding to determine the field label + const firstBinding = Object.values( bindings )[ 0 ]; + if ( ! firstBinding?.args?.key ) { + return __( 'SCF Fields', 'secure-custom-fields' ); + } + + const fieldKey = firstBinding.args.key; + + // Check if we're in the site editor (editing a template) + const { getCurrentPostType } = select( editorStore ); + const currentPostType = getCurrentPostType(); + const isSiteEditor = currentPostType === 'wp_template'; + + if ( isSiteEditor ) { + const fieldMetadata = + select( STORE_NAME ).getFieldMetadata( fieldKey ); + return fieldMetadata?.label || formatFieldLabel( fieldKey ); + } + + // Regular post editor - get from post data + const { getEditedEntityRecord } = select( coreDataStore ); + + const post = + context?.postType && context?.postId + ? getEditedEntityRecord( + 'postType', + context.postType, + context.postId + ) + : undefined; + + const scfFields = getSCFFields( post ); + const fieldConfig = scfFields[ fieldKey ]; + + if ( fieldConfig?.label ) { + return fieldConfig.label; + } + + const fieldMetadata = select( STORE_NAME ).getFieldMetadata( fieldKey ); + return fieldMetadata?.label || formatFieldLabel( fieldKey ); + }, canUserEditValue() { return false; }, diff --git a/assets/src/js/bindings/store.js b/assets/src/js/bindings/store.js new file mode 100644 index 00000000..d49af852 --- /dev/null +++ b/assets/src/js/bindings/store.js @@ -0,0 +1,78 @@ +/** + * SCF Field Metadata Store + * + * Manages field metadata for block bindings using WordPress data store. + */ + +import { createReduxStore, register } from '@wordpress/data'; + +const DEFAULT_STATE = { + fieldMetadata: {}, +}; + +const actions = { + setFieldMetadata( fields ) { + return { + type: 'SET_FIELD_METADATA', + fields, + }; + }, + addFieldMetadata( fields ) { + return { + type: 'ADD_FIELD_METADATA', + fields, + }; + }, + clearFieldMetadata() { + return { + type: 'CLEAR_FIELD_METADATA', + }; + }, +}; + +const reducer = ( state = DEFAULT_STATE, action ) => { + switch ( action.type ) { + case 'SET_FIELD_METADATA': + return { + ...state, + fieldMetadata: action.fields, + }; + case 'ADD_FIELD_METADATA': + return { + ...state, + fieldMetadata: { + ...state.fieldMetadata, + ...action.fields, + }, + }; + case 'CLEAR_FIELD_METADATA': + return { + ...state, + fieldMetadata: {}, + }; + default: + return state; + } +}; + +const selectors = { + getFieldMetadata( state, fieldKey ) { + return state.fieldMetadata[ fieldKey ] || null; + }, + getAllFieldMetadata( state ) { + return state.fieldMetadata; + }, + hasFieldMetadata( state, fieldKey ) { + return !! state.fieldMetadata[ fieldKey ]; + }, +}; + +export const STORE_NAME = 'secure-custom-fields/field-metadata'; + +const store = createReduxStore( STORE_NAME, { + reducer, + actions, + selectors, +} ); + +register( store ); diff --git a/assets/src/js/bindings/utils.js b/assets/src/js/bindings/utils.js new file mode 100644 index 00000000..28596862 --- /dev/null +++ b/assets/src/js/bindings/utils.js @@ -0,0 +1,170 @@ +/** + * Utility functions for block bindings + */ + +import { BLOCK_BINDINGS_CONFIG } from './constants'; + +/** + * Gets the bindable attributes for a given block. + * + * @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. + * + * @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. + * + * @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. + * + * @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. + * + * @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. + * + * @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. + * + * @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/includes/rest-api/class-acf-rest-types-endpoint.php b/includes/rest-api/class-acf-rest-types-endpoint.php index 53499ffb..f615a79a 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,15 +293,27 @@ 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', ); } + /** + * Add source parameter to the collection parameters for the types endpoint. + * + * @since SCF 6.5.0 + * + * @param array $query_params JSON Schema-formatted collection parameters. + * @return array Modified collection parameters. + */ + public function add_collection_params( $query_params ) { + $query_params['source'] = $this->get_source_param_definition(); + return $query_params; + } + /** * Add source parameter directly to the endpoints for proper documentation * @@ -315,19 +339,6 @@ public function add_parameter_to_endpoints( $endpoints ) { return $endpoints; } - /** - * Add source parameter to the collection parameters for the types endpoint. - * - * @since SCF 6.5.0 - * - * @param array $query_params JSON Schema-formatted collection parameters. - * @return array Modified collection parameters. - */ - public function add_collection_params( $query_params ) { - $query_params['source'] = $this->get_source_param_definition(); - return $query_params; - } - /** * Clean up null entries from the response * From 6483b52c20a04d39dfd582ab7df671f43c3489ac Mon Sep 17 00:00:00 2001 From: Carlos Bravo <37012961+cbravobernal@users.noreply.github.com> Date: Mon, 24 Nov 2025 16:25:10 +0100 Subject: [PATCH 02/10] Fix some post vibed coded --- assets/src/js/bindings/field-processing.js | 5 ++- assets/src/js/bindings/hooks.js | 4 ++ assets/src/js/bindings/sources.js | 42 ------------------- assets/src/js/bindings/store.js | 1 + assets/src/js/bindings/utils.js | 7 ++++ .../class-acf-rest-types-endpoint.php | 26 ++++++------ 6 files changed, 28 insertions(+), 57 deletions(-) diff --git a/assets/src/js/bindings/field-processing.js b/assets/src/js/bindings/field-processing.js index 69438b03..f54f141d 100644 --- a/assets/src/js/bindings/field-processing.js +++ b/assets/src/js/bindings/field-processing.js @@ -61,7 +61,7 @@ export function resolveImageAttribute( imageObj, attribute ) { /** * Processes a single field binding and returns its resolved value. * - * @since 6.5.0 + * @since 6.7.0 * * @param {string} attribute The attribute being bound. * @param {Object} args The binding arguments. @@ -108,7 +108,7 @@ export function processFieldBinding( attribute, args, scfFields ) { /** * Formats a field key into a human-readable label. * - * @since 6.5.0 + * @since 6.7.0 * * @param {string} fieldKey The field key (e.g., 'my_field_name'). * @return {string} Formatted label (e.g., 'My Field Name'). @@ -127,6 +127,7 @@ export function formatFieldLabel( fieldKey ) { /** * 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. diff --git a/assets/src/js/bindings/hooks.js b/assets/src/js/bindings/hooks.js index 73b3afac..f9cb8a0e 100644 --- a/assets/src/js/bindings/hooks.js +++ b/assets/src/js/bindings/hooks.js @@ -15,6 +15,7 @@ import { STORE_NAME } from './store'; /** * 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() { @@ -54,6 +55,7 @@ export function useSiteEditorContext() { /** * 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() { @@ -90,6 +92,7 @@ export function usePostEditorFields() { /** * 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. */ @@ -154,6 +157,7 @@ export function useSiteEditorFields( postType ) { /** * 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. */ diff --git a/assets/src/js/bindings/sources.js b/assets/src/js/bindings/sources.js index d2477051..db69496a 100644 --- a/assets/src/js/bindings/sources.js +++ b/assets/src/js/bindings/sources.js @@ -86,48 +86,6 @@ registerBlockBindingsSource( { return result; }, - getPlaceholder( { context, bindings, select } ) { - // Get the first binding to determine the field label - const firstBinding = Object.values( bindings )[ 0 ]; - if ( ! firstBinding?.args?.key ) { - return __( 'SCF Fields', 'secure-custom-fields' ); - } - - const fieldKey = firstBinding.args.key; - - // Check if we're in the site editor (editing a template) - const { getCurrentPostType } = select( editorStore ); - const currentPostType = getCurrentPostType(); - const isSiteEditor = currentPostType === 'wp_template'; - - if ( isSiteEditor ) { - const fieldMetadata = - select( STORE_NAME ).getFieldMetadata( fieldKey ); - return fieldMetadata?.label || formatFieldLabel( fieldKey ); - } - - // Regular post editor - get from post data - const { getEditedEntityRecord } = select( coreDataStore ); - - const post = - context?.postType && context?.postId - ? getEditedEntityRecord( - 'postType', - context.postType, - context.postId - ) - : undefined; - - const scfFields = getSCFFields( post ); - const fieldConfig = scfFields[ fieldKey ]; - - if ( fieldConfig?.label ) { - return fieldConfig.label; - } - - const fieldMetadata = select( STORE_NAME ).getFieldMetadata( fieldKey ); - return fieldMetadata?.label || formatFieldLabel( fieldKey ); - }, canUserEditValue() { return false; }, diff --git a/assets/src/js/bindings/store.js b/assets/src/js/bindings/store.js index d49af852..fb4b6f3c 100644 --- a/assets/src/js/bindings/store.js +++ b/assets/src/js/bindings/store.js @@ -1,6 +1,7 @@ /** * SCF Field Metadata Store * + * @since 6.7.0 * Manages field metadata for block bindings using WordPress data store. */ diff --git a/assets/src/js/bindings/utils.js b/assets/src/js/bindings/utils.js index 28596862..1b10b3aa 100644 --- a/assets/src/js/bindings/utils.js +++ b/assets/src/js/bindings/utils.js @@ -7,6 +7,7 @@ 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. */ @@ -18,6 +19,7 @@ export function getBindableAttributes( blockName ) { /** * 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. @@ -40,6 +42,7 @@ export function getAllowedFieldTypes( blockName, attribute = null ) { /** * 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. @@ -68,6 +71,7 @@ export function getFilteredFieldOptions( /** * 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. @@ -96,6 +100,7 @@ export function canUseUnifiedBinding( blockName, bindableAttributes ) { /** * 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. */ @@ -125,6 +130,7 @@ export function extractPostTypeFromTemplate( templateSlug ) { /** * 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. */ @@ -152,6 +158,7 @@ export function formatFieldGroupsData( fieldGroups ) { /** * 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. */ diff --git a/includes/rest-api/class-acf-rest-types-endpoint.php b/includes/rest-api/class-acf-rest-types-endpoint.php index f615a79a..ff8805d6 100644 --- a/includes/rest-api/class-acf-rest-types-endpoint.php +++ b/includes/rest-api/class-acf-rest-types-endpoint.php @@ -301,19 +301,6 @@ private function get_source_param_definition() { ); } - /** - * Add source parameter to the collection parameters for the types endpoint. - * - * @since SCF 6.5.0 - * - * @param array $query_params JSON Schema-formatted collection parameters. - * @return array Modified collection parameters. - */ - public function add_collection_params( $query_params ) { - $query_params['source'] = $this->get_source_param_definition(); - return $query_params; - } - /** * Add source parameter directly to the endpoints for proper documentation * @@ -339,6 +326,19 @@ public function add_parameter_to_endpoints( $endpoints ) { return $endpoints; } + /** + * Add source parameter to the collection parameters for the types endpoint. + * + * @since SCF 6.5.0 + * + * @param array $query_params JSON Schema-formatted collection parameters. + * @return array Modified collection parameters. + */ + public function add_collection_params( $query_params ) { + $query_params['source'] = $this->get_source_param_definition(); + return $query_params; + } + /** * Clean up null entries from the response * From 08e96f02b1ec9e2ccf449cf2b4c259e6f1a4d23a Mon Sep 17 00:00:00 2001 From: Carlos Bravo <37012961+cbravobernal@users.noreply.github.com> Date: Mon, 24 Nov 2025 16:29:23 +0100 Subject: [PATCH 03/10] More small imrpovementes --- assets/src/js/bindings/block-editor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/src/js/bindings/block-editor.js b/assets/src/js/bindings/block-editor.js index b1586169..8b8464b2 100644 --- a/assets/src/js/bindings/block-editor.js +++ b/assets/src/js/bindings/block-editor.js @@ -148,7 +148,7 @@ const withCustomControls = createHigherOrderComponent( ( BlockEdit ) => { return ( <> - + Date: Mon, 24 Nov 2025 16:48:01 +0100 Subject: [PATCH 04/10] Address copilot review --- assets/src/js/bindings/hooks.js | 2 +- includes/rest-api/class-acf-rest-types-endpoint.php | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/assets/src/js/bindings/hooks.js b/assets/src/js/bindings/hooks.js index f9cb8a0e..572bf79e 100644 --- a/assets/src/js/bindings/hooks.js +++ b/assets/src/js/bindings/hooks.js @@ -78,7 +78,7 @@ export function usePostEditorFields() { Object.entries( record.acf ).forEach( ( [ key, value ] ) => { if ( key.endsWith( '_source' ) ) { const baseFieldName = key.replace( '_source', '' ); - if ( record.acf.hasOwnProperty( baseFieldName ) ) { + if ( Object.hasOwn( record.acf, baseFieldName ) ) { sourcedFields[ baseFieldName ] = value; } } diff --git a/includes/rest-api/class-acf-rest-types-endpoint.php b/includes/rest-api/class-acf-rest-types-endpoint.php index ff8805d6..d51a7999 100644 --- a/includes/rest-api/class-acf-rest-types-endpoint.php +++ b/includes/rest-api/class-acf-rest-types-endpoint.php @@ -162,8 +162,6 @@ private function get_source_post_types( $source ) { array_keys( get_post_types( array(), 'objects' ) ), array_merge( $core_types, $scf_types ) ); - default: - return array(); } } From 3b696d7ed9052a3983637620233a405d1d88129b Mon Sep 17 00:00:00 2001 From: Carlos Bravo <37012961+cbravobernal@users.noreply.github.com> Date: Mon, 24 Nov 2025 19:45:06 +0100 Subject: [PATCH 05/10] Fix phpstan --- includes/rest-api/class-acf-rest-types-endpoint.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/includes/rest-api/class-acf-rest-types-endpoint.php b/includes/rest-api/class-acf-rest-types-endpoint.php index d51a7999..ff8805d6 100644 --- a/includes/rest-api/class-acf-rest-types-endpoint.php +++ b/includes/rest-api/class-acf-rest-types-endpoint.php @@ -162,6 +162,8 @@ private function get_source_post_types( $source ) { array_keys( get_post_types( array(), 'objects' ) ), array_merge( $core_types, $scf_types ) ); + default: + return array(); } } From 12ea3baf5ea0a816cde6dcf5616f69751dca5195 Mon Sep 17 00:00:00 2001 From: Carlos Bravo <37012961+cbravobernal@users.noreply.github.com> Date: Fri, 28 Nov 2025 15:13:41 +0100 Subject: [PATCH 06/10] Add testing, some refactor --- assets/src/js/bindings/fieldMetadataCache.js | 62 +++ assets/src/js/bindings/hooks.js | 7 +- assets/src/js/bindings/sources.js | 7 +- assets/src/js/bindings/store.js | 79 --- .../blocks-v3/components/error-boundary.js | 2 + tests/e2e/block-bindings-site-editor.spec.ts | 310 ++++++++++++ .../e2e/plugins/scf-test-setup-post-types.php | 16 + tests/js/bindings/field-processing.test.js | 309 ++++++++++++ tests/js/bindings/fieldMetadataCache.test.js | 164 ++++++ tests/js/bindings/hooks.test.js | 476 ++++++++++++++++++ tests/js/bindings/sources.test.js | 224 +++++++++ tests/js/bindings/utils.test.js | 335 ++++++++++++ .../block-edit-error-boundary.test.js | 60 +-- tests/js/setup-tests.js | 165 ++++++ .../rest-api/test-rest-types-endpoint.php | 251 ++++++++- 15 files changed, 2330 insertions(+), 137 deletions(-) create mode 100644 assets/src/js/bindings/fieldMetadataCache.js delete mode 100644 assets/src/js/bindings/store.js create mode 100644 tests/e2e/block-bindings-site-editor.spec.ts create mode 100644 tests/js/bindings/field-processing.test.js create mode 100644 tests/js/bindings/fieldMetadataCache.test.js create mode 100644 tests/js/bindings/hooks.test.js create mode 100644 tests/js/bindings/sources.test.js create mode 100644 tests/js/bindings/utils.test.js 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 index 572bf79e..62232f57 100644 --- a/assets/src/js/bindings/hooks.js +++ b/assets/src/js/bindings/hooks.js @@ -3,14 +3,14 @@ */ import { useState, useEffect } from '@wordpress/element'; -import { useSelect, useDispatch } from '@wordpress/data'; +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 { STORE_NAME } from './store'; +import { addFieldMetadata } from './fieldMetadataCache'; /** * Custom hook to detect if we're in the site editor and get the template info. @@ -100,7 +100,6 @@ export function useSiteEditorFields( postType ) { const [ fields, setFields ] = useState( {} ); const [ isLoading, setIsLoading ] = useState( false ); const [ error, setError ] = useState( null ); - const { addFieldMetadata } = useDispatch( STORE_NAME ); useEffect( () => { if ( ! postType ) { @@ -149,7 +148,7 @@ export function useSiteEditorFields( postType ) { return () => { isCancelled = true; }; - }, [ postType, addFieldMetadata ] ); + }, [ postType ] ); return { fields, isLoading, error }; } diff --git a/assets/src/js/bindings/sources.js b/assets/src/js/bindings/sources.js index db69496a..e48dc6e9 100644 --- a/assets/src/js/bindings/sources.js +++ b/assets/src/js/bindings/sources.js @@ -14,7 +14,7 @@ import { processFieldBinding, formatFieldLabel, } from './field-processing'; -import { STORE_NAME } from './store'; +import { getFieldMetadata } from './fieldMetadataCache'; /** * Register the SCF field binding source. @@ -29,7 +29,7 @@ registerBlockBindingsSource( { return __( 'SCF Fields', 'secure-custom-fields' ); } - const fieldMetadata = select( STORE_NAME ).getFieldMetadata( fieldKey ); + const fieldMetadata = getFieldMetadata( fieldKey ); if ( fieldMetadata?.label ) { return fieldMetadata.label; @@ -53,8 +53,7 @@ registerBlockBindingsSource( { return; } - const fieldMetadata = - select( STORE_NAME ).getFieldMetadata( fieldKey ); + const fieldMetadata = getFieldMetadata( fieldKey ); result[ attribute ] = fieldMetadata?.label || formatFieldLabel( fieldKey ); } diff --git a/assets/src/js/bindings/store.js b/assets/src/js/bindings/store.js deleted file mode 100644 index fb4b6f3c..00000000 --- a/assets/src/js/bindings/store.js +++ /dev/null @@ -1,79 +0,0 @@ -/** - * SCF Field Metadata Store - * - * @since 6.7.0 - * Manages field metadata for block bindings using WordPress data store. - */ - -import { createReduxStore, register } from '@wordpress/data'; - -const DEFAULT_STATE = { - fieldMetadata: {}, -}; - -const actions = { - setFieldMetadata( fields ) { - return { - type: 'SET_FIELD_METADATA', - fields, - }; - }, - addFieldMetadata( fields ) { - return { - type: 'ADD_FIELD_METADATA', - fields, - }; - }, - clearFieldMetadata() { - return { - type: 'CLEAR_FIELD_METADATA', - }; - }, -}; - -const reducer = ( state = DEFAULT_STATE, action ) => { - switch ( action.type ) { - case 'SET_FIELD_METADATA': - return { - ...state, - fieldMetadata: action.fields, - }; - case 'ADD_FIELD_METADATA': - return { - ...state, - fieldMetadata: { - ...state.fieldMetadata, - ...action.fields, - }, - }; - case 'CLEAR_FIELD_METADATA': - return { - ...state, - fieldMetadata: {}, - }; - default: - return state; - } -}; - -const selectors = { - getFieldMetadata( state, fieldKey ) { - return state.fieldMetadata[ fieldKey ] || null; - }, - getAllFieldMetadata( state ) { - return state.fieldMetadata; - }, - hasFieldMetadata( state, fieldKey ) { - return !! state.fieldMetadata[ fieldKey ]; - }, -}; - -export const STORE_NAME = 'secure-custom-fields/field-metadata'; - -const store = createReduxStore( STORE_NAME, { - reducer, - actions, - selectors, -} ); - -register( store ); 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/tests/e2e/block-bindings-site-editor.spec.ts b/tests/e2e/block-bindings-site-editor.spec.ts new file mode 100644 index 00000000..d9cb49d4 --- /dev/null +++ b/tests/e2e/block-bindings-site-editor.spec.ts @@ -0,0 +1,310 @@ +/** + * 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 TEXTAREA_FIELD_LABEL = 'Product Description'; +const IMAGE_FIELD_LABEL = 'Product Image'; +const URL_FIELD_LABEL = 'Product Link'; +const POST_TYPE = 'product'; + +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, + } ) => { + // Create a field group + await admin.visitAdminPage( 'edit.php', 'post_type=acf-field-group' ); + await page.click( 'a.acf-btn:has-text("Add New")' ); + + // Fill field group 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 ); + + // Set field type + const fieldType = page.locator( + 'select[id^="acf_fields-field_"][id$="-type"]' + ); + await fieldType.selectOption( 'text' ); + + // Show in REST API + await page.check( 'input[name*="[show_in_rest]"]' ); + + // Set location to product post type + const locationSelect = page.locator( + 'select[data-name="param"]' + ); + await locationSelect.selectOption( 'post_type' ); + + const locationValue = page.locator( + 'select[data-name="value"]' + ); + await locationValue.selectOption( POST_TYPE ); + + // Publish field group + await page.click( 'button.acf-btn.acf-publish[type="submit"]' ); + + // Wait for success message + await page.waitForSelector( '.updated.notice' ); + + // Navigate to site editor + await admin.visitAdminPage( 'site-editor.php' ); + + // Wait for site editor to load + await page.waitForSelector( '.edit-site-header' ); + + // Create or edit a template for the product post type + // This part will depend on the actual site editor implementation + // For now, we'll test that the REST API endpoint returns the fields + const response = await page.evaluate( async () => { + const res = await fetch( '/wp-json/wp/v2/types/product?context=edit' ); + return res.json(); + } ); + + expect( response ).toHaveProperty( 'scf_field_groups' ); + expect( response.scf_field_groups ).toBeInstanceOf( Array ); + expect( response.scf_field_groups.length ).toBeGreaterThan( 0 ); + + const fieldGroup = response.scf_field_groups[ 0 ]; + expect( fieldGroup ).toHaveProperty( 'title', FIELD_GROUP_LABEL ); + expect( fieldGroup.fields ).toBeInstanceOf( Array ); + expect( fieldGroup.fields[ 0 ] ).toHaveProperty( 'label', TEXT_FIELD_LABEL ); + expect( fieldGroup.fields[ 0 ] ).toHaveProperty( 'type', 'text' ); + } ); + + test( 'should bind image field to image block in site editor', async ( { + page, + admin, + } ) => { + // Create a field group with image field + await admin.visitAdminPage( 'edit.php', 'post_type=acf-field-group' ); + await page.click( 'a.acf-btn:has-text("Add New")' ); + + await page.fill( '#title', 'Product Images' ); + + const fieldLabel = page.locator( + 'input[id^="acf_fields-field_"][id$="-label"]' + ); + await fieldLabel.fill( IMAGE_FIELD_LABEL ); + + const fieldType = page.locator( + 'select[id^="acf_fields-field_"][id$="-type"]' + ); + await fieldType.selectOption( 'image' ); + + // Show in REST API + await page.check( 'input[name*="[show_in_rest]"]' ); + + // Set location + const locationSelect = page.locator( + 'select[data-name="param"]' + ); + await locationSelect.selectOption( 'post_type' ); + + const locationValue = page.locator( + 'select[data-name="value"]' + ); + await locationValue.selectOption( POST_TYPE ); + + // Publish field group + await page.click( 'button.acf-btn.acf-publish[type="submit"]' ); + + await page.waitForSelector( '.updated.notice' ); + + // Verify field is available via REST API + const response = await page.evaluate( async () => { + const res = await fetch( '/wp-json/wp/v2/types/product?context=edit' ); + return res.json(); + } ); + + const imageField = response.scf_field_groups + .flatMap( ( group ) => group.fields ) + .find( ( field ) => field.label === IMAGE_FIELD_LABEL ); + + expect( imageField ).toBeDefined(); + expect( imageField.type ).toBe( 'image' ); + } ); + + test( 'should retrieve field groups for custom post type via REST API', async ( { + page, + admin, + } ) => { + // Create a comprehensive field group + await admin.visitAdminPage( 'edit.php', 'post_type=acf-field-group' ); + await page.click( 'a.acf-btn:has-text("Add New")' ); + + await page.fill( '#title', 'Complete Product Fields' ); + + // Add multiple field types + const fields = [ + { label: 'Name', type: 'text' }, + { label: 'Description', type: 'textarea' }, + { label: 'Price', type: 'number' }, + { label: 'Featured Image', type: 'image' }, + { label: 'External URL', type: 'url' }, + ]; + + for ( let i = 0; i < fields.length; i++ ) { + if ( i > 0 ) { + // Click add field button for additional fields + await page.click( 'a.acf-button-add:has-text("Add Field")' ); + } + + const fieldLabelInput = page.locator( + `input[id^="acf_fields-field_"][id$="-label"]` + ).nth( i ); + await fieldLabelInput.fill( fields[ i ].label ); + + const fieldTypeSelect = page.locator( + `select[id^="acf_fields-field_"][id$="-type"]` + ).nth( i ); + await fieldTypeSelect.selectOption( fields[ i ].type ); + + // Show in REST API + const restCheckbox = page.locator( + 'input[name*="[show_in_rest]"]' + ).nth( i ); + await restCheckbox.check(); + } + + // Set location + const locationSelect = page.locator( + 'select[data-name="param"]' + ); + await locationSelect.selectOption( 'post_type' ); + + const locationValue = page.locator( + 'select[data-name="value"]' + ); + await locationValue.selectOption( POST_TYPE ); + + // Publish field group + await page.click( 'button.acf-btn.acf-publish[type="submit"]' ); + + await page.waitForSelector( '.updated.notice' ); + + // Test REST API response + const response = await page.evaluate( async () => { + const res = await fetch( '/wp-json/wp/v2/types/product?context=edit' ); + return res.json(); + } ); + + expect( response.scf_field_groups ).toBeInstanceOf( Array ); + + const fieldGroup = response.scf_field_groups.find( + ( group ) => group.title === 'Complete Product Fields' + ); + + expect( fieldGroup ).toBeDefined(); + expect( fieldGroup.fields.length ).toBe( fields.length ); + + fields.forEach( ( field, index ) => { + expect( fieldGroup.fields[ index ].label ).toBe( field.label ); + expect( fieldGroup.fields[ index ].type ).toBe( field.type ); + } ); + } ); + + test( 'should filter post types by source parameter', async ( { + page, + } ) => { + // Test core source + const coreResponse = await page.evaluate( async () => { + const res = await fetch( '/wp-json/wp/v2/types?source=core' ); + return res.json(); + } ); + + expect( coreResponse ).toHaveProperty( 'post' ); + expect( coreResponse ).toHaveProperty( 'page' ); + expect( coreResponse ).not.toHaveProperty( 'product' ); + + // Test other source (should include custom post types) + const otherResponse = await page.evaluate( async () => { + const res = await fetch( '/wp-json/wp/v2/types?source=other' ); + return res.json(); + } ); + + // Custom post types should be in 'other' + expect( Object.keys( otherResponse ).length ).toBeGreaterThanOrEqual( 0 ); + } ); + + test( 'should handle bindings with show_in_rest enabled', async ( { + page, + admin, + requestUtils, + } ) => { + // Create a field group with show_in_rest enabled + await admin.visitAdminPage( 'edit.php', 'post_type=acf-field-group' ); + await page.click( 'a.acf-btn:has-text("Add New")' ); + + await page.fill( '#title', 'REST Enabled Fields' ); + + const fieldLabel = page.locator( + 'input[id^="acf_fields-field_"][id$="-label"]' + ); + await fieldLabel.fill( 'REST Field' ); + + const fieldType = page.locator( + 'select[id^="acf_fields-field_"][id$="-type"]' + ); + await fieldType.selectOption( 'text' ); + + // Enable show in REST API + await page.check( 'input[name*="[show_in_rest]"]' ); + + // Set location + const locationSelect = page.locator( + 'select[data-name="param"]' + ); + await locationSelect.selectOption( 'post_type' ); + + const locationValue = page.locator( + 'select[data-name="value"]' + ); + await locationValue.selectOption( 'post' ); + + // Publish + await page.click( 'button.acf-btn.acf-publish[type="submit"]' ); + await page.waitForSelector( '.updated.notice' ); + + // Create a post with the field + const post = await requestUtils.createPost( { + title: 'Test Post', + status: 'publish', + acf: { + rest_field: 'Test value', + }, + } ); + + // Verify field is accessible via REST API + const response = await page.evaluate( async ( postId ) => { + const res = await fetch( + `/wp-json/wp/v2/posts/${ postId }?context=edit` + ); + return res.json(); + }, post.id ); + + expect( response ).toHaveProperty( 'acf' ); + expect( response.acf ).toHaveProperty( 'rest_field' ); + } ); +} ); 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'] ); } } From 06bb89857078c728b7f452abb0546faf1cfdf0b7 Mon Sep 17 00:00:00 2001 From: Carlos Bravo <37012961+cbravobernal@users.noreply.github.com> Date: Fri, 28 Nov 2025 15:46:06 +0100 Subject: [PATCH 07/10] Remove store call --- assets/src/js/bindings/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/assets/src/js/bindings/index.js b/assets/src/js/bindings/index.js index ca0614fa..c81a6544 100644 --- a/assets/src/js/bindings/index.js +++ b/assets/src/js/bindings/index.js @@ -1,3 +1,2 @@ -import './store.js'; import './sources.js'; import './block-editor.js'; From ced340b6c8e2012105c62cd037f1c16b644d1b00 Mon Sep 17 00:00:00 2001 From: Carlos Bravo <37012961+cbravobernal@users.noreply.github.com> Date: Sun, 30 Nov 2025 23:59:47 +0100 Subject: [PATCH 08/10] Update e2e --- tests/e2e/block-bindings-site-editor.spec.ts | 303 ++++--------------- 1 file changed, 51 insertions(+), 252 deletions(-) diff --git a/tests/e2e/block-bindings-site-editor.spec.ts b/tests/e2e/block-bindings-site-editor.spec.ts index d9cb49d4..d5bb6b5f 100644 --- a/tests/e2e/block-bindings-site-editor.spec.ts +++ b/tests/e2e/block-bindings-site-editor.spec.ts @@ -5,22 +5,16 @@ 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 TEXTAREA_FIELD_LABEL = 'Product Description'; const IMAGE_FIELD_LABEL = 'Product Image'; -const URL_FIELD_LABEL = 'Product Link'; -const POST_TYPE = 'product'; 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(); } ); @@ -29,282 +23,87 @@ test.describe( 'Block Bindings in Site Editor', () => { page, admin, } ) => { - // Create a field group + // Navigate to Field Groups and create new. await admin.visitAdminPage( 'edit.php', 'post_type=acf-field-group' ); - await page.click( 'a.acf-btn:has-text("Add New")' ); + const addNewButton = page.locator( 'a.acf-btn:has-text("Add New")' ); + await addNewButton.click(); - // Fill field group title + // Fill field group title. + await page.waitForSelector( '#title' ); await page.fill( '#title', FIELD_GROUP_LABEL ); - // Add text field + // 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. - // Set field type + // 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' ); - // Show in REST API - await page.check( 'input[name*="[show_in_rest]"]' ); + // Select Group Settings > Enable REST API + const groupSettingsTab = page.getByRole( 'link', { name: 'Group Settings' } ); + await groupSettingsTab.click(); - // Set location to product post type - const locationSelect = page.locator( - 'select[data-name="param"]' - ); - await locationSelect.selectOption( 'post_type' ); + // Enable Show in REST API + const showInRestCheckbox = page.locator( '#acf_field_group-show_in_rest' ); + await showInRestCheckbox.check( { force: true } ); - const locationValue = page.locator( - 'select[data-name="value"]' + // Submit form. + const publishButton = page.locator( + 'button.acf-btn.acf-publish[type="submit"]' ); - await locationValue.selectOption( POST_TYPE ); - - // Publish field group - await page.click( 'button.acf-btn.acf-publish[type="submit"]' ); + await publishButton.click(); - // Wait for success message - await page.waitForSelector( '.updated.notice' ); + // 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' ); - - // Wait for site editor to load - await page.waitForSelector( '.edit-site-header' ); - - // Create or edit a template for the product post type - // This part will depend on the actual site editor implementation - // For now, we'll test that the REST API endpoint returns the fields - const response = await page.evaluate( async () => { - const res = await fetch( '/wp-json/wp/v2/types/product?context=edit' ); - return res.json(); - } ); - - expect( response ).toHaveProperty( 'scf_field_groups' ); - expect( response.scf_field_groups ).toBeInstanceOf( Array ); - expect( response.scf_field_groups.length ).toBeGreaterThan( 0 ); - - const fieldGroup = response.scf_field_groups[ 0 ]; - expect( fieldGroup ).toHaveProperty( 'title', FIELD_GROUP_LABEL ); - expect( fieldGroup.fields ).toBeInstanceOf( Array ); - expect( fieldGroup.fields[ 0 ] ).toHaveProperty( 'label', TEXT_FIELD_LABEL ); - expect( fieldGroup.fields[ 0 ] ).toHaveProperty( 'type', 'text' ); - } ); - - test( 'should bind image field to image block in site editor', async ( { - page, - admin, - } ) => { - // Create a field group with image field - await admin.visitAdminPage( 'edit.php', 'post_type=acf-field-group' ); - await page.click( 'a.acf-btn:has-text("Add New")' ); - - await page.fill( '#title', 'Product Images' ); - - const fieldLabel = page.locator( - 'input[id^="acf_fields-field_"][id$="-label"]' - ); - await fieldLabel.fill( IMAGE_FIELD_LABEL ); - - const fieldType = page.locator( - 'select[id^="acf_fields-field_"][id$="-type"]' - ); - await fieldType.selectOption( 'image' ); - - // Show in REST API - await page.check( 'input[name*="[show_in_rest]"]' ); - - // Set location - const locationSelect = page.locator( - 'select[data-name="param"]' - ); - await locationSelect.selectOption( 'post_type' ); - - const locationValue = page.locator( - 'select[data-name="value"]' - ); - await locationValue.selectOption( POST_TYPE ); - - // Publish field group - await page.click( 'button.acf-btn.acf-publish[type="submit"]' ); - - await page.waitForSelector( '.updated.notice' ); - - // Verify field is available via REST API - const response = await page.evaluate( async () => { - const res = await fetch( '/wp-json/wp/v2/types/product?context=edit' ); - return res.json(); - } ); - - const imageField = response.scf_field_groups - .flatMap( ( group ) => group.fields ) - .find( ( field ) => field.label === IMAGE_FIELD_LABEL ); - - expect( imageField ).toBeDefined(); - expect( imageField.type ).toBe( 'image' ); - } ); - - test( 'should retrieve field groups for custom post type via REST API', async ( { - page, - admin, - } ) => { - // Create a comprehensive field group - await admin.visitAdminPage( 'edit.php', 'post_type=acf-field-group' ); - await page.click( 'a.acf-btn:has-text("Add New")' ); - - await page.fill( '#title', 'Complete Product Fields' ); + await admin.visitAdminPage( 'site-editor.php?p=%2Fwp_template%2Ftwentytwentyfive%2F%2Fsingle&canvas=edit' ); - // Add multiple field types - const fields = [ - { label: 'Name', type: 'text' }, - { label: 'Description', type: 'textarea' }, - { label: 'Price', type: 'number' }, - { label: 'Featured Image', type: 'image' }, - { label: 'External URL', type: 'url' }, - ]; + // 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 - for ( let i = 0; i < fields.length; i++ ) { - if ( i > 0 ) { - // Click add field button for additional fields - await page.click( 'a.acf-button-add:has-text("Add Field")' ); - } + // 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(); - const fieldLabelInput = page.locator( - `input[id^="acf_fields-field_"][id$="-label"]` - ).nth( i ); - await fieldLabelInput.fill( fields[ i ].label ); + // Press Enter to add a new paragraph block + await page.keyboard.press('Enter'); - const fieldTypeSelect = page.locator( - `select[id^="acf_fields-field_"][id$="-type"]` - ).nth( i ); - await fieldTypeSelect.selectOption( fields[ i ].type ); + // Wait for the newly created empty paragraph block + await frameLocator.locator('[data-type="core/paragraph"][data-empty="true"]').waitFor({ timeout: 5000 }); - // Show in REST API - const restCheckbox = page.locator( - 'input[name*="[show_in_rest]"]' - ).nth( i ); - await restCheckbox.check(); - } + // Click on the empty paragraph block to select it + const emptyParagraph = frameLocator.locator('[data-type="core/paragraph"][data-empty="true"]'); + await emptyParagraph.click(); - // Set location - const locationSelect = page.locator( - 'select[data-name="param"]' - ); - await locationSelect.selectOption( 'post_type' ); - - const locationValue = page.locator( - 'select[data-name="value"]' - ); - await locationValue.selectOption( POST_TYPE ); - - // Publish field group - await page.click( 'button.acf-btn.acf-publish[type="submit"]' ); - - await page.waitForSelector( '.updated.notice' ); - - // Test REST API response - const response = await page.evaluate( async () => { - const res = await fetch( '/wp-json/wp/v2/types/product?context=edit' ); - return res.json(); - } ); - - expect( response.scf_field_groups ).toBeInstanceOf( Array ); - - const fieldGroup = response.scf_field_groups.find( - ( group ) => group.title === 'Complete Product Fields' - ); - - expect( fieldGroup ).toBeDefined(); - expect( fieldGroup.fields.length ).toBe( fields.length ); - - fields.forEach( ( field, index ) => { - expect( fieldGroup.fields[ index ].label ).toBe( field.label ); - expect( fieldGroup.fields[ index ].type ).toBe( field.type ); - } ); - } ); - - test( 'should filter post types by source parameter', async ( { - page, - } ) => { - // Test core source - const coreResponse = await page.evaluate( async () => { - const res = await fetch( '/wp-json/wp/v2/types?source=core' ); - return res.json(); - } ); - - expect( coreResponse ).toHaveProperty( 'post' ); - expect( coreResponse ).toHaveProperty( 'page' ); - expect( coreResponse ).not.toHaveProperty( 'product' ); - - // Test other source (should include custom post types) - const otherResponse = await page.evaluate( async () => { - const res = await fetch( '/wp-json/wp/v2/types?source=other' ); - return res.json(); - } ); - - // Custom post types should be in 'other' - expect( Object.keys( otherResponse ).length ).toBeGreaterThanOrEqual( 0 ); - } ); + // 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 }); - test( 'should handle bindings with show_in_rest enabled', async ( { - page, - admin, - requestUtils, - } ) => { - // Create a field group with show_in_rest enabled - await admin.visitAdminPage( 'edit.php', 'post_type=acf-field-group' ); - await page.click( 'a.acf-btn:has-text("Add New")' ); - - await page.fill( '#title', 'REST Enabled Fields' ); - - const fieldLabel = page.locator( - 'input[id^="acf_fields-field_"][id$="-label"]' - ); - await fieldLabel.fill( 'REST Field' ); - - const fieldType = page.locator( - 'select[id^="acf_fields-field_"][id$="-type"]' - ); - await fieldType.selectOption( 'text' ); - - // Enable show in REST API - await page.check( 'input[name*="[show_in_rest]"]' ); - - // Set location - const locationSelect = page.locator( - 'select[data-name="param"]' - ); - await locationSelect.selectOption( 'post_type' ); - - const locationValue = page.locator( - 'select[data-name="value"]' - ); - await locationValue.selectOption( 'post' ); + // Click on the combobox input to open suggestions + const comboboxInput = page.locator('input[id^="components-form-token-input-combobox-control-"]').first(); + await comboboxInput.click(); - // Publish - await page.click( 'button.acf-btn.acf-publish[type="submit"]' ); - await page.waitForSelector( '.updated.notice' ); + // Wait for suggestions to appear + await page.waitForSelector('ul[id^="components-form-token-suggestions-combobox-control-"]', { timeout: 5000 }); - // Create a post with the field - const post = await requestUtils.createPost( { - title: 'Test Post', - status: 'publish', - acf: { - rest_field: 'Test value', - }, - } ); + // Select "Product Name" from the dropdown + await page.getByRole('option', { name: 'Product Name' }).click(); - // Verify field is accessible via REST API - const response = await page.evaluate( async ( postId ) => { - const res = await fetch( - `/wp-json/wp/v2/posts/${ postId }?context=edit` - ); - return res.json(); - }, post.id ); + // Verify the paragraph block now contains "Product Name" + const boundParagraph = frameLocator.locator('[data-type="core/paragraph"]:has-text("Product Name")'); + await expect( boundParagraph ).toBeVisible(); - expect( response ).toHaveProperty( 'acf' ); - expect( response.acf ).toHaveProperty( 'rest_field' ); } ); } ); From 9baed981da318d7218f9945c10f263a24b711141 Mon Sep 17 00:00:00 2001 From: Carlos Bravo <37012961+cbravobernal@users.noreply.github.com> Date: Mon, 1 Dec 2025 00:23:24 +0100 Subject: [PATCH 09/10] Fix CI? --- tests/e2e/block-bindings-site-editor.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/e2e/block-bindings-site-editor.spec.ts b/tests/e2e/block-bindings-site-editor.spec.ts index d5bb6b5f..5b5c761e 100644 --- a/tests/e2e/block-bindings-site-editor.spec.ts +++ b/tests/e2e/block-bindings-site-editor.spec.ts @@ -5,6 +5,7 @@ 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'; @@ -12,9 +13,11 @@ 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(); } ); From 42c0413421d0b7ff84f5df79c29766b5bee69034 Mon Sep 17 00:00:00 2001 From: Carlos Bravo <37012961+cbravobernal@users.noreply.github.com> Date: Mon, 1 Dec 2025 12:45:53 +0100 Subject: [PATCH 10/10] Close first site popup --- tests/e2e/block-bindings-site-editor.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/e2e/block-bindings-site-editor.spec.ts b/tests/e2e/block-bindings-site-editor.spec.ts index 5b5c761e..348fd946 100644 --- a/tests/e2e/block-bindings-site-editor.spec.ts +++ b/tests/e2e/block-bindings-site-editor.spec.ts @@ -74,6 +74,10 @@ test.describe( 'Block Bindings in Site Editor', () => { 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"]');