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,
+};