diff --git a/.changeset/ten-dolphins-smash.md b/.changeset/ten-dolphins-smash.md new file mode 100644 index 00000000..d79fd735 --- /dev/null +++ b/.changeset/ten-dolphins-smash.md @@ -0,0 +1,7 @@ +--- +"@wpengine/wp-graphql-content-blocks": major +--- + +Feature: Add support for querying array type query data from blocks + +Query source block attribute types are supported. See: https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/#query-source diff --git a/includes/Blocks/Block.php b/includes/Blocks/Block.php index e9d8fb11..18f2bb95 100644 --- a/includes/Blocks/Block.php +++ b/includes/Blocks/Block.php @@ -81,13 +81,14 @@ private function register_block_type() { * Registers the block attributes GraphQL type and adds it as a field on the Block. */ private function register_block_attributes_as_fields(): void { + // Grab any additional block attributes attached into the class itself if ( isset( $this->additional_block_attributes ) ) { - $block_attributes = ! empty( $this->block_attributes ) ? array_merge( $this->block_attributes, $this->additional_block_attributes ) : $this->additional_block_attributes; - $block_attribute_fields = $this->get_block_attribute_fields( $block_attributes ); + $block_attributes = ! empty( $this->block_attributes ) ? array_merge( $this->block_attributes, $this->additional_block_attributes ) : $this->additional_block_attributes; } else { - $block_attribute_fields = $this->get_block_attribute_fields( $this->block_attributes ); + $block_attributes = $this->block_attributes; } - + $block_attribute_fields = $this->get_block_attribute_fields( $block_attributes, $this->type_name . 'Attributes' ); + // For each attribute, register a new object type and attach it to the block type as a field if ( ! empty( $block_attribute_fields ) ) { $block_attribute_type_name = $this->type_name . 'Attributes'; register_graphql_object_type( @@ -122,65 +123,194 @@ private function register_block_attributes_as_fields(): void { } /** - * Gets the WPGraphQL field registration config for the block attributes. - * - * @param ?array $block_attributes The block attributes. + * Returns the type of the block attribute + * + * @param string $name The block name + * @param object $attribute The block attribute config + * @param string $prefix Current prefix string to use for the get_query_type + * + * @return mixed */ - private function get_block_attribute_fields( ?array $block_attributes ): array { - $block_attribute_fields = []; - - // Bail early if no attributes are defined. - if ( null === $block_attributes ) { - return $block_attribute_fields; - } - - foreach ( $block_attributes as $attribute_name => $attribute_config ) { - $graphql_type = null; + private function get_attribute_type( $name, $attribute, $prefix ) { + $type = null; - if ( ! isset( $attribute_config['type'] ) ) { - return $block_attribute_fields; - } - - switch ( $attribute_config['type'] ) { + if ( isset( $attribute['type'] ) ) { + switch ( $attribute['type'] ) { case 'string': - $graphql_type = 'String'; + $type = 'String'; + break; + case 'boolean': + $type = 'Boolean'; break; case 'number': - $graphql_type = 'Float'; + $type = 'Float'; break; case 'integer': - $graphql_type = 'Int'; - break; - case 'boolean': - $graphql_type = 'Boolean'; + $type = 'Int'; break; case 'array': + if ( isset( $attribute['query'] ) ) { + $type = [ 'list_of' => $this->get_query_type( $name, $attribute['query'], $prefix ) ]; + } elseif ( isset( $attribute['items'] ) ) { + $of_type = $this->get_attribute_type( $name, $attribute['items'], $prefix ); + + if ( null !== $of_type ) { + $type = [ 'list_of' => $of_type ]; + } else { + $type = Scalar::get_block_attributes_array_type_name(); + } + } else { + $type = Scalar::get_block_attributes_array_type_name(); + } + break; case 'object': - $graphql_type = Scalar::get_block_attributes_object_type_name(); + $type = Scalar::get_block_attributes_object_type_name(); break; } + } elseif ( isset( $attribute['source'] ) ) { + $type = 'String'; + } + + if ( null !== $type ) { + $default_value = $attribute['default'] ?? null; + + if ( isset( $default_value ) ) { + $type = [ 'non_null' => $type ]; + } + } + + return $type; + } + + /** + * Gets the WPGraphQL field registration config for the block attributes. + * + * @param ?array $block_attributes The block attributes. + * @param string|null $prefix The current prefix string to use for the get_query_type + * + * @return array + */ + private function get_block_attribute_fields( ?array $block_attributes, $prefix = '' ): array { + $fields = []; + + // Bail early if no attributes are defined. + if ( null === $block_attributes ) { + return $fields; + } + + foreach ( $block_attributes as $attribute_name => $attribute_config ) { + $graphql_type = self::get_attribute_type( $attribute_name, $attribute_config, $prefix ); - // Skip if there's no valid type. if ( empty( $graphql_type ) ) { continue; } - // Create the field config. - $block_attribute_fields[ Utils::format_field_name( $attribute_name ) ] = [ + $fields[ Utils::format_field_name( $attribute_name ) ] = [ 'type' => $graphql_type, 'description' => sprintf( // translators: %1$s is the attribute name, %2$s is the block name. - __( 'The "%1$s" field on the "%2$s" block', 'wp-graphql-content-blocks' ), + __( 'The "%1$s" field on the "%2$s" block or block attributes', 'wp-graphql-content-blocks' ), $attribute_name, - $this->type_name + $prefix ), 'resolve' => function ( $block ) use ( $attribute_name, $attribute_config ) { - return $this->resolve_block_attributes( $block, $attribute_name, $attribute_config ); + $config = [ + $attribute_name => $attribute_config, + ]; + $result = $this->resolve_block_attributes_recursive( $block['attrs'], wp_unslash( render_block( $block ) ), $config ); + + return $result[ $attribute_name ]; }, ]; }//end foreach - return $block_attribute_fields; + return $fields; + } + + /** + * Returns the type of the block query attribute + * + * @param string $name The block name + * @param array $query The block query config + * @param string $prefix The current prefix string to use for registering the new query attribute type + */ + private function get_query_type( string $name, array $query, string $prefix ): string { + $type = $prefix . ucfirst( $name ); + + $fields = $this->create_attributes_fields( $query, $type ); + + register_graphql_object_type( + $type, + [ + 'fields' => $fields, + 'description' => sprintf( + // translators: %1$s is the attribute name, %2$s is the block attributes field. + __( 'The "%1$s" field on the "%2$s" block attribute field', 'wp-graphql-content-blocks' ), + $type, + $prefix + ), + ] + ); + + return $type; + } + + /** + * Creates the new attribute fields for query types + * + * @param array $attributes The query attributes config + * @param string $prefix The current prefix string to use for registering the new query attribute type + * + * @return array + */ + private function create_attributes_fields( $attributes, $prefix ): array { + $fields = []; + foreach ( $attributes as $name => $attribute ) { + $type = $this->get_attribute_type( $name, $attribute, $prefix ); + + if ( isset( $type ) ) { + $default_value = $attribute['default'] ?? null; + + $fields[ Utils::format_field_name( $name ) ] = [ + 'type' => $type, + 'description' => sprintf( + // translators: %1$s is the attribute name, %2$s is the block attributes field. + __( 'The "%1$s" field on the "%2$s" block attribute field', 'wp-graphql-content-blocks' ), + $name, + $prefix + ), + 'resolve' => function ( $attributes ) use ( $name, $default_value ) { + $value = $attributes[ $name ] ?? $default_value; + return $this->normalize_attribute_value( $value, $attributes['__type'][ $name ]['type'] ); + }, + ]; + } + } + + return $fields; + } + + /** + * Normalizes the value of the attribute + * + * @param array|string $value The value + * @param string $type The type of the value + * + * @return mixed + */ + private function normalize_attribute_value( $value, $type ) { + switch ( $type ) { + case 'string': + return (string) $value; + case 'number': + return (float) $value; + case 'boolean': + return (bool) $value; + case 'integer': + return (int) $value; + default: + return $value; + } } /** @@ -202,7 +332,7 @@ private function get_block_attributes_interfaces(): array { } /** - * Register the Type for the block + * Register the Type for the block. This happens after all other object types are already registered. */ private function register_type(): void { /** @@ -237,72 +367,95 @@ private function resolve( $block ) { } /** - * Returns the necessary block data to resolve the block attributes. - * - * @param array $block The block data passed to the resolver. - * @param string $attribute_name The name of the attribute to resolve. - * @param array $attribute_config The config for the attribute. + * Resolved the value of the block attributes based on the specified config + * + * @param array $attributes The block current attributes value + * @param string $html The block rendered html + * @param array $config The block current attribute configuration + * + * @return array */ - private function resolve_block_attributes( $block, $attribute_name, $attribute_config ) { - // Get default value. - $default = isset( $attribute_config['default'] ) ? $attribute_config['default'] : null; - // Case when only source defined: Classic Blocks - if ( isset( $attribute_config['source'] ) && ! isset( $attribute_config['selector'] ) ) { - $rendered_block = wp_unslash( render_block( $block ) ); - $value = null; - if ( empty( $rendered_block ) ) { - return $value; - } - switch ( $attribute_config['source'] ) { + private function resolve_block_attributes_recursive( $attributes, $html, $config ): array { + $result = []; + foreach ( $config as $key => $value ) { + // Get default value. + $default = $value['default'] ?? null; + $source = $value['source'] ?? null; + switch ( $source ) { case 'html': - $value = $rendered_block; + if ( ! isset( $value['selector'] ) ) { + $result[ $key ] = $this->parse_single_source( $html, $source ); + } else { + $result[ $key ] = DOMHelpers::parseHTML( $html, $value['selector'] ); + + if ( isset( $value['multiline'] ) && ! empty( $result[ $key ] ) ) { + $result[ $key ] = DOMHelpers::getElementsFromHTML( $result[ $key ], $value['multiline'] ); + } + } break; - } - return $value; - } - // Case when both selector and source are defined - if ( isset( $attribute_config['selector'], $attribute_config['source'] ) ) { - $rendered_block = wp_unslash( render_block( $block ) ); - $value = null; - if ( empty( $rendered_block ) ) { - return $value; - } - - switch ( $attribute_config['source'] ) { case 'attribute': - $value = DOMHelpers::parseAttribute( $rendered_block, $attribute_config['selector'], $attribute_config['attribute'], $default ); - break; - case 'html': - $value = DOMHelpers::parseHTML( $rendered_block, $attribute_config['selector'], $default ); - - if ( isset( $attribute_config['multiline'] ) && ! empty( $value ) ) { - $value = DOMHelpers::getElementsFromHTML( $value, $attribute_config['multiline'] ); - } - + $result[ $key ] = DOMHelpers::parseAttribute( $html, $value['selector'], $value['attribute'], $value ); break; case 'text': - $value = DOMHelpers::getTextFromSelector( $rendered_block, $attribute_config['selector'], $default ); - + $result[ $key ] = DOMHelpers::parseText( $html, $value['selector'] ); break; - }//end switch + case 'query': + $temp = []; + foreach ( DOMHelpers::findNodes( $html, $value['selector'] ) as $source_node ) { + foreach ( $value['query'] as $q_key => $q_value ) { + $temp_config = [ + $q_key => $q_value, + ]; + $res = $this->resolve_block_attributes_recursive( $attributes, $source_node->html(), $temp_config ); + $temp[ $q_key ] = $res[ $q_key ]; + } + $result[ $key ][] = $temp; + } + break; + } // Post processing of return value based on configured type - switch ( $attribute_config['type'] ) { + switch ( $value['type'] ) { case 'integer': - $value = intval( $value ); + $result[ $key ] = intval( $result[ $key ] ); break; case 'boolean': - // If the value is empty or false return - if ( is_null( $value ) || false === $value ) { + if ( false === $result[ $key ] ) { break; } - // Otherwise it's truthy - $value = true; + if ( is_null( $result[ $key ] ) ) { + $result[ $key ] = false; + break; + } + $result[ $key ] = true; break; } + + if ( empty( $result[ $key ] ) ) { + $result[ $key ] = $attributes[ $key ] ?? $default; + } + } + + return $result; + } + /** + * Parses the block content of a source only block type + * + * @param string $html The html value + * @param string $source The source type + * + * @return string|null + */ + private function parse_single_source( $html, $source ) { + $value = null; + if ( empty( $html ) ) { return $value; - }//end if - - return $block['attrs'][ $attribute_name ] ?? $default; + } + switch ( $source ) { + case 'html': + $value = DOMHelpers::findNodes( $html )->innerHTML(); + break; + } + return $value; } } diff --git a/includes/Data/ContentBlocksResolver.php b/includes/Data/ContentBlocksResolver.php index 2990db14..ab8b1850 100644 --- a/includes/Data/ContentBlocksResolver.php +++ b/includes/Data/ContentBlocksResolver.php @@ -84,7 +84,7 @@ static function ( $parsed_block ) { ARRAY_FILTER_USE_BOTH ); - // 1st Level assigning of unique id's and missing blockNames + // 2nd Level assigning of unique id's and missing blockNames $parsed_blocks = array_map( static function ( $parsed_block ) { $parsed_block['clientId'] = uniqid(); diff --git a/includes/Type/Scalar/Scalar.php b/includes/Type/Scalar/Scalar.php index 0851e2ab..010a5cf1 100644 --- a/includes/Type/Scalar/Scalar.php +++ b/includes/Type/Scalar/Scalar.php @@ -24,6 +24,15 @@ public function init(): void { }, ] ); + register_graphql_scalar( + 'BlockAttributesArray', + [ + 'description' => __( 'Generic Array Scalar Type', 'wp-graphql-content-blocks' ), + 'serialize' => static function ( $value ) { + return wp_json_encode( $value ); + }, + ] + ); } /** @@ -32,4 +41,11 @@ public function init(): void { public static function get_block_attributes_object_type_name(): string { return 'BlockAttributesObject'; } + + /** + * Return type name of BlockAttributesArray. + */ + public static function get_block_attributes_array_type_name(): string { + return 'BlockAttributesArray'; + } } diff --git a/includes/Utilities/DomHelpers.php b/includes/Utilities/DomHelpers.php index 9a0d2c7e..8eda5e55 100644 --- a/includes/Utilities/DomHelpers.php +++ b/includes/Utilities/DomHelpers.php @@ -29,6 +29,10 @@ public static function parseAttribute( $html, $selector, $attribute, $default_va if ( '*' === $selector ) { $selector = '*[' . $attribute . ']'; } + + if ( empty( $selector ) ) { + $selector = '*'; + } $node = $doc->find( $selector ); $default_value = isset( $default_value ) ? $default_value : null; return ( ! empty( $node ) && isset( $node[0] ) ) ? $node[0]->getAttribute( $attribute ) : $default_value; @@ -112,7 +116,7 @@ public static function getElementsFromHTML( $html, $selector ) { * * @return string|null The text content of the selector if found. */ - public static function getTextFromSelector( $html, $selector ) { + public static function parseText( $html, $selector ) { $doc = new Document(); $doc->loadHTML( $html ); $nodes = $doc->find( $selector ); @@ -125,4 +129,26 @@ public static function getTextFromSelector( $html, $selector ) { // https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent return $nodes[0]->text(); } + + /** + * Parses the html into DOMElement and searches the DOM tree for a given XPath expression or CSS selector. + * + * @param string $html The HTML string to parse. + * @param string|null $selector The selector to use. + * + * @return \DOMElement[]|\DOMElement + */ + public static function findNodes( $html, $selector = null ) { + // Bail early if there's no html to parse. + if ( empty( trim( $html ) ) ) { + return null; + } + $doc = new Document( $html ); + // $html + $elem = $doc->find( '*' )[2]; + if ( $selector ) { + $elem = $doc->find( $selector ); + } + return $elem; + } } diff --git a/tests/unit/blocks/CoreTableTest.php b/tests/unit/blocks/CoreTableTest.php new file mode 100644 index 00000000..68776340 --- /dev/null +++ b/tests/unit/blocks/CoreTableTest.php @@ -0,0 +1,72 @@ +post_id = wp_insert_post( + array( + 'post_title' => 'Post Title', + 'post_content' => preg_replace( + '/\s+/', + ' ', + trim( + ' + +
+ +
Header 1Header 2
Footer 1Footer 2
+
Caption
+ + ' + ) + ), + 'post_status' => 'publish', + ) + ); + } + + public function tearDown(): void { + parent::tearDown(); + wp_delete_post( $this->post_id, true ); + } + + public function test_retrieve_core_table_attribute_fields() { + $query = ' + fragment CoreTableBlockFragment on CoreTable { + attributes { + caption + align + anchor + } + } + + query GetPosts { + posts(first: 1) { + nodes { + editorBlocks { + name + ...CoreTableBlockFragment + } + } + } + } + '; + $actual = graphql( array( 'query' => $query ) ); + $node = $actual['data']['posts']['nodes'][0]; + $this->assertEquals( $node['editorBlocks'][0]['name'], 'core/table' ); + // There should be only one block using that query when not using flat: true + $this->assertEquals( count( $node['editorBlocks'] ), 1 ); + $this->assertEquals( $node['editorBlocks'][0]['attributes'], [ + 'caption' => "Caption", + 'align' => null, + 'anchor' => null + ]); + } +} diff --git a/tests/unit/blocks/CoreVideoTest.php b/tests/unit/blocks/CoreVideoTest.php index 96401174..c98acbc4 100644 --- a/tests/unit/blocks/CoreVideoTest.php +++ b/tests/unit/blocks/CoreVideoTest.php @@ -80,16 +80,16 @@ public function test_retrieve_core_video_attributes() { 'align' => null, 'anchor' => null, 'autoplay' => true, - 'tracks' => '[]', - 'muted' => false, - 'caption' => '', + 'tracks' => [], + 'muted' => null, + 'caption' => null, 'preload' => 'auto', 'src' => 'http://mysite.local/wp-content/uploads/2023/07/pexels_videos_1860684-1440p.mp4', 'playsInline' => true, - 'controls' => null, + 'controls' => true, 'loop' => true, 'poster' => 'http://mysite.local/wp-content/uploads/2023/05/pexels-egor-komarov-14420089-scaled.jpg', 'id' => 1636.0, - ]); + ]); } }