Skip to content

Connect block attributes with custom fields via UI #176

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 37 commits into from
Jul 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
464fa1f
First commit
cbravobernal Jun 12, 2025
66c9f18
Set binding
cbravobernal Jun 12, 2025
3606d36
fix margin
cbravobernal Jun 12, 2025
07ae7d7
Include it as beta feature
cbravobernal Jun 12, 2025
8b31839
Add clear all fields button
cbravobernal Jun 15, 2025
e8d2d18
Fix format date
cbravobernal Jun 16, 2025
dc2f05a
Add edit button, still not working
cbravobernal Jun 17, 2025
1cea2b0
Fix option name
ockham Jun 24, 2025
8d7c6b4
Make work with all attributes
cbravobernal Jun 25, 2025
6c47ea5
Pending refactor, allow all attributes connection for images
cbravobernal Jun 25, 2025
521b36d
Add a not working change, used ai a lot here
cbravobernal Jun 26, 2025
6cd6504
Remove edit stuff
cbravobernal Jun 26, 2025
d9a43fd
Remove now-obsolete modal related code
ockham Jun 26, 2025
24db51e
Use link/unlink button
ockham Jun 26, 2025
69d65aa
Use toolspanel
cbravobernal Jun 26, 2025
8493b04
Use better link button
cbravobernal Jun 27, 2025
ffa345b
Make some cleaning
cbravobernal Jun 27, 2025
7fa68ab
update lock son
cbravobernal Jul 7, 2025
f4473db
Simplify the UI to connect images
cbravobernal Jul 8, 2025
8ab8f9c
Remove clog
cbravobernal Jul 8, 2025
8d17f95
Address copilot review
cbravobernal Jul 8, 2025
6dd8c20
Remove not used stuff
cbravobernal Jul 9, 2025
c0b9367
Address suggestion
cbravobernal Jul 9, 2025
afe14e4
AI powered refactor
cbravobernal Jul 9, 2025
4ec1ada
Add range
cbravobernal Jul 9, 2025
89d69ff
Fix not allowed bindings
cbravobernal Jul 9, 2025
2c96d62
Restructure processFieldBinding
ockham Jul 9, 2025
60976f6
Remove non used bindings
cbravobernal Jul 9, 2025
c57b271
Indentation
ockham Jul 9, 2025
718af07
Fix client-side formatting of the date field
ockham Jul 10, 2025
a4c8f8d
Huge refactor
cbravobernal Jul 10, 2025
4b1b3ce
Remove password case
cbravobernal Jul 10, 2025
866bb43
Merge branch 'update/connect-bindings-with-ui-refactor' into update/c…
cbravobernal Jul 10, 2025
46cea17
Fix unset bindings (AI based)
cbravobernal Jul 10, 2025
fc5ceff
More refactors
cbravobernal Jul 10, 2025
7fe385a
Add default size
cbravobernal Jul 14, 2025
4a0aeaf
Prevent sidebar from moving
cbravobernal Jul 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
312 changes: 312 additions & 0 deletions assets/src/js/bindings/block-editor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
/**
* WordPress dependencies
*/
import { useState, useEffect, useCallback, useMemo } from '@wordpress/element';
import { addFilter } from '@wordpress/hooks';
import { createHigherOrderComponent } from '@wordpress/compose';
import {
InspectorControls,
useBlockBindingsUtils,
} from '@wordpress/block-editor';
import {
ComboboxControl,
__experimentalToolsPanel as ToolsPanel,
__experimentalToolsPanelItem as ToolsPanelItem,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { useSelect } from '@wordpress/data';
import { store as coreDataStore } from '@wordpress/core-data';
import { store as editorStore } from '@wordpress/editor';

// These constant and the function above have been copied from Gutenberg. It should be public, eventually.

const BLOCK_BINDINGS_CONFIG = {
'core/paragraph': {
content: [ 'text', 'textarea', 'date_picker', 'number', 'range' ],
},
'core/heading': {
content: [ 'text', 'textarea', 'date_picker', 'number', 'range' ],
},
'core/image': {
id: [ 'image' ],
url: [ 'image' ],
title: [ 'image' ],
alt: [ 'image' ],
},
'core/button': {
url: [ 'url' ],
text: [ 'text', 'checkbox', 'select', 'date_picker' ],
linkTarget: [ 'text', 'checkbox', 'select' ],
rel: [ 'text', 'checkbox', 'select' ],
},
};

/**
* Gets the bindable attributes for a given block.
*
* @param {string} blockName The name of the block.
*
* @return {string[]} The bindable attributes for the block.
*/
function getBindableAttributes( blockName ) {
const config = BLOCK_BINDINGS_CONFIG[ blockName ];
return config ? Object.keys( config ) : [];
}

/**
* Add custom controls to all blocks
*/
const withCustomControls = createHigherOrderComponent( ( BlockEdit ) => {
return ( props ) => {
const bindableAttributes = getBindableAttributes( props.name );
const { updateBlockBindings, removeAllBlockBindings } =
useBlockBindingsUtils();

// Get ACF fields for current post
const fields = useSelect( ( select ) => {
const { getEditedEntityRecord } = select( coreDataStore );
const { getCurrentPostType, getCurrentPostId } =
select( editorStore );

const postType = getCurrentPostType();
const postId = getCurrentPostId();

if ( ! postType || ! postId ) return {};

const record = getEditedEntityRecord(
'postType',
postType,
postId
);

// Extract fields that end with '_source' (simplified)
const sourcedFields = {};
Object.entries( record?.acf || {} ).forEach( ( [ key, value ] ) => {
if ( key.endsWith( '_source' ) ) {
const baseFieldName = key.replace( '_source', '' );
if ( record?.acf.hasOwnProperty( baseFieldName ) ) {
sourcedFields[ baseFieldName ] = value;
}
}
} );
return sourcedFields;
}, [] );

// Get filtered field options for an attribute
const getFieldOptions = useCallback(
( attribute = null ) => {
if ( ! fields || Object.keys( fields ).length === 0 ) return [];

const blockConfig = BLOCK_BINDINGS_CONFIG[ props.name ];
let allowedTypes = null;

if ( blockConfig ) {
allowedTypes = attribute
? blockConfig[ attribute ]
: Object.values( blockConfig ).flat();
}

return Object.entries( fields )
.filter(
( [ , fieldConfig ] ) =>
! allowedTypes ||
allowedTypes.includes( fieldConfig.type )
)
.map( ( [ fieldName, fieldConfig ] ) => ( {
value: fieldName,
label: fieldConfig.label,
} ) );
},
[ fields, props.name ]
);

// Check if all attributes use the same field types (for "all attributes" mode)
const canUseAllAttributesMode = useMemo( () => {
if ( ! bindableAttributes || bindableAttributes.length <= 1 )
return false;

const blockConfig = BLOCK_BINDINGS_CONFIG[ props.name ];
if ( ! blockConfig ) return false;

const firstAttributeTypes =
blockConfig[ bindableAttributes[ 0 ] ] || [];
return bindableAttributes.every( ( attr ) => {
const attrTypes = blockConfig[ attr ] || [];
return (
attrTypes.length === firstAttributeTypes.length &&
attrTypes.every( ( type ) =>
firstAttributeTypes.includes( type )
)
);
} );
}, [ bindableAttributes, props.name ] );

// Track bound fields
const [ boundFields, setBoundFields ] = useState( {} );

// Sync with current bindings
useEffect( () => {
const currentBindings = props.attributes?.metadata?.bindings || {};
const newBoundFields = {};

Object.keys( currentBindings ).forEach( ( attribute ) => {
if ( currentBindings[ attribute ]?.args?.key ) {
newBoundFields[ attribute ] =
currentBindings[ attribute ].args.key;
}
} );

setBoundFields( newBoundFields );
}, [ props.attributes?.metadata?.bindings ] );

// Handle field selection
const handleFieldChange = useCallback(
( attribute, value ) => {
if ( Array.isArray( attribute ) ) {
// Handle multiple attributes at once
const newBoundFields = { ...boundFields };
const bindings = {};

attribute.forEach( ( attr ) => {
newBoundFields[ attr ] = value;
bindings[ attr ] = value
? {
source: 'acf/field',
args: { key: value },
}
: undefined;
} );

setBoundFields( newBoundFields );
updateBlockBindings( bindings );
} else {
// Handle single attribute
setBoundFields( ( prev ) => ( {
...prev,
[ attribute ]: value,
} ) );
updateBlockBindings( {
[ attribute ]: value
? {
source: 'acf/field',
args: { key: value },
}
: undefined,
} );
}
},
[ boundFields, updateBlockBindings ]
);

// Handle reset
const handleReset = useCallback( () => {
removeAllBlockBindings();
setBoundFields( {} );
}, [ removeAllBlockBindings ] );

// Don't show if no fields or attributes
const fieldOptions = getFieldOptions();
if ( fieldOptions.length === 0 || ! bindableAttributes ) {
return <BlockEdit { ...props } />;
}

return (
<>
<InspectorControls { ...props }>
<ToolsPanel
label={ __(
'Connect to a field',
'secure-custom-fields'
) }
resetAll={ handleReset }
>
{ canUseAllAttributesMode ? (
<ToolsPanelItem
hasValue={ () =>
!! boundFields[ bindableAttributes[ 0 ] ]
}
label={ __(
'All attributes',
'secure-custom-fields'
) }
onDeselect={ () =>
handleFieldChange(
bindableAttributes,
null
)
}
isShownByDefault={ true }
>
<ComboboxControl
label={ __(
'Field',
'secure-custom-fields'
) }
placeholder={ __(
'Select a field',
'secure-custom-fields'
) }
options={ getFieldOptions() }
value={
boundFields[
bindableAttributes[ 0 ]
] || ''
}
onChange={ ( value ) =>
handleFieldChange(
bindableAttributes,
value
)
}
__next40pxDefaultSize
__nextHasNoMarginBottom
/>
</ToolsPanelItem>
) : (
bindableAttributes.map( ( attribute ) => (
<ToolsPanelItem
key={ `scf-field-${ attribute }` }
hasValue={ () =>
!! boundFields[ attribute ]
}
label={ attribute }
onDeselect={ () =>
handleFieldChange( attribute, null )
}
isShownByDefault={ true }
>
<ComboboxControl
label={ attribute }
placeholder={ __(
'Select a field',
'secure-custom-fields'
) }
options={ getFieldOptions( attribute ) }
value={ boundFields[ attribute ] || '' }
onChange={ ( value ) =>
handleFieldChange(
attribute,
value
)
}
__next40pxDefaultSize
__nextHasNoMarginBottom
/>
</ToolsPanelItem>
) )
) }
</ToolsPanel>
</InspectorControls>
<BlockEdit { ...props } />
</>
);
};
}, 'withCustomControls' );

if ( window.scf?.betaFeatures?.connect_fields ) {
addFilter(
'editor.BlockEdit',
'secure-custom-fields/with-custom-controls',
withCustomControls
);
}
1 change: 1 addition & 0 deletions assets/src/js/bindings/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
import './sources.js';
import './block-editor.js';
Loading
Loading