diff --git a/composer.json b/composer.json index 65c426151..3b7c33478 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ }, "require-dev": { "brain/monkey": "^2", + "mockery/mockery": "^1.2.4", "squizlabs/php_codesniffer": "^3.4", "wp-coding-standards/wpcs": "2.1.1" } diff --git a/php/blocks/class-field.php b/php/blocks/class-field.php index 3b96a6678..357317f1f 100644 --- a/php/blocks/class-field.php +++ b/php/blocks/class-field.php @@ -125,6 +125,20 @@ public function from_array( $config ) { $this->settings = $config['settings']; } + if ( ! isset( $config['type'] ) ) { + $control_class_name = 'Block_Lab\\Blocks\\Controls\\'; + $control_class_name .= ucwords( $this->control, '_' ); + if ( class_exists( $control_class_name ) ) { + /** + * An instance of the control, to retrieve the correct type. + * + * @var Control_Abstract $control_class + */ + $control_class = new $control_class_name(); + $this->type = $control_class->type; + } + } + // Add any other non-default keys to the settings array. $field_defaults = array( 'name', 'label', 'control', 'type', 'order', 'settings' ); $field_settings = array_diff( array_keys( $config ), $field_defaults ); diff --git a/php/blocks/class-loader.php b/php/blocks/class-loader.php index 674bcec1c..c7b59b99d 100644 --- a/php/blocks/class-loader.php +++ b/php/blocks/class-loader.php @@ -22,11 +22,13 @@ class Loader extends Component_Abstract { protected $assets = []; /** - * JSON representing last loaded blocks. + * An associative array of block config data for the blocks that will be registered. * - * @var string + * The key of each item in the array is the block name. + * + * @var array */ - protected $blocks = ''; + protected $blocks = array(); /** * A data store for sharing data to helper functions. @@ -138,7 +140,7 @@ protected function editor_assets() { // Add dynamic Gutenberg blocks. wp_add_inline_script( 'block-lab-blocks', - 'const blockLabBlocks = ' . $this->blocks, + 'const blockLabBlocks = ' . wp_json_encode( $this->blocks ), 'before' ); @@ -150,8 +152,7 @@ protected function editor_assets() { $this->plugin->get_version() ); - $blocks = json_decode( $this->blocks, true ); - $block_names = wp_list_pluck( $blocks, 'name' ); + $block_names = wp_list_pluck( $this->blocks, 'name' ); foreach ( $block_names as $block_name ) { $this->enqueue_block_styles( $block_name, array( 'preview', 'block' ) ); @@ -193,9 +194,7 @@ protected function dynamic_block_loader() { return; } - $blocks = json_decode( $this->blocks, true ); - - foreach ( $blocks as $block_name => $block_config ) { + foreach ( $this->blocks as $block_name => $block_config ) { $block = new Block(); $block->from_array( $block_config ); $this->register_block( $block_name, $block ); @@ -239,9 +238,7 @@ protected function register_block( $block_name, $block ) { * @return array */ protected function register_categories( $categories ) { - $blocks = json_decode( $this->blocks, true ); - - foreach ( $blocks as $block_config ) { + foreach ( $this->blocks as $block_config ) { if ( ! isset( $block_config['category'] ) ) { continue; } @@ -531,11 +528,10 @@ protected function block_template( $name, $type = 'block' ) { * Load all the published blocks and blocks/block.json files. */ protected function retrieve_blocks() { - $this->blocks = ''; - $blocks = []; - - // Retrieve blocks from blocks.json. - // Reverse to preserve order of preference when using array_merge. + /** + * Retrieve blocks from blocks.json. + * Reverse to preserve order of preference when using array_merge. + */ $blocks_files = array_reverse( (array) block_lab()->locate_template( 'blocks/blocks.json', '', false ) ); foreach ( $blocks_files as $blocks_file ) { // This is expected to be on the local filesystem, so file_get_contents() is ok to use here. @@ -544,10 +540,13 @@ protected function retrieve_blocks() { // Merge if no json_decode error occurred. if ( json_last_error() == JSON_ERROR_NONE ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison - $blocks = array_merge( $blocks, $block_data ); + $this->blocks = array_merge( $this->blocks, $block_data ); } } + /** + * Retrieve blocks stored as posts in the WordPress database. + */ $block_posts = new \WP_Query( [ 'post_type' => block_lab()->get_post_type_slug(), @@ -563,11 +562,60 @@ protected function retrieve_blocks() { // Merge if no json_decode error occurred. if ( json_last_error() == JSON_ERROR_NONE ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison - $blocks = array_merge( $blocks, $block_data ); + $this->blocks = array_merge( $this->blocks, $block_data ); } } } - $this->blocks = wp_json_encode( $blocks ); + /** + * Use this action to add new blocks and fields with the block_lab_add_block and block_lab_add_field helper functions. + */ + do_action( 'block_lab_add_blocks' ); + + /** + * Filter the available blocks. + * + * This is used internally by the block_lab_add_block and block_lab_add_field helper functions, + * but it can also be used to hide certain blocks if desired. + * + * @param array $blocks An associative array of blocks. + */ + $this->blocks = apply_filters( 'block_lab_blocks', $this->blocks ); + } + + /** + * Add a new block. + * + * This method should be called during the block_lab_add_blocks action, to ensure + * that the block isn't added too late. + * + * @param array $block_config The config of the block to add. + */ + public function add_block( $block_config ) { + if ( ! isset( $block_config['name'] ) ) { + return; + } + + $this->blocks[ "block-lab/{$block_config['name']}" ] = $block_config; + } + + /** + * Add a new field to an existing block. + * + * This method should be called during the block_lab_add_blocks action, to ensure + * that the block isn't added too late. + * + * @param string $block_name The name of the block that the field is added to. + * @param array $field_config The config of the field to add. + */ + public function add_field( $block_name, $field_config ) { + if ( ! isset( $this->blocks[ "block-lab/{$block_name}" ] ) ) { + return; + } + if ( ! isset( $field_config['name'] ) ) { + return; + } + + $this->blocks[ "block-lab/{$block_name}" ]['fields'][ $field_config['name'] ] = $field_config; } } diff --git a/php/helpers.php b/php/helpers.php index d7ef0807f..4f1e6bae6 100644 --- a/php/helpers.php +++ b/php/helpers.php @@ -318,3 +318,72 @@ function block_field_config( $name ) { return (array) $config->fields[ $name ]; } + +/** + * Add a new block. + * + * @param string $block_name The block name (slug), like 'example-block'. + * @param array $block_config { + * An associative array containing the block configuration. + * + * @type string $title The block title. + * @type string $icon The block icon. See assets/icons.json for a JSON array of all possible values. Default: 'block_lab'. + * @type string $category The slug of a registered category. Categories include: common, formatting, layout, widgets, embed. Default: 'common'. + * @type array $excluded Exclude the block in these post types. Default: []. + * @type string[] $keywords An array of up to three keywords. Default: []. + * @type array $fields { + * An associative array containing block fields. Each key in the array should be the field slug. + * + * @type array {$slug} { + * An associative array describing a field. Refer to the $field_config parameter of block_lab_add_field(). + * } + * } + * } + */ +function block_lab_add_block( $block_name, $block_config = array() ) { + $block_config['name'] = str_replace( '_', '-', sanitize_title( $block_name ) ); + + $default_config = array( + 'title' => str_replace( '-', ' ', ucwords( $block_config['name'], '-' ) ), + 'icon' => 'block_lab', + 'category' => 'common', + 'excluded' => array(), + 'keywords' => array(), + 'fields' => array(), + ); + + $block_config = wp_parse_args( $block_config, $default_config ); + block_lab()->loader->add_block( $block_config ); +} + +/** + * Add a field to a block. + * + * @param string $block_name The block name (slug), like 'example-block'. + * @param string $field_name The field name (slug), like 'first-name'. + * @param array $field_config { + * An associative array containing the field configuration. + * + * @type string $name The field name. + * @type string $label The field label. + * @type string $control The field control type. Default: 'text'. + * @type int $order The order that the field appears in. Default: 0. + * @type array $settings { + * An associative array of settings for the field. Each field has a different set of possible settings. + * Check the register_settings method for the field, found in php/blocks/controls/class-{field name}.php. + * } + * } + */ +function block_lab_add_field( $block_name, $field_name, $field_config = array() ) { + $field_config['name'] = str_replace( '_', '-', sanitize_title( $field_name ) ); + + $default_config = array( + 'label' => str_replace( '-', ' ', ucwords( $field_config['name'], '-' ) ), + 'control' => 'text', + 'order' => 0, + 'settings' => array(), + ); + + $field_config = wp_parse_args( $field_config, $default_config ); + block_lab()->loader->add_field( $block_name, $field_config ); +} diff --git a/tests/php/unit/blocks/test-class-field.php b/tests/php/unit/blocks/test-class-field.php index 3f3c68ea1..1ffd77325 100644 --- a/tests/php/unit/blocks/test-class-field.php +++ b/tests/php/unit/blocks/test-class-field.php @@ -93,6 +93,25 @@ public function test_from_array() { $this->assertAttributeNotEmpty( 'settings', $this->instance->settings['sub_fields']['baz'] ); } + /** + * Test from_array when there is no 'type' in the $config argument. + * + * @covers \Block_Lab\Blocks\Field::from_array() + */ + public function test_from_array_without_type() { + $this->instance->from_array( [ 'control' => 'rich_text' ] ); + $this->assertEquals( 'string', $this->instance->type ); + + $this->instance = new Blocks\Field( array() ); + $this->instance->from_array( [ 'control' => 'post' ] ); + $this->assertEquals( 'object', $this->instance->type ); + + // The control class doesn't exist, so this shouldn't change the default value of $type, 'string'. + $this->instance = new Blocks\Field( array() ); + $this->instance->from_array( [ 'control' => 'non-existent' ] ); + $this->assertEquals( 'string', $this->instance->type ); + } + /** * Test to_array. * diff --git a/tests/php/unit/blocks/test-class-loader.php b/tests/php/unit/blocks/test-class-loader.php index 6b1cdd5e2..25ac06070 100644 --- a/tests/php/unit/blocks/test-class-loader.php +++ b/tests/php/unit/blocks/test-class-loader.php @@ -19,6 +19,24 @@ class Test_Loader extends Abstract_Template { */ public $instance; + /** + * A mock block config without a name. + * + * @var array + */ + public $block_config_without_name = array( + 'foo' => 'Example Value' + ); + + /** + * A mock block config with a name. + * + * @var array + */ + public $block_config_with_name = array( + 'name' => 'Example Block' + ); + /** * Teardown. * @@ -376,6 +394,55 @@ function( $directory ) use ( $overridden_theme_template_path ) { $this->assertContains( $expected_overriden_template_contents, ob_get_clean() ); } + /** + * Test add_block. + * + * @covers \Block_Lab\Blocks\Loader::add_block() + */ + public function test_add_block() { + // The block config does not have a name, so it should not be added to the $blocks property. + $this->instance->add_block( $this->block_config_without_name ); + $this->assertEmpty( $this->get_protected_property( 'blocks' ) ); + + // Now that the block config has a name, it should be added to the $blocks property. + $this->instance->add_block( $this->block_config_with_name ); + $actual_blocks = $this->get_protected_property( 'blocks' ); + $this->assertEquals( + $this->block_config_with_name, + $actual_blocks[ "block-lab/{$this->block_config_with_name['name']}" ] + ); + } + + /** + * Test add_field. + * + * @covers \Block_Lab\Blocks\Loader::add_field() + */ + public function test_add_field() { + $block_name = 'example-block'; + $full_block_name = "block-lab/{$block_name}"; + $field_name = 'baz-field'; + $field_config_with_name = [ 'name' => $field_name ]; + $field_config_without_name = [ 'baz' => 'example' ]; + + // The block does not exist in the $blocks property, so this should not add anything to it. + $this->instance->add_field( $block_name, $field_config_with_name ); + $this->assertEmpty( $this->get_protected_property( 'blocks' ) ); + + // The second argument doesn't have a 'name' value, so this shouldn't add anything to the $blocks property. + $this->instance->add_field( $block_name, $field_config_without_name ); + $this->assertEmpty( $this->get_protected_property( 'blocks' ) ); + + // Now that the block name exists in the $blocks property, this should add the field to it. + $this->set_protected_property( 'blocks', [ $full_block_name => [] ] ); + $this->instance->add_field( $block_name, $field_config_with_name ); + $actual_blocks = $this->get_protected_property( 'blocks' ); + $this->assertEquals( + $field_config_with_name, + $actual_blocks[ $full_block_name ]['fields'][ $field_name ] + ); + } + /** * Gets the full paths of the template CSS files, in order of reverse priority. * diff --git a/tests/php/unit/helpers/class-abstract-template.php b/tests/php/unit/helpers/class-abstract-template.php index 1590009fc..dcd933bcf 100644 --- a/tests/php/unit/helpers/class-abstract-template.php +++ b/tests/php/unit/helpers/class-abstract-template.php @@ -105,7 +105,7 @@ public function invoke_protected_method( $method_name, $args = array() ) { * Gets a protected property's value. * * @param string $property The name of the property to get. - * @return mixed The property value + * @return mixed The property value. * @throws ReflectionException For a non-accessible property. */ public function get_protected_property( $property ) { diff --git a/tests/php/unit/test-helpers.php b/tests/php/unit/test-helpers.php index 7dddcab0f..4bcea15c9 100644 --- a/tests/php/unit/test-helpers.php +++ b/tests/php/unit/test-helpers.php @@ -6,18 +6,23 @@ */ use Block_Lab\Blocks; +use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; /** * Tests for helpers.php. */ class Test_Helpers extends \WP_UnitTestCase { + // Shows the assertions as passing. + use MockeryPHPUnitIntegration; + /** * Teardown. * * @inheritdoc */ public function tearDown() { + block_lab()->loader = new Blocks\Loader(); remove_all_filters( 'block_lab_default_fields' ); remove_all_filters( 'block_lab_data_attributes' ); remove_all_filters( 'block_lab_data_config' ); @@ -149,4 +154,87 @@ function( $default_fields ) use ( $additional_field_name ) { $this->assertEquals( $additional_field_value, $return_value ); $this->assertEquals( $additional_field_value, $echoed_value ); } + + /** + * Test block_lab_add_block. + * + * @covers ::block_lab_add_block() + */ + public function test_block_lab_add_block() { + // Test calling this without the optional second argument. + $block_name = 'example-block'; + $expected_default_config = [ + 'category' => 'common', + 'excluded' => [], + 'fields' => [], + 'icon' => 'block_lab', + 'keywords' => [], + 'name' => $block_name, + 'title' => 'Example Block', + ]; + + $loader = Mockery::mock( Blocks\Loader::class ); + block_lab()->loader = $loader; + $loader->expects()->add_block( $expected_default_config ); + block_lab_add_block( $block_name ); + + // Test passing a $block_config, with a long name. + $block_name = 'this-is-a-long-block-name'; + $block_config = [ + 'category' => 'example', + 'excluded' => [ 'baz', 'another' ], + 'fields' => [ 'text' ], + 'icon' => 'great_icon', + 'keywords' => [ 'hero', 'ad' ], + 'name' => $block_name, + ]; + + $expected_config = array_merge( + $block_config, + [ 'title' => 'This Is A Long Block Name' ] + ); + $loader->expects()->add_block( $expected_config ); + block_lab_add_block( $block_name, $block_config ); + } + + /** + * Test block_lab_add_field. + * + * @covers ::block_lab_add_field() + */ + public function test_block_lab_add_field() { + // Test calling this without the optional third argument. + $block_name = 'baz-block'; + $field_name = 'another-field'; + $expected_default_config = [ + 'control' => 'text', + 'label' => 'Another Field', + 'name' => $field_name, + 'order' => 0, + 'settings' => [], + ]; + + $loader = Mockery::mock( Blocks\Loader::class ); + block_lab()->loader = $loader; + $loader->expects()->add_field( $block_name, $expected_default_config )->once(); + block_lab_add_field( $block_name, $field_name ); + + // Test passing a full $field_config. + $block_name = 'example-block-name-here'; + $field_name = 'here_is_a_long_field_name'; + $field_config = [ + 'control' => 'rich_text', + 'label' => 'Here Is Another Field', + 'order' => 3, + 'settings' => [ 'foo' => 'baz' ], + ]; + + $expected_field_config = array_merge( + $field_config, + [ 'name' => 'here-is-a-long-field-name' ] + ); + + $loader->expects()->add_field( $block_name, $expected_field_config )->once(); + block_lab_add_field( $block_name, $field_name, $field_config ); + } }