Skip to content

Commit 5a894f5

Browse files
authored
Feature: Add binding connector to the site editor. (#253)
* Initial vibe coded commit * Fix some post vibed coded * More small imrpovementes * Address copilot review * Fix phpstan * Add testing, some refactor * Remove store call * Update e2e * Fix CI? * Close first site popup * Fix error boundary * Fix placeholder
1 parent 53b7351 commit 5a894f5

19 files changed

+2855
-329
lines changed

assets/src/js/bindings/block-editor.js

Lines changed: 76 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* WordPress dependencies
33
*/
4-
import { useState, useEffect, useCallback, useMemo } from '@wordpress/element';
4+
import { useCallback, useMemo } from '@wordpress/element';
55
import { addFilter } from '@wordpress/hooks';
66
import { createHigherOrderComponent } from '@wordpress/compose';
77
import {
@@ -14,152 +14,76 @@ import {
1414
__experimentalToolsPanelItem as ToolsPanelItem,
1515
} from '@wordpress/components';
1616
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-
};
4317

4418
/**
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.
19+
* Internal dependencies
5020
*/
51-
function getBindableAttributes( blockName ) {
52-
const config = BLOCK_BINDINGS_CONFIG[ blockName ];
53-
return config ? Object.keys( config ) : [];
54-
}
21+
import { BINDING_SOURCE } from './constants';
22+
import {
23+
getBindableAttributes,
24+
getFilteredFieldOptions,
25+
canUseUnifiedBinding,
26+
fieldsToOptions,
27+
} from './utils';
28+
import {
29+
useSiteEditorContext,
30+
usePostEditorFields,
31+
useSiteEditorFields,
32+
useBoundFields,
33+
} from './hooks';
5534

5635
/**
57-
* Add custom controls to all blocks
36+
* Add custom block binding controls to supported blocks.
37+
*
38+
* @since 6.5.0
5839
*/
5940
const withCustomControls = createHigherOrderComponent( ( BlockEdit ) => {
6041
return ( props ) => {
6142
const bindableAttributes = getBindableAttributes( props.name );
6243
const { updateBlockBindings, removeAllBlockBindings } =
6344
useBlockBindingsUtils();
6445

65-
// Get ACF fields for current post
66-
const fields = useSelect( ( select ) => {
67-
const { getEditedEntityRecord } = select( coreDataStore );
68-
const { getCurrentPostType, getCurrentPostId } =
69-
select( editorStore );
46+
// Get editor context
47+
const { isSiteEditor, templatePostType } = useSiteEditorContext();
7048

71-
const postType = getCurrentPostType();
72-
const postId = getCurrentPostId();
49+
// Get fields based on editor context
50+
const postEditorFields = usePostEditorFields();
51+
const { fields: siteEditorFields } =
52+
useSiteEditorFields( templatePostType );
7353

74-
if ( ! postType || ! postId ) return {};
54+
// Use appropriate fields based on context
55+
const activeFields = isSiteEditor ? siteEditorFields : postEditorFields;
7556

76-
const record = getEditedEntityRecord(
77-
'postType',
78-
postType,
79-
postId
80-
);
57+
// Convert fields to options format
58+
const allFieldOptions = useMemo(
59+
() => fieldsToOptions( activeFields ),
60+
[ activeFields ]
61+
);
8162

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-
}, [] );
63+
// Track bound fields
64+
const { boundFields, setBoundFields } = useBoundFields(
65+
props.attributes
66+
);
9467

95-
// Get filtered field options for an attribute
96-
const getFieldOptions = useCallback(
68+
// Get filtered field options for a specific attribute
69+
const getAttributeFieldOptions = useCallback(
9770
( 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-
} ) );
71+
return getFilteredFieldOptions(
72+
allFieldOptions,
73+
props.name,
74+
attribute
75+
);
11976
},
120-
[ fields, props.name ]
77+
[ allFieldOptions, props.name ]
12178
);
12279

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 ] );
80+
// Check if all attributes can use unified binding mode
81+
const canUseAllAttributesMode = useMemo(
82+
() => canUseUnifiedBinding( props.name, bindableAttributes ),
83+
[ props.name, bindableAttributes ]
84+
);
16185

162-
// Handle field selection
86+
// Handle field selection changes
16387
const handleFieldChange = useCallback(
16488
( attribute, value ) => {
16589
if ( Array.isArray( attribute ) ) {
@@ -171,7 +95,7 @@ const withCustomControls = createHigherOrderComponent( ( BlockEdit ) => {
17195
newBoundFields[ attr ] = value;
17296
bindings[ attr ] = value
17397
? {
174-
source: 'acf/field',
98+
source: BINDING_SOURCE,
17599
args: { key: value },
176100
}
177101
: undefined;
@@ -188,29 +112,37 @@ const withCustomControls = createHigherOrderComponent( ( BlockEdit ) => {
188112
updateBlockBindings( {
189113
[ attribute ]: value
190114
? {
191-
source: 'acf/field',
115+
source: BINDING_SOURCE,
192116
args: { key: value },
193117
}
194118
: undefined,
195119
} );
196120
}
197121
},
198-
[ boundFields, updateBlockBindings ]
122+
[ boundFields, setBoundFields, updateBlockBindings ]
199123
);
200124

201-
// Handle reset
125+
// Handle reset all bindings
202126
const handleReset = useCallback( () => {
203127
removeAllBlockBindings();
204128
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-
}
129+
}, [ removeAllBlockBindings, setBoundFields ] );
130+
131+
// Determine if we should show the panel
132+
const shouldShowPanel = useMemo( () => {
133+
// In site editor, show panel if block has bindable attributes
134+
if ( isSiteEditor ) {
135+
return bindableAttributes && bindableAttributes.length > 0;
136+
}
137+
// In post editor, only show if we have fields available
138+
return (
139+
allFieldOptions.length > 0 &&
140+
bindableAttributes &&
141+
bindableAttributes.length > 0
142+
);
143+
}, [ isSiteEditor, allFieldOptions, bindableAttributes ] );
212144

213-
if ( bindableAttributes.length === 0 ) {
145+
if ( ! shouldShowPanel ) {
214146
return <BlockEdit { ...props } />;
215147
}
216148

@@ -239,7 +171,7 @@ const withCustomControls = createHigherOrderComponent( ( BlockEdit ) => {
239171
null
240172
)
241173
}
242-
isShownByDefault={ true }
174+
isShownByDefault
243175
>
244176
<ComboboxControl
245177
label={ __(
@@ -250,7 +182,7 @@ const withCustomControls = createHigherOrderComponent( ( BlockEdit ) => {
250182
'Select a field',
251183
'secure-custom-fields'
252184
) }
253-
options={ getFieldOptions() }
185+
options={ getAttributeFieldOptions() }
254186
value={
255187
boundFields[
256188
bindableAttributes[ 0 ]
@@ -269,23 +201,25 @@ const withCustomControls = createHigherOrderComponent( ( BlockEdit ) => {
269201
) : (
270202
bindableAttributes.map( ( attribute ) => (
271203
<ToolsPanelItem
272-
key={ `scf-field-${ attribute }` }
204+
key={ `scf-binding-${ attribute }` }
273205
hasValue={ () =>
274206
!! boundFields[ attribute ]
275207
}
276208
label={ attribute }
277209
onDeselect={ () =>
278210
handleFieldChange( attribute, null )
279211
}
280-
isShownByDefault={ true }
212+
isShownByDefault
281213
>
282214
<ComboboxControl
283215
label={ attribute }
284216
placeholder={ __(
285217
'Select a field',
286218
'secure-custom-fields'
287219
) }
288-
options={ getFieldOptions( attribute ) }
220+
options={ getAttributeFieldOptions(
221+
attribute
222+
) }
289223
value={ boundFields[ attribute ] || '' }
290224
onChange={ ( value ) =>
291225
handleFieldChange(
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Block binding configuration
3+
*
4+
* Defines which SCF field types can be bound to specific block attributes.
5+
*/
6+
export const BLOCK_BINDINGS_CONFIG = {
7+
'core/paragraph': {
8+
content: [ 'text', 'textarea', 'date_picker', 'number', 'range' ],
9+
},
10+
'core/heading': {
11+
content: [ 'text', 'textarea', 'date_picker', 'number', 'range' ],
12+
},
13+
'core/image': {
14+
id: [ 'image' ],
15+
url: [ 'image' ],
16+
title: [ 'image' ],
17+
alt: [ 'image' ],
18+
},
19+
'core/button': {
20+
url: [ 'url' ],
21+
text: [ 'text', 'checkbox', 'select', 'date_picker' ],
22+
linkTarget: [ 'text', 'checkbox', 'select' ],
23+
rel: [ 'text', 'checkbox', 'select' ],
24+
},
25+
};
26+
27+
/**
28+
* Binding source identifier
29+
*/
30+
export const BINDING_SOURCE = 'acf/field';

0 commit comments

Comments
 (0)