@wordpress/block-editor
Version:
325 lines (310 loc) • 8.86 kB
JavaScript
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import {
getBlockBindingsSource,
getBlockBindingsSources,
getBlockType,
} from '@wordpress/blocks';
import {
__experimentalItemGroup as ItemGroup,
__experimentalItem as Item,
__experimentalText as Text,
__experimentalToolsPanel as ToolsPanel,
__experimentalToolsPanelItem as ToolsPanelItem,
__experimentalVStack as VStack,
privateApis as componentsPrivateApis,
} from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { useContext, Fragment } from '@wordpress/element';
import { useViewportMatch } from '@wordpress/compose';
/**
* Internal dependencies
*/
import {
canBindAttribute,
getBindableAttributes,
useBlockBindingsUtils,
} from '../utils/block-bindings';
import { unlock } from '../lock-unlock';
import InspectorControls from '../components/inspector-controls';
import BlockContext from '../components/block-context';
import { useBlockEditContext } from '../components/block-edit';
import { store as blockEditorStore } from '../store';
const { Menu } = unlock( componentsPrivateApis );
const EMPTY_OBJECT = {};
const useToolsPanelDropdownMenuProps = () => {
const isMobile = useViewportMatch( 'medium', '<' );
return ! isMobile
? {
popoverProps: {
placement: 'left-start',
// For non-mobile, inner sidebar width (248px) - button width (24px) - border (1px) + padding (16px) + spacing (20px)
offset: 259,
},
}
: {};
};
function BlockBindingsPanelMenuContent( { fieldsList, attribute, binding } ) {
const { clientId } = useBlockEditContext();
const registeredSources = getBlockBindingsSources();
const { updateBlockBindings } = useBlockBindingsUtils();
const currentKey = binding?.args?.key;
const attributeType = useSelect(
( select ) => {
const { name: blockName } =
select( blockEditorStore ).getBlock( clientId );
const _attributeType =
getBlockType( blockName ).attributes?.[ attribute ]?.type;
return _attributeType === 'rich-text' ? 'string' : _attributeType;
},
[ clientId, attribute ]
);
return (
<>
{ Object.entries( fieldsList ).map( ( [ name, fields ], i ) => (
<Fragment key={ name }>
<Menu.Group>
{ Object.keys( fieldsList ).length > 1 && (
<Menu.GroupLabel>
{ registeredSources[ name ].label }
</Menu.GroupLabel>
) }
{ Object.entries( fields )
.filter(
( [ , args ] ) => args?.type === attributeType
)
.map( ( [ key, args ] ) => (
<Menu.RadioItem
key={ key }
onChange={ () =>
updateBlockBindings( {
[ attribute ]: {
source: name,
args: { key },
},
} )
}
name={ attribute + '-binding' }
value={ key }
checked={ key === currentKey }
>
<Menu.ItemLabel>
{ args?.label }
</Menu.ItemLabel>
<Menu.ItemHelpText>
{ args?.value }
</Menu.ItemHelpText>
</Menu.RadioItem>
) ) }
</Menu.Group>
{ i !== Object.keys( fieldsList ).length - 1 && (
<Menu.Separator />
) }
</Fragment>
) ) }
</>
);
}
function BlockBindingsAttribute( { attribute, binding, fieldsList } ) {
const { source: sourceName, args } = binding || {};
const sourceProps = getBlockBindingsSource( sourceName );
const isSourceInvalid = ! sourceProps;
return (
<VStack className="block-editor-bindings__item" spacing={ 0 }>
<Text truncate>{ attribute }</Text>
{ !! binding && (
<Text
truncate
variant={ ! isSourceInvalid && 'muted' }
isDestructive={ isSourceInvalid }
>
{ isSourceInvalid
? __( 'Invalid source' )
: fieldsList?.[ sourceName ]?.[ args?.key ]?.label ||
sourceProps?.label ||
sourceName }
</Text>
) }
</VStack>
);
}
function ReadOnlyBlockBindingsPanelItems( { bindings, fieldsList } ) {
return (
<>
{ Object.entries( bindings ).map( ( [ attribute, binding ] ) => (
<Item key={ attribute }>
<BlockBindingsAttribute
attribute={ attribute }
binding={ binding }
fieldsList={ fieldsList }
/>
</Item>
) ) }
</>
);
}
function EditableBlockBindingsPanelItems( {
attributes,
bindings,
fieldsList,
} ) {
const { updateBlockBindings } = useBlockBindingsUtils();
const isMobile = useViewportMatch( 'medium', '<' );
return (
<>
{ attributes.map( ( attribute ) => {
const binding = bindings[ attribute ];
return (
<ToolsPanelItem
key={ attribute }
hasValue={ () => !! binding }
label={ attribute }
onDeselect={ () => {
updateBlockBindings( {
[ attribute ]: undefined,
} );
} }
>
<Menu
placement={
isMobile ? 'bottom-start' : 'left-start'
}
>
<Menu.TriggerButton render={ <Item /> }>
<BlockBindingsAttribute
attribute={ attribute }
binding={ binding }
fieldsList={ fieldsList }
/>
</Menu.TriggerButton>
<Menu.Popover gutter={ isMobile ? 8 : 36 }>
<BlockBindingsPanelMenuContent
fieldsList={ fieldsList }
attribute={ attribute }
binding={ binding }
/>
</Menu.Popover>
</Menu>
</ToolsPanelItem>
);
} ) }
</>
);
}
export const BlockBindingsPanel = ( { name: blockName, metadata } ) => {
const blockContext = useContext( BlockContext );
const { removeAllBlockBindings } = useBlockBindingsUtils();
const bindableAttributes = getBindableAttributes( blockName );
const dropdownMenuProps = useToolsPanelDropdownMenuProps();
// `useSelect` is used purposely here to ensure `getFieldsList`
// is updated whenever there are updates in block context.
// `source.getFieldsList` may also call a selector via `select`.
const _fieldsList = {};
const { fieldsList, canUpdateBlockBindings } = useSelect(
( select ) => {
if ( ! bindableAttributes || bindableAttributes.length === 0 ) {
return EMPTY_OBJECT;
}
const registeredSources = getBlockBindingsSources();
Object.entries( registeredSources ).forEach(
( [ sourceName, { getFieldsList, usesContext } ] ) => {
if ( getFieldsList ) {
// Populate context.
const context = {};
if ( usesContext?.length ) {
for ( const key of usesContext ) {
context[ key ] = blockContext[ key ];
}
}
const sourceList = getFieldsList( {
select,
context,
} );
// Only add source if the list is not empty.
if ( Object.keys( sourceList || {} ).length ) {
_fieldsList[ sourceName ] = { ...sourceList };
}
}
}
);
return {
fieldsList:
Object.values( _fieldsList ).length > 0
? _fieldsList
: EMPTY_OBJECT,
canUpdateBlockBindings:
select( blockEditorStore ).getSettings()
.canUpdateBlockBindings,
};
},
[ blockContext, bindableAttributes ]
);
// Return early if there are no bindable attributes.
if ( ! bindableAttributes || bindableAttributes.length === 0 ) {
return null;
}
// Filter bindings to only show bindable attributes and remove pattern overrides.
const { bindings } = metadata || {};
const filteredBindings = { ...bindings };
Object.keys( filteredBindings ).forEach( ( key ) => {
if (
! canBindAttribute( blockName, key ) ||
filteredBindings[ key ].source === 'core/pattern-overrides'
) {
delete filteredBindings[ key ];
}
} );
// Lock the UI when the user can't update bindings or there are no fields to connect to.
const readOnly =
! canUpdateBlockBindings || ! Object.keys( fieldsList ).length;
if ( readOnly && Object.keys( filteredBindings ).length === 0 ) {
return null;
}
return (
<InspectorControls group="bindings">
<ToolsPanel
label={ __( 'Attributes' ) }
resetAll={ () => {
removeAllBlockBindings();
} }
dropdownMenuProps={ dropdownMenuProps }
className="block-editor-bindings__panel"
>
<ItemGroup isBordered isSeparated>
{ readOnly ? (
<ReadOnlyBlockBindingsPanelItems
bindings={ filteredBindings }
fieldsList={ fieldsList }
/>
) : (
<EditableBlockBindingsPanelItems
attributes={ bindableAttributes }
bindings={ filteredBindings }
fieldsList={ fieldsList }
/>
) }
</ItemGroup>
{ /*
Use a div element to make the ToolsPanelHiddenInnerWrapper
toggle the visibility of this help text automatically.
*/ }
<Text as="div" variant="muted">
<p>
{ __(
'Attributes connected to custom fields or other dynamic data.'
) }
</p>
</Text>
</ToolsPanel>
</InspectorControls>
);
};
export default {
edit: BlockBindingsPanel,
attributeKeys: [ 'metadata' ],
hasSupport() {
return true;
},
};