Skip to content

Commit 2475c85

Browse files
cbravobernalockham
andauthored
Connect block attributes with custom fields via UI (#176)
* First commit * Set binding * fix margin * Include it as beta feature * Add clear all fields button * Fix format date * Add edit button, still not working * Fix option name * Make work with all attributes * Pending refactor, allow all attributes connection for images * Add a not working change, used ai a lot here * Remove edit stuff * Remove now-obsolete modal related code * Use link/unlink button * Use toolspanel * Use better link button * Make some cleaning * update lock son * Simplify the UI to connect images * Remove clog * Address copilot review * Remove not used stuff * Address suggestion * AI powered refactor * Add range * Fix not allowed bindings * Restructure processFieldBinding * Remove non used bindings * Indentation * Fix client-side formatting of the date field * Huge refactor * Remove password case * Fix unset bindings (AI based) * More refactors * Add default size * Prevent sidebar from moving --------- Co-authored-by: Bernie Reiter <[email protected]>
1 parent be5ac5b commit 2475c85

File tree

10 files changed

+468
-66
lines changed

10 files changed

+468
-66
lines changed
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
/**
2+
* WordPress dependencies
3+
*/
4+
import { useState, useEffect, useCallback, useMemo } from '@wordpress/element';
5+
import { addFilter } from '@wordpress/hooks';
6+
import { createHigherOrderComponent } from '@wordpress/compose';
7+
import {
8+
InspectorControls,
9+
useBlockBindingsUtils,
10+
} from '@wordpress/block-editor';
11+
import {
12+
ComboboxControl,
13+
__experimentalToolsPanel as ToolsPanel,
14+
__experimentalToolsPanelItem as ToolsPanelItem,
15+
} from '@wordpress/components';
16+
import { __ } from '@wordpress/i18n';
17+
import { useSelect } from '@wordpress/data';
18+
import { store as coreDataStore } from '@wordpress/core-data';
19+
import { store as editorStore } from '@wordpress/editor';
20+
21+
// These constant and the function above have been copied from Gutenberg. It should be public, eventually.
22+
23+
const BLOCK_BINDINGS_CONFIG = {
24+
'core/paragraph': {
25+
content: [ 'text', 'textarea', 'date_picker', 'number', 'range' ],
26+
},
27+
'core/heading': {
28+
content: [ 'text', 'textarea', 'date_picker', 'number', 'range' ],
29+
},
30+
'core/image': {
31+
id: [ 'image' ],
32+
url: [ 'image' ],
33+
title: [ 'image' ],
34+
alt: [ 'image' ],
35+
},
36+
'core/button': {
37+
url: [ 'url' ],
38+
text: [ 'text', 'checkbox', 'select', 'date_picker' ],
39+
linkTarget: [ 'text', 'checkbox', 'select' ],
40+
rel: [ 'text', 'checkbox', 'select' ],
41+
},
42+
};
43+
44+
/**
45+
* Gets the bindable attributes for a given block.
46+
*
47+
* @param {string} blockName The name of the block.
48+
*
49+
* @return {string[]} The bindable attributes for the block.
50+
*/
51+
function getBindableAttributes( blockName ) {
52+
const config = BLOCK_BINDINGS_CONFIG[ blockName ];
53+
return config ? Object.keys( config ) : [];
54+
}
55+
56+
/**
57+
* Add custom controls to all blocks
58+
*/
59+
const withCustomControls = createHigherOrderComponent( ( BlockEdit ) => {
60+
return ( props ) => {
61+
const bindableAttributes = getBindableAttributes( props.name );
62+
const { updateBlockBindings, removeAllBlockBindings } =
63+
useBlockBindingsUtils();
64+
65+
// Get ACF fields for current post
66+
const fields = useSelect( ( select ) => {
67+
const { getEditedEntityRecord } = select( coreDataStore );
68+
const { getCurrentPostType, getCurrentPostId } =
69+
select( editorStore );
70+
71+
const postType = getCurrentPostType();
72+
const postId = getCurrentPostId();
73+
74+
if ( ! postType || ! postId ) return {};
75+
76+
const record = getEditedEntityRecord(
77+
'postType',
78+
postType,
79+
postId
80+
);
81+
82+
// Extract fields that end with '_source' (simplified)
83+
const sourcedFields = {};
84+
Object.entries( record?.acf || {} ).forEach( ( [ key, value ] ) => {
85+
if ( key.endsWith( '_source' ) ) {
86+
const baseFieldName = key.replace( '_source', '' );
87+
if ( record?.acf.hasOwnProperty( baseFieldName ) ) {
88+
sourcedFields[ baseFieldName ] = value;
89+
}
90+
}
91+
} );
92+
return sourcedFields;
93+
}, [] );
94+
95+
// Get filtered field options for an attribute
96+
const getFieldOptions = useCallback(
97+
( attribute = null ) => {
98+
if ( ! fields || Object.keys( fields ).length === 0 ) return [];
99+
100+
const blockConfig = BLOCK_BINDINGS_CONFIG[ props.name ];
101+
let allowedTypes = null;
102+
103+
if ( blockConfig ) {
104+
allowedTypes = attribute
105+
? blockConfig[ attribute ]
106+
: Object.values( blockConfig ).flat();
107+
}
108+
109+
return Object.entries( fields )
110+
.filter(
111+
( [ , fieldConfig ] ) =>
112+
! allowedTypes ||
113+
allowedTypes.includes( fieldConfig.type )
114+
)
115+
.map( ( [ fieldName, fieldConfig ] ) => ( {
116+
value: fieldName,
117+
label: fieldConfig.label,
118+
} ) );
119+
},
120+
[ fields, props.name ]
121+
);
122+
123+
// Check if all attributes use the same field types (for "all attributes" mode)
124+
const canUseAllAttributesMode = useMemo( () => {
125+
if ( ! bindableAttributes || bindableAttributes.length <= 1 )
126+
return false;
127+
128+
const blockConfig = BLOCK_BINDINGS_CONFIG[ props.name ];
129+
if ( ! blockConfig ) return false;
130+
131+
const firstAttributeTypes =
132+
blockConfig[ bindableAttributes[ 0 ] ] || [];
133+
return bindableAttributes.every( ( attr ) => {
134+
const attrTypes = blockConfig[ attr ] || [];
135+
return (
136+
attrTypes.length === firstAttributeTypes.length &&
137+
attrTypes.every( ( type ) =>
138+
firstAttributeTypes.includes( type )
139+
)
140+
);
141+
} );
142+
}, [ bindableAttributes, props.name ] );
143+
144+
// Track bound fields
145+
const [ boundFields, setBoundFields ] = useState( {} );
146+
147+
// Sync with current bindings
148+
useEffect( () => {
149+
const currentBindings = props.attributes?.metadata?.bindings || {};
150+
const newBoundFields = {};
151+
152+
Object.keys( currentBindings ).forEach( ( attribute ) => {
153+
if ( currentBindings[ attribute ]?.args?.key ) {
154+
newBoundFields[ attribute ] =
155+
currentBindings[ attribute ].args.key;
156+
}
157+
} );
158+
159+
setBoundFields( newBoundFields );
160+
}, [ props.attributes?.metadata?.bindings ] );
161+
162+
// Handle field selection
163+
const handleFieldChange = useCallback(
164+
( attribute, value ) => {
165+
if ( Array.isArray( attribute ) ) {
166+
// Handle multiple attributes at once
167+
const newBoundFields = { ...boundFields };
168+
const bindings = {};
169+
170+
attribute.forEach( ( attr ) => {
171+
newBoundFields[ attr ] = value;
172+
bindings[ attr ] = value
173+
? {
174+
source: 'acf/field',
175+
args: { key: value },
176+
}
177+
: undefined;
178+
} );
179+
180+
setBoundFields( newBoundFields );
181+
updateBlockBindings( bindings );
182+
} else {
183+
// Handle single attribute
184+
setBoundFields( ( prev ) => ( {
185+
...prev,
186+
[ attribute ]: value,
187+
} ) );
188+
updateBlockBindings( {
189+
[ attribute ]: value
190+
? {
191+
source: 'acf/field',
192+
args: { key: value },
193+
}
194+
: undefined,
195+
} );
196+
}
197+
},
198+
[ boundFields, updateBlockBindings ]
199+
);
200+
201+
// Handle reset
202+
const handleReset = useCallback( () => {
203+
removeAllBlockBindings();
204+
setBoundFields( {} );
205+
}, [ removeAllBlockBindings ] );
206+
207+
// Don't show if no fields or attributes
208+
const fieldOptions = getFieldOptions();
209+
if ( fieldOptions.length === 0 || ! bindableAttributes ) {
210+
return <BlockEdit { ...props } />;
211+
}
212+
213+
return (
214+
<>
215+
<InspectorControls { ...props }>
216+
<ToolsPanel
217+
label={ __(
218+
'Connect to a field',
219+
'secure-custom-fields'
220+
) }
221+
resetAll={ handleReset }
222+
>
223+
{ canUseAllAttributesMode ? (
224+
<ToolsPanelItem
225+
hasValue={ () =>
226+
!! boundFields[ bindableAttributes[ 0 ] ]
227+
}
228+
label={ __(
229+
'All attributes',
230+
'secure-custom-fields'
231+
) }
232+
onDeselect={ () =>
233+
handleFieldChange(
234+
bindableAttributes,
235+
null
236+
)
237+
}
238+
isShownByDefault={ true }
239+
>
240+
<ComboboxControl
241+
label={ __(
242+
'Field',
243+
'secure-custom-fields'
244+
) }
245+
placeholder={ __(
246+
'Select a field',
247+
'secure-custom-fields'
248+
) }
249+
options={ getFieldOptions() }
250+
value={
251+
boundFields[
252+
bindableAttributes[ 0 ]
253+
] || ''
254+
}
255+
onChange={ ( value ) =>
256+
handleFieldChange(
257+
bindableAttributes,
258+
value
259+
)
260+
}
261+
__next40pxDefaultSize
262+
__nextHasNoMarginBottom
263+
/>
264+
</ToolsPanelItem>
265+
) : (
266+
bindableAttributes.map( ( attribute ) => (
267+
<ToolsPanelItem
268+
key={ `scf-field-${ attribute }` }
269+
hasValue={ () =>
270+
!! boundFields[ attribute ]
271+
}
272+
label={ attribute }
273+
onDeselect={ () =>
274+
handleFieldChange( attribute, null )
275+
}
276+
isShownByDefault={ true }
277+
>
278+
<ComboboxControl
279+
label={ attribute }
280+
placeholder={ __(
281+
'Select a field',
282+
'secure-custom-fields'
283+
) }
284+
options={ getFieldOptions( attribute ) }
285+
value={ boundFields[ attribute ] || '' }
286+
onChange={ ( value ) =>
287+
handleFieldChange(
288+
attribute,
289+
value
290+
)
291+
}
292+
__next40pxDefaultSize
293+
__nextHasNoMarginBottom
294+
/>
295+
</ToolsPanelItem>
296+
) )
297+
) }
298+
</ToolsPanel>
299+
</InspectorControls>
300+
<BlockEdit { ...props } />
301+
</>
302+
);
303+
};
304+
}, 'withCustomControls' );
305+
306+
if ( window.scf?.betaFeatures?.connect_fields ) {
307+
addFilter(
308+
'editor.BlockEdit',
309+
'secure-custom-fields/with-custom-controls',
310+
withCustomControls
311+
);
312+
}

assets/src/js/bindings/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
import './sources.js';
2+
import './block-editor.js';

0 commit comments

Comments
 (0)