diff --git a/lib/blocks.php b/lib/blocks.php index c751656881501..0f803aed65e5a 100644 --- a/lib/blocks.php +++ b/lib/blocks.php @@ -42,6 +42,7 @@ function gutenberg_reregister_core_block_types() { 'post-excerpt.php' => 'core/post-excerpt', 'post-featured-image.php' => 'core/post-featured-image', 'post-tags.php' => 'core/post-tags', + 'query.php' => 'core/query', ); $registry = WP_Block_Type_Registry::get_instance(); diff --git a/packages/block-library/src/editor.scss b/packages/block-library/src/editor.scss index 6dc247b6d744a..575d6f551434e 100644 --- a/packages/block-library/src/editor.scss +++ b/packages/block-library/src/editor.scss @@ -30,6 +30,7 @@ @import "./paragraph/editor.scss"; @import "./post-excerpt/editor.scss"; @import "./pullquote/editor.scss"; +@import "./query/editor.scss"; @import "./quote/editor.scss"; @import "./rss/editor.scss"; @import "./search/editor.scss"; diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index 206c705651e9a..a8625d4e01826 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -75,6 +75,7 @@ import * as postDate from './post-date'; import * as postExcerpt from './post-excerpt'; import * as postFeaturedImage from './post-featured-image'; import * as postTags from './post-tags'; +import * as query from './query'; /** * Function to register an individual block. @@ -207,6 +208,7 @@ export const __experimentalRegisterExperimentalCoreBlocks = postExcerpt, postFeaturedImage, postTags, + query, ] : [] ), ].forEach( registerBlock ); diff --git a/packages/block-library/src/query/block.json b/packages/block-library/src/query/block.json new file mode 100644 index 0000000000000..6c04338978baa --- /dev/null +++ b/packages/block-library/src/query/block.json @@ -0,0 +1,45 @@ +{ + "name": "core/query", + "category": "layout", + "attributes": { + "className": { + "type": "string" + }, + "criteria": { + "type": "object", + "default": { + "per_page": 3, + "offset": 0, + "tags": [], + "categories": [], + "author": [], + "specificPosts": [] + } + }, + "blocks": { + "type": "array", + "default": [ + { + "name": "core/post-title", + "innerBlocks": [], + "isValid": true + }, + { + "name": "core/post-date", + "innerBlocks": [], + "isValid": true + }, + { + "name": "core/post-author", + "innerBlocks": [], + "isValid": true + }, + { + "name": "core/post-excerpt", + "innerBlocks": [], + "isValid": true + } + ] + } + } +} diff --git a/packages/block-library/src/query/edit.js b/packages/block-library/src/query/edit.js new file mode 100644 index 0000000000000..ab75484b67adb --- /dev/null +++ b/packages/block-library/src/query/edit.js @@ -0,0 +1,218 @@ +/** + * Internal dependencies + */ +import QueryPanel from './query-panel'; + +/** + * External dependencies + */ +import classNames from 'classnames'; +import { debounce, isUndefined, pickBy } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component, Fragment } from '@wordpress/element'; +import { + BlockList, + BlockEditorProvider, + InspectorControls, + WritingFlow, +} from '@wordpress/block-editor'; +import { cloneBlock } from '@wordpress/blocks'; +import { PanelBody, Placeholder, Spinner } from '@wordpress/components'; +import { compose } from '@wordpress/compose'; +import { EntityProvider } from '@wordpress/core-data'; +import { withSelect } from '@wordpress/data'; + +class Edit extends Component { + constructor( props ) { + super( props ); + this.state = { + editingPost: null, + blocksTree: {}, + }; + + this.debouncedCreateBlockTree = debounce( + this.createBlockTree.bind( this ), + 1000 + ); + } + + componentDidMount() { + this.createBlockTree(); + } + + componentDidUpdate( prevProps ) { + const { query } = this.props; + if ( prevProps.query !== query ) { + this.createBlockTree(); + } + } + + createBlockTree() { + const { editingPost, blocksTree } = this.state; + const { attributes, query } = this.props; + const { blocks } = attributes; + const newBlocksTree = ( query || [] ).reduce( + ( accumulator, post ) => ( { + ...accumulator, + [ post.id ]: + post.id === editingPost + ? blocksTree[ post.id ] + : blocks.map( ( block ) => cloneBlock( block ) ), + } ), + {} + ); + this.setState( { blocksTree: newBlocksTree } ); + } + + updateBlocks( blocks, postId ) { + const { setAttributes } = this.props; + const { blocksTree } = this.state; + const cleanBlocks = blocks.map( ( block ) => cloneBlock( block ) ); + this.setState( + { + blocksTree: { ...( blocksTree || [] ), [ postId ]: blocks }, + editingPost: postId, + }, + () => { + setAttributes( { blocks: cleanBlocks } ); + this.debouncedCreateBlockTree(); + } + ); + } + + render() { + const { + attributes, + className, + postList, + query, + setAttributes, + settings, + } = this.props; + + const { criteria } = attributes; + + const { editingPost, blocksTree } = this.state; + const classes = classNames( + className, + editingPost ? 'is-editing' : '' + ); + + return ( +
+ + + + setAttributes( { criteria: newCriteria } ) + } + /> + + + + { ! query && ( + + + + ) } + { query && ! query.length && ( + + { __( 'Sorry, no posts were found.' ) } + + ) } + { query && + !! query.length && + query.map( ( post ) => { + if ( ! blocksTree[ post.id ] ) return null; + return ( +
+ + + this.updateBlocks( + blocks, + post.id + ) + } + settings={ settings } + > + + + + + +
+ ); + } ) } +
+
+ ); + } +} + +const isSpecificPostModeActive = ( { specificMode, specificPosts } ) => + specificMode && specificPosts && specificPosts.length; + +const queryCriteriaFromAttributes = ( criteria ) => { + const { + per_page: perPage, + author, + categories, + tags, + specificPosts, + } = criteria; + const queryCriteria = pickBy( + isSpecificPostModeActive( criteria ) + ? { + include: specificPosts, + orderby: 'include', + per_page: specificPosts.length, + } + : { + per_page: perPage, + categories, + author, + tags, + }, + ( value ) => ! isUndefined( value ) + ); + return queryCriteria; +}; + +export default compose( + withSelect( ( select, props ) => { + const { attributes } = props; + const { criteria } = attributes; + const queryCriteria = queryCriteriaFromAttributes( criteria ); + + return { + query: select( 'core' ).getEntityRecords( + 'postType', + 'post', + queryCriteria + ), + settings: select( 'core/block-editor' ).getSettings(), + }; + } ) +)( Edit ); diff --git a/packages/block-library/src/query/editor.scss b/packages/block-library/src/query/editor.scss new file mode 100644 index 0000000000000..70a66a126c535 --- /dev/null +++ b/packages/block-library/src/query/editor.scss @@ -0,0 +1,5 @@ +.wp-block-query { + .block-editor-writing-flow__click-redirect { + display: none; + } +} diff --git a/packages/block-library/src/query/index.js b/packages/block-library/src/query/index.js new file mode 100644 index 0000000000000..24cf1db0b90b0 --- /dev/null +++ b/packages/block-library/src/query/index.js @@ -0,0 +1,41 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; +import edit from './edit'; + +const { name } = metadata; +export { metadata, name }; + +export const title = __( 'Query' ); + +/* From https://material.io/tools/icons */ +export const icon = ( + + + + +); + +export const settings = { + title, + icon, + keywords: [], + description: __( 'A collection of posts.' ), + supports: { + html: false, + align: false, + }, + edit, +}; diff --git a/packages/block-library/src/query/index.php b/packages/block-library/src/query/index.php new file mode 100644 index 0000000000000..ef9fd400c5366 --- /dev/null +++ b/packages/block-library/src/query/index.php @@ -0,0 +1,122 @@ + 'render_block_core_query', + ) + ) + ); +} + +/** + * Convert criteria object into args ready for use in WP_Query + * + * @param array $criteria A criteria object. + * + * @return array Return an array of args. + */ +function core_query_attributes_to_critera( $criteria ) { + if ( isset( $criteria[ 'specificMode' ] ) && $criteria[ 'specificMode' ] == 1 ) { + $args = array( + 'post_status' => 'publish', + 'p' => $criteria[ 'singleId' ], + ); + return $args; + } + + $args = array( + 'posts_per_page' => ! empty( $criteria['per_page'] ) ? intval( $criteria['per_page'] ) : 3, + 'post_status' => 'publish', + 'suppress_filters' => false, + 'ignore_sticky_posts' => true, + ); + + if ( ! empty( $criteria['author'] ) ) { + $args['author'] = implode( ",", $criteria['author'] ); + } + if ( ! empty( $criteria['categories'] ) ) { + $args['cat'] = implode( ",", $criteria['categories'] ); + } + if ( ! empty( $criteria['tags'] ) ) { + $args['tag_in'] = intval( $criteria['tags'] ); + } + if ( ! empty( $criteria['search'] ) ) { + $args['s'] = sanitize_text_field( $criteria['search'] ); + } + return $args; +} + +function render_block_core_query( $attributes ) { + $blocks = ! empty( $attributes['blocks'] ) ? $attributes['blocks'] : array(); + $args = core_query_attributes_to_critera( $attributes['criteria'] ); + $posts_to_show = $args['posts_per_page']; + $args['posts_per_page'] = $args['posts_per_page'] + count( Blocks_Query::$displayedPostIds ); + + $query = new WP_Query( $args ); + $posts_shown = 0; + + ob_start(); + ?> +
+ have_posts() ) : ?> + have_posts() && $posts_shown < $posts_to_show) : ?> + the_post(); + $id = get_the_ID(); + if ( in_array( $id, Blocks_Query::$displayedPostIds ) ) { + continue; + } else { + array_push( Blocks_Query::$displayedPostIds, $id ); + $posts_shown++; + } + ?> +
+ $block['name'], + 'innerContent' => array(), + ); + if ( isset( $block['attributes'])) { + $block_data['attrs'] = $block['attributes']; + } + + $allowed_html = wp_kses_allowed_html( 'post' ); + $allowed_html['time'] = array( + 'class' => true, + 'datetime' => true, + ); + + echo wp_kses( render_block( $block_data ), $allowed_html ); + } + ?> +
+ + +
+ { + const { + entityKind, + entityName, + entityToString, + onChange, + setState, + value, + input, + } = props; + + let currentValues = []; + if ( value.length ) { + const serverValues = + select( 'core' ).getEntityRecords( entityKind, entityName, { + per_page: value.length, + include: value.join( ',' ), + } ) || []; + currentValues = value + .map( ( id ) => { + const entity = serverValues.find( ( e ) => e.id === id ); + return entity + ? { value: entityToString( entity ), id: entity.id } + : undefined; + } ) + .filter( ( v ) => !! v ); + } + + const rawSuggestions = + select( 'core' ).getEntityRecords( entityKind, entityName, { + per_page: 100, + search: input, + exclude: value.join( ',' ), + } ) || []; + + const updateValue = ( entityValues ) => { + if ( onChange ) { + const entityIds = entityValues + .map( ( newValue ) => + typeof newValue === 'object' + ? newValue.id + : rawSuggestions.find( + ( entity ) => + entityToString( entity ) === newValue + )?.id + ) + .filter( ( catId ) => typeof catId !== 'undefined' ); + onChange( entityIds ); + } + }; + + return { + ...props, + value: currentValues, + suggestions: rawSuggestions.map( ( c ) => entityToString( c ) ), + onChange: updateValue, + onInputChange: ( newInput ) => setState( { input: newInput } ), + }; + } ) +)( FormTokenField ); + +export default class QueryPanel extends Component { + updateCriteria( newCriteria ) { + const { criteria, onChange } = this.props; + const { + per_page: perPage, + offset, + categories, + tags, + search, + author, + specificMode, + specificPosts, + } = { + ...criteria, + ...newCriteria, + }; + + const sanitizedCriteria = { + per_page: parseInt( perPage ), + offset: parseInt( offset ), + specificMode: !! specificMode, + specificPosts, + }; + + if ( author ) { + sanitizedCriteria.author = author.map( ( n ) => parseInt( n ) ); + } + + if ( categories ) { + sanitizedCriteria.categories = categories.map( ( n ) => + parseInt( n ) + ); + } + + if ( tags ) { + sanitizedCriteria.tags = tags.map( ( n ) => parseInt( n ) ); + } + + if ( search && search.trim().length > 0 ) { + sanitizedCriteria.search = search; + } + + return onChange( sanitizedCriteria ); + } + + render() { + const { criteria } = this.props; + const { + author, + per_page: perPage, + specificMode, + specificPosts, + categories, + tags, + } = criteria; + + return ( + + + this.updateCriteria( { specificMode: newValue } ) + } + label={ __( 'Choose Specific Posts', 'newspack-blocks' ) } + /> + { specificMode && ( + e.title.rendered } + label="Entity Posts" + onChange={ ( newValue ) => + this.updateCriteria( { specificPosts: newValue } ) + } + value={ specificPosts } + /> + ) } + { ! specificMode && ( + + + this.updateCriteria( { per_page: newValue } ) + } + /> + e.name } + label={ __( 'Author', 'newspack-blocks' ) } + onChange={ ( newValue ) => + this.updateCriteria( { author: newValue } ) + } + value={ author } + /> + e.name } + label={ __( 'Category', 'newspack-blocks' ) } + onChange={ ( newValue ) => + this.updateCriteria( { categories: newValue } ) + } + value={ categories } + /> + e.name } + label={ __( 'tags', 'newspack-blocks' ) } + onChange={ ( newVolue ) => + this.updateCriteria( { tags: newVolue } ) + } + value={ tags } + /> + + ) } + + ); + } +} + +QueryPanel.defaultProps = { + criteria: { + per_page: 3, + offset: 0, + specificMode: false, + specificPosts: [], + author: [], + categories: [], + tags: [], + search: '', + }, + onChange: () => null, +};