diff --git a/bin/webpack.blocks.js b/bin/webpack.blocks.js index f986bbf65..ccddbbfd7 100644 --- a/bin/webpack.blocks.js +++ b/bin/webpack.blocks.js @@ -23,6 +23,7 @@ module.exports = [ 'react-dom': [ 'cf', 'vendor', 'react-dom' ], 'nanoid': [ 'cf', 'vendor', 'nanoid' ], 'immer': [ 'cf', 'vendor', 'immer' ], + '@wordpress/api-fetch': [ 'wp', 'apiFetch' ], '@wordpress/components': [ 'wp', 'components' ], '@wordpress/blocks': [ 'wp', 'blocks' ], '@wordpress/editor': [ 'wp', 'editor' ], diff --git a/config.php b/config.php index 69c86edab..c681d73f4 100644 --- a/config.php +++ b/config.php @@ -4,7 +4,7 @@ # Define version constant if ( ! defined( __NAMESPACE__ . '\VERSION' ) ) { - define( __NAMESPACE__ . '\VERSION', '3.0.2' ); + define( __NAMESPACE__ . '\VERSION', '3.1.0-beta.1' ); } # Define root directory diff --git a/core/Container/Block_Container.php b/core/Container/Block_Container.php index 5b5e5774c..b202a8bbc 100644 --- a/core/Container/Block_Container.php +++ b/core/Container/Block_Container.php @@ -11,6 +11,14 @@ class Block_Container extends Container { */ public $settings = array( 'preview' => true, + 'parent' => null, + 'inner_blocks' => array( + 'enabled' => false, + 'position' => 'above', + 'template' => null, + 'template_lock' => null, + 'allowed_blocks' => null, + ), 'category' => array( 'slug' => 'common', ), @@ -221,6 +229,117 @@ public function set_preview_mode( $preview = true ) { return $this; } + /** + * Set the parent block(s) in which the block type can be inserted. + * + * @see https://wordpress.org/gutenberg/handbook/designers-developers/developers/block-api/block-registration/#parent-optional + * + * @param string|string[]|null $parent + * @return Block_Container + */ + public function set_parent( $parent = null ) { + if ( ! is_array( $parent ) && ! is_string( $parent ) && ! is_null( $parent ) ) { + throw new \Exception( __( "The parent must be 'array', 'string' or 'null'.", 'crb' ) ); + } + + $this->settings[ 'parent' ] = is_string( $parent ) ? array( $parent ) : $parent; + + return $this; + } + + /** + * Set whether the inner blocks are available for the block type. + * + * @param boolean $inner_blocks + * @return Block_Container + */ + public function set_inner_blocks( $inner_blocks = true ) { + $this->settings[ 'inner_blocks' ][ 'enabled' ] = $inner_blocks; + + return $this; + } + + /** + * Set the position of the inner blocks to be rendered + * above or below the fields. + * + * @param string $position + * @return Block_Container + */ + public function set_inner_blocks_position( $position = 'above' ) { + if ( ! in_array( $position, [ 'above', 'below' ] ) ) { + throw new \Exception( __( "The position of inner blocks must be 'above' or 'below'.", 'crb' ) ); + } + + $this->settings[ 'inner_blocks' ][ 'position' ] = $position; + + return $this; + } + + /** + * Set the default template that should be rendered in inner blocks. + * + * @see https://github.com/WordPress/gutenberg/tree/master/packages/editor/src/components/inner-blocks#template + * + * @param array[]|null $template + * @return Block_Container + */ + public function set_inner_blocks_template( $template = null ) { + if ( ! is_array( $template ) && ! is_null( $template ) ) { + throw new \Exception( __( "The template must be an 'array' or 'null'.", 'crb' ) ); + } + + $this->settings[ 'inner_blocks' ][ 'template' ] = $template; + + return $this; + } + + /** + * Set the lock mode used by template of inner blocks. + * + * @see https://github.com/WordPress/gutenberg/tree/master/packages/editor/src/components/inner-blocks#templatelock + * + * @param string|boolean|null $lock + * @return Block_Container + */ + public function set_inner_blocks_template_lock( $lock = null ) { + if ( is_string( $lock ) && ! in_array( $lock, [ 'all', 'insert' ] ) ) { + throw new \Exception( __( "The template lock must be 'all', 'insert', 'false' or 'null'.", 'crb' ) ); + } + + $this->settings[ 'inner_blocks' ][ 'template_lock' ] = $lock; + + return $this; + } + + /** + * Set the list of allowed blocks that can be inserted. + * + * @see https://github.com/WordPress/gutenberg/tree/master/packages/editor/src/components/inner-blocks#allowedblocks + * + * @param string[]|null $blocks + * @return Block_Container + */ + public function set_allowed_inner_blocks( $blocks = null ) { + if ( ! is_array( $blocks ) && ! is_null( $blocks ) ) { + throw new \Exception( __( "The allowed blocks must be an 'array' or 'null'.", 'crb' ) ); + } + + if ( is_array( $blocks ) ) { + $this->settings[ 'inner_blocks' ][ 'allowed_blocks' ] = array_map( function ( $block ) { + if ( $block instanceof self ) { + return $block->get_block_type_name(); + } + + return $block; + }, $blocks ); + } else { + $this->settings[ 'inner_blocks' ][ 'allowed_blocks' ] = $blocks; + } + + return $this; + } + /** * Set the render callback of the block type. * @@ -239,16 +358,30 @@ public function set_render_callback( $render_callback ) { * Render the block type. * * @param array $attributes + * @param string $content * @return string */ - public function render_block( $attributes ) { + public function render_block( $attributes, $content ) { + $fields = $attributes['data']; + + // Unset the "data" property because we + // pass it as separate argument to the callback. + unset($attributes['data']); + ob_start(); - call_user_func( $this->render_callback , $attributes[ 'data' ] ); + call_user_func( $this->render_callback , $fields, $attributes, $content ); return ob_get_clean(); } + /** + * Returns the block type name, e.g. "carbon-fields/testimonial" + */ + private function get_block_type_name() { + return str_replace( 'carbon-fields-container-', 'carbon-fields/', str_replace( '_', '-', $this->id ) ); + } + /** * Register the block type. * @@ -263,7 +396,6 @@ protected function register_block() { throw new \Exception( __( "'render_callback' must be a callable.", 'crb' ) ); } - $name = str_replace( 'carbon-fields-container-', 'carbon-fields/', str_replace( '_', '-', $this->id ) ); $style = isset( $this->settings[ 'style' ] ) ? $this->settings[ 'style' ] : null; $editor_style = isset( $this->settings[ 'editor_style' ] ) ? $this->settings[ 'editor_style' ] : null; $attributes = array_reduce( $this->get_fields(), function( $attributes, $field ) { @@ -277,7 +409,7 @@ protected function register_block() { ), ) ); - register_block_type( $name, array( + register_block_type( $this->get_block_type_name(), array( 'style' => $style, 'editor_style' => $editor_style, 'attributes' => $attributes, diff --git a/core/REST_API/Router.php b/core/REST_API/Router.php index 34c97847a..187aaae85 100644 --- a/core/REST_API/Router.php +++ b/core/REST_API/Router.php @@ -58,6 +58,13 @@ class Router { 'permission_callback' => 'allow_access', 'methods' => 'GET', ), + 'block_renderer' => array( + 'path' => '/block-renderer', + 'callback' => 'block_renderer', + 'permission_callback' => 'block_renderer_permission', + 'methods' => 'POST', + 'args' => 'block_renderer_args_schema', + ) ); /** @@ -177,6 +184,7 @@ protected function register_route( $route ) { 'methods' => $route['methods'], 'permission_callback' => array( $this, $route['permission_callback'] ), 'callback' => array( $this, $route['callback'] ), + 'args' => isset( $route['args'] ) ? call_user_func( array( $this, $route['args'] ) ) : array(), ) ); } @@ -351,4 +359,110 @@ protected function set_options( $request ) { return new \WP_REST_Response( __( 'Theme Options updated.', 'carbon-fields' ), 200 ); } + + /** + * Checks if a given request has access to read blocks. + * + * @see https://github.com/WordPress/WordPress/blob/master/wp-includes/rest-api/endpoints/class-wp-rest-block-renderer-controller.php#L78-L116 + * + * @param WP_REST_Request + * @return true|WP_Error + */ + public function block_renderer_permission( $request ) { + global $post; + + $post_id = isset( $request['post_id'] ) ? intval( $request['post_id'] ) : 0; + + if ( 0 < $post_id ) { + $post = get_post( $post_id ); + + if ( ! $post || ! current_user_can( 'edit_post', $post->ID ) ) { + return new \WP_Error( + 'block_cannot_read', + __( 'Sorry, you are not allowed to read blocks of this post.', 'carbon-fields' ), + array( + 'status' => rest_authorization_required_code(), + ) + ); + } + } else { + if ( ! current_user_can( 'edit_posts' ) ) { + return new \WP_Error( + 'block_cannot_read', + __( 'Sorry, you are not allowed to read blocks as this user.', 'carbon-fields' ), + array( + 'status' => rest_authorization_required_code(), + ) + ); + } + } + + return true; + } + + /** + * Returns the schema of the accepted arguments. + * + * @see https://github.com/WordPress/WordPress/blob/master/wp-includes/rest-api/endpoints/class-wp-rest-block-renderer-controller.php#L56-L71 + * + * @return array + */ + public function block_renderer_args_schema() { + return array( + 'name' => array( + 'type' => 'string', + 'required' => true, + 'description' => __( 'The name of the block.', 'carbon-fields' ), + ), + 'content' => array( + 'type' => 'string', + 'required' => true, + 'description' => __( 'The content of the block.', 'carbon-fields' ), + ), + 'post_id' => array( + 'type' => 'integer', + 'description' => __( 'ID of the post context.', 'carbon-fields' ), + ), + ); + } + + /** + * Returns block output from block's registered render_callback. + * + * @see https://github.com/WordPress/WordPress/blob/master/wp-includes/rest-api/endpoints/class-wp-rest-block-renderer-controller.php#L118-L154 + * + * @param WP_REST_Request $request + * @return WP_REST_Response|WP_Error + */ + public function block_renderer( $request ) { + global $post; + + $post_id = isset( $request['post_id'] ) ? intval( $request['post_id'] ) : 0; + + if ( 0 < $post_id ) { + $post = get_post( $post_id ); + + // Set up postdata since this will be needed if post_id was set. + setup_postdata( $post ); + } + + $registry = \WP_Block_Type_Registry::get_instance(); + $block = $registry->get_registered( $request['name'] ); + + if ( null === $block ) { + return new \WP_Error( + 'block_invalid', + __( 'Invalid block.' ), + array( + 'status' => 404, + ) + ); + } + + $data = array( + 'rendered' => do_blocks( $request['content'] ), + ); + + return rest_ensure_response( $data ); + } } diff --git a/package.json b/package.json index cfc78a439..2b35e43d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "carbon-fields", - "version": "3.0.2", + "version": "3.1.0-beta.1", "description": "WordPress developer-friendly custom fields for post types, taxonomy terms, users, comments, widgets, options, navigation menus and more.", "directories": { "test": "tests" @@ -61,6 +61,7 @@ }, "dependencies": { "@babel/runtime": "^7.1.5", + "@wordpress/api-fetch": "^2.2.8", "@wordpress/blocks": "^6.0.2", "@wordpress/components": "^7.0.3", "@wordpress/compose": "^3.0.0", diff --git a/packages/blocks/components/block-edit/index.js b/packages/blocks/components/block-edit/index.js index 9a3cb49e4..b9041bf94 100644 --- a/packages/blocks/components/block-edit/index.js +++ b/packages/blocks/components/block-edit/index.js @@ -3,12 +3,12 @@ */ import cx from 'classnames'; import { Component, Fragment } from '@wordpress/element'; +import { Toolbar, PanelBody } from '@wordpress/components'; import { - Toolbar, - PanelBody, - ServerSideRender -} from '@wordpress/components'; -import { BlockControls, InspectorControls } from '@wordpress/editor'; + InnerBlocks, + BlockControls, + InspectorControls +} from '@wordpress/editor'; import { withSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { @@ -27,6 +27,7 @@ import { getFieldType } from '@carbon-fields/core'; */ import './style.scss'; import Field from '../field'; +import ServerSideRender from '../server-side-render'; class BlockEdit extends Component { /** @@ -182,15 +183,27 @@ class BlockEdit extends Component { const { currentTab } = this.state; const { - name, + clientId, container, - attributes, supportsTabs, - supportsPreview + supportsPreview, + supportsInnerBlocks } = this.props; + const innerBlocks = ( ( supportsInnerBlocks && this.isInEditMode ) && ( +
+ +
+ ) ); + return ( + { container.settings.inner_blocks.position === 'above' && innerBlocks } + { supportsPreview && ( - + ) } + { container.settings.inner_blocks.position === 'below' && innerBlocks } + { this.isInPreviewMode && ( { @@ -280,17 +295,21 @@ class BlockEdit extends Component { } } -export default withSelect( ( select, { name } ) => { +export default withSelect( ( select, { clientId, name } ) => { const { hasBlockSupport } = select( 'core/blocks' ); + const { getBlockRootClientId } = select( 'core/editor' ); const { getContainerDefinitionByBlockName, getFieldDefinitionsByBlockName } = select( 'carbon-fields/blocks' ); + const rootClientId = getBlockRootClientId( clientId ); + return { container: getContainerDefinitionByBlockName( name ), fields: getFieldDefinitionsByBlockName( name ), supportsTabs: hasBlockSupport( name, 'tabs' ), - supportsPreview: hasBlockSupport( name, 'preview' ) + supportsPreview: hasBlockSupport( name, 'preview' ) && ! rootClientId, + supportsInnerBlocks: hasBlockSupport( name, 'innerBlocks' ) }; } )( BlockEdit ); diff --git a/packages/blocks/components/block-edit/style.scss b/packages/blocks/components/block-edit/style.scss index af25e7270..e60c1a5c8 100644 --- a/packages/blocks/components/block-edit/style.scss +++ b/packages/blocks/components/block-edit/style.scss @@ -44,3 +44,8 @@ .cf-block__preview { min-height: 100px; } + +.cf-block__inner-blocks .block-list-appender { + margin-top: 32px; + margin-bottom: 32px; +} diff --git a/packages/blocks/components/block-save/index.js b/packages/blocks/components/block-save/index.js index c3a80144d..12138e02e 100644 --- a/packages/blocks/components/block-save/index.js +++ b/packages/blocks/components/block-save/index.js @@ -2,16 +2,40 @@ * External dependencies. */ import { Component } from '@wordpress/element'; +import { InnerBlocks } from '@wordpress/editor'; class BlockSave extends Component { /** * Render the component. * - * @return {Object} + * @return {null} */ render() { return null; } } +/** + * Adds the content of inner blocks to the saved content. + * + * @param {mixed} element + * @param {Object} blockType + * @return {mixed} + */ +function addInnerBlocksContent( element, blockType ) { + if ( ! /^carbon\-fields\/.+$/.test( blockType.name ) ) { + return element; + } + + if ( ! blockType.supports.innerBlocks ) { + return element; + } + + return ( + + ); +} + +wp.hooks.addFilter( 'blocks.getSaveElement', 'carbon-fields/blocks', addInnerBlocksContent ); + export default BlockSave; diff --git a/packages/blocks/components/server-side-render/index.js b/packages/blocks/components/server-side-render/index.js new file mode 100644 index 000000000..2320ec2c6 --- /dev/null +++ b/packages/blocks/components/server-side-render/index.js @@ -0,0 +1,154 @@ +/** + * External dependencies. + */ +import apiFetch from '@wordpress/api-fetch'; +import { Component, RawHTML } from '@wordpress/element'; +import { Placeholder, Spinner } from '@wordpress/components'; +import { withSelect } from '@wordpress/data'; +import { serialize } from '@wordpress/blocks'; +import { __, sprintf } from '@wordpress/i18n'; +import { isEqual, debounce } from 'lodash'; + +/** + * This component is slightly modified version of the `ServerSideRender` component + * that comes by default with Gutenberg. + * + * @see https://github.com/WordPress/gutenberg/tree/master/packages/components/src/server-side-render + */ +class ServerSideRender extends Component { + /** + * Local state. + * + * @type {Object} + */ + state = { + response: null + }; + + /** + * Lifecycle hook. + * + * @return {void} + */ + componentDidMount() { + this.isStillMounted = true; + + // Do the initial rendering. + this.fetch( this.props ); + + // Only debounce once the initial fetch occurs to ensure that the first + // renders show data as soon as possible. + this.fetch = debounce( this.fetch, 500 ); + } + + /** + * Lifecycle hook. + * + * @return {void} + */ + componentWillUnmount() { + this.isStillMounted = false; + } + + /** + * Lifecycle hook. + * + * @param {Object} prevProps + * @return {void} + */ + componentDidUpdate( prevProps ) { + if ( ! isEqual( prevProps, this.props ) ) { + this.fetch( this.props ); + } + } + + /** + * Fetch the preview of the block. + * + * @param {Object} props + * @return {void} + */ + fetch( props ) { + if ( ! this.isStillMounted ) { + return; + } + + if ( null !== this.state.response ) { + this.setState( { response: null } ); + } + + const { block } = props; + + // Store the latest fetch request so that when we process it, we can + // check if it is the current request, to avoid race conditions on slow networks. + const fetchRequest = this.currentFetchRequest = apiFetch( { + method: 'post', + path: '/carbon-fields/v1/block-renderer', + data: { + name: block.name, + content: serialize( [ block ] ) + } + } ) + .then( ( response ) => { + if ( this.isStillMounted && fetchRequest === this.currentFetchRequest && response && response.rendered ) { + this.setState( { + response: response.rendered + } ); + } + } ) + .catch( ( error ) => { + if ( this.isStillMounted && fetchRequest === this.currentFetchRequest ) { + this.setState( { + response: { + error: true, + errorMsg: error.message + } + } ); + } + } ); + } + + /** + * Render the component. + * + * @return {Object} + */ + render() { + const { response } = this.state; + const { className } = this.props; + + if ( ! response ) { + return ( + + + + ); + } else if ( response.error ) { + return ( + + { sprintf( __( 'Error loading block: %s', 'carbon-fields-ui' ), response.errorMsg ) } + + ); + } else if ( ! response.length ) { + return ( + + { __( 'No results found.', 'carbon-fields-ui' ) } + + ); + } + + return ( + + { response } + + ); + } +} + +export default withSelect( ( select, { clientId } ) => { + const { getBlock } = select( 'core/editor' ); + + return { + block: getBlock( clientId ) + }; +} )( ServerSideRender ); diff --git a/packages/blocks/index.js b/packages/blocks/index.js index db22a67f5..583df01fc 100644 --- a/packages/blocks/index.js +++ b/packages/blocks/index.js @@ -44,6 +44,7 @@ get( window.cf, 'preloaded.blocks', [] ).forEach( ( container ) => { registerBlockType( `carbon-fields/${ name }`, { title: container.title, icon: getBlockSetting( 'icon' ), + parent: getBlockSetting( 'parent', [] ), category: getBlockSetting( 'category.slug' ), keywords: getBlockSetting( 'keywords', [] ), description: getBlockSetting( 'description', '' ), @@ -56,6 +57,7 @@ get( window.cf, 'preloaded.blocks', [] ).forEach( ( container ) => { supports: { tabs: isPlainObject( getBlockSetting( 'tabs' ) ), preview: getBlockSetting( 'preview' ), + innerBlocks: getBlockSetting( 'inner_blocks.enabled' ), alignWide: false, anchor: false, html: false diff --git a/packages/core/fields/association/index.js b/packages/core/fields/association/index.js index 6e0a12cb0..c15fd288a 100644 --- a/packages/core/fields/association/index.js +++ b/packages/core/fields/association/index.js @@ -229,7 +229,7 @@ class AssociationField extends Component {
- + { ( ! option.disabled diff --git a/packages/core/fields/color/index.js b/packages/core/fields/color/index.js index 2a0b6a85d..93116a921 100644 --- a/packages/core/fields/color/index.js +++ b/packages/core/fields/color/index.js @@ -10,7 +10,7 @@ import { get } from 'lodash'; */ import './style.scss'; import Picker from './picker'; -import hexToRgba from '../../utils/hex-to-rgba'; +import { hexToRgba, rgbaToHex } from '../../utils/hex-and-rgba'; class ColorField extends Component { /** @@ -27,7 +27,7 @@ class ColorField extends Component { * * @return {void} */ - getBackgrounColor = () => { + getBackgroundColor = () => { const { field, value } = this.props; const colorHex = value ? value : '#FFFFFFFF'; @@ -44,9 +44,15 @@ class ColorField extends Component { * @return {void} */ handleChange = ( color ) => { - const { id, onChange } = this.props; + const { id, onChange, field } = this.props; - onChange( id, get( color, 'hex', '' ) ); + let value = get( color, 'hex', '' ).toUpperCase(); + + if ( field.alphaEnabled ) { + value = rgbaToHex( get( color, 'rgb', null ) ); + } + + onChange( id, value ); } /** @@ -80,7 +86,7 @@ class ColorField extends Component { />