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