diff --git a/.eslintrc b/.eslintrc
new file mode 100644
index 0000000..d3ad2ab
--- /dev/null
+++ b/.eslintrc
@@ -0,0 +1,5 @@
+{
+ "extends": [
+ "@10up/eslint-config/wordpress"
+ ]
+}
diff --git a/assets/js/block-extensions/core-query.js b/assets/js/block-extensions/core-query.js
new file mode 100644
index 0000000..a2de124
--- /dev/null
+++ b/assets/js/block-extensions/core-query.js
@@ -0,0 +1,273 @@
+/* eslint-disable @wordpress/no-unsafe-wp-apis */
+/* eslint-disable import/no-extraneous-dependencies */
+/* eslint-disable import/extensions */
+/**
+ * External dependencies
+ */
+import { v4 as uuidv4 } from 'uuid';
+import { registerBlockExtension, ContentPicker } from '@10up/block-components';
+
+/**
+ * WordPress dependencies
+ */
+import {
+ ToggleControl,
+ Notice,
+ SelectControl,
+ BaseControl,
+ __experimentalToolsPanel as ToolsPanel,
+ __experimentalToolsPanelItem as ToolsPanelItem,
+} from '@wordpress/components';
+import { InspectorControls } from '@wordpress/block-editor';
+import { useSelect } from '@wordpress/data';
+import { __ } from '@wordpress/i18n';
+import { store as coreStore } from '@wordpress/core-data';
+import { store as editorStore } from '@wordpress/editor';
+import { useMemo, useEffect, useRef } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { store } from '../store';
+
+const BlockEdit = ({ setAttributes, attributes }) => {
+ const { query, showRelated, relationshipKey, sourcePost, orderByRelationship } = attributes;
+ const { postType: queriedPostType } = query;
+
+ const { postTypes, relationships, hasRelationships, currentPostId, currentPostType } =
+ useSelect(
+ (select) => {
+ const { getPostTypes } = select(coreStore);
+ const excludedPostTypes = ['attachment'];
+ const filteredPostTypes = getPostTypes({ per_page: -1 })?.filter(
+ ({ viewable, slug }) => viewable && !excludedPostTypes.includes(slug),
+ );
+
+ const currentPostId = select(editorStore).getCurrentPostId();
+ const currentPostType = select(editorStore).getCurrentPostType();
+
+ const postRelationships = select(store).getRelationships(
+ sourcePost?.[0]?.id || currentPostId,
+ );
+ const filteredRelationships = Object.values(postRelationships).filter(
+ (relationship) =>
+ Array.isArray(relationship.post_type) &&
+ relationship.post_type.includes(queriedPostType),
+ );
+
+ return {
+ postTypes: filteredPostTypes,
+ relationships: filteredRelationships,
+ hasRelationships: filteredRelationships.length > 0,
+ currentPostId,
+ currentPostType,
+ };
+ },
+ [queriedPostType, sourcePost?.length],
+ );
+
+ const postTypesSlugs = useMemo(() => (postTypes || []).map(({ slug }) => slug), [postTypes]);
+
+ const relationshipsOptions = useMemo(
+ () =>
+ relationships.map((relationship) => ({
+ value: relationship.rel_key,
+ label: relationship.labels.name,
+ })),
+ [relationships],
+ );
+
+ const selectedRelationshipKey = useMemo(
+ () =>
+ relationshipKey && relationships.some((rel) => rel.rel_key === relationshipKey)
+ ? relationshipKey
+ : relationships[0]?.rel_key,
+ [relationshipKey, relationships],
+ );
+
+ const selectedRelationship = useMemo(
+ () =>
+ relationships.find((relationship) => relationship.rel_key === selectedRelationshipKey),
+ [relationships, selectedRelationshipKey],
+ );
+
+ const lastQueryRef = useRef(query);
+
+ const updatedQuery = useMemo(() => {
+ if (!showRelated || !selectedRelationship) {
+ const { relationshipQuery, orderByRelationship, ...cleanQuery } = query;
+ return cleanQuery;
+ }
+
+ return {
+ ...query,
+ orderByRelationship: orderByRelationship && selectedRelationship.sortable,
+ relationshipQuery: [
+ {
+ name: selectedRelationship.rel_name,
+ related_to_post: sourcePost?.[0]?.id || currentPostId,
+ },
+ ],
+ };
+ }, [query, showRelated, orderByRelationship, selectedRelationship, sourcePost, currentPostId]);
+
+ useEffect(() => {
+ if (JSON.stringify(lastQueryRef.current) !== JSON.stringify(updatedQuery)) {
+ setAttributes({ query: updatedQuery });
+ lastQueryRef.current = updatedQuery;
+ }
+ }, [updatedQuery, setAttributes]);
+
+ const relationshipsControlLabel = __('Relationship', 'tenup-content-connect');
+ const relationshipsControlHelp = __(
+ 'Select a relationship to determine how related entities are retrieved.',
+ 'tenup-content-connect',
+ );
+ const sourcePostControlHelp = __(
+ 'Choose the post from which related entities will be pulled. Defaults to the current post.',
+ 'tenup-content-connect',
+ );
+
+ const resetAll = () => {
+ setAttributes({
+ showRelated: false,
+ sourcePost: undefined,
+ relationshipKey: undefined,
+ orderByRelationship: true,
+ });
+ };
+
+ const onShowRelatedChange = (value) => {
+ if (!value) {
+ resetAll();
+ } else {
+ setAttributes({
+ showRelated: value,
+ sourcePost: [
+ {
+ id: currentPostId,
+ type: currentPostType,
+ uuid: uuidv4(),
+ },
+ ],
+ });
+ }
+ };
+
+ const onSourcePostChange = (ids) => {
+ setAttributes({ sourcePost: ids.length ? ids : undefined });
+ };
+
+ const onRelationshipChange = (value) => {
+ setAttributes({ relationshipKey: value });
+ };
+
+ const onOrderByRelationshipChange = (value) => {
+ setAttributes({ orderByRelationship: value });
+ };
+
+ return (
+
+
+ !!showRelated}
+ label={__('Related entities', 'tenup-content-connect')}
+ onDeselect={() => resetAll()}
+ isShownByDefault
+ >
+
+
+ {showRelated && (
+ !!sourcePost}
+ label={__('Source post', 'tenup-content-connect')}
+ onDeselect={() => setAttributes({ sourcePost: undefined })}
+ isShownByDefault
+ >
+
+
+
+
+ )}
+ {showRelated && (
+ !!relationshipKey}
+ label={__('Relationship', 'tenup-content-connect')}
+ onDeselect={() => setAttributes({ relationshipKey: undefined })}
+ isShownByDefault
+ >
+ {hasRelationships && (
+
+ )}
+ {!hasRelationships && (
+
+ {__(
+ 'No relationships exist for the selected post type or post. Try selecting a different post or post type.',
+ 'tenup-content-connect',
+ )}
+
+ )}
+
+ )}
+ {showRelated && selectedRelationship?.sortable && (
+ !!orderByRelationship}
+ label={__('Order by relationship', 'tenup-content-connect')}
+ onDeselect={() => setAttributes({ orderByRelationship: undefined })}
+ >
+
+
+ )}
+
+
+ );
+};
+
+registerBlockExtension('core/query', {
+ extensionName: 'content-connect',
+ attributes: {
+ showRelated: {
+ type: 'boolean',
+ default: false,
+ },
+ sourcePost: {
+ type: 'array',
+ },
+ relationshipKey: {
+ type: 'string',
+ },
+ orderByRelationship: {
+ type: 'boolean',
+ default: true,
+ },
+ },
+ classNameGenerator: () => '',
+ Edit: BlockEdit,
+});
diff --git a/assets/js/block-extensions/index.js b/assets/js/block-extensions/index.js
new file mode 100644
index 0000000..3d4461d
--- /dev/null
+++ b/assets/js/block-extensions/index.js
@@ -0,0 +1 @@
+import './core-query';
diff --git a/assets/js/components/relationships-panel.tsx b/assets/js/components/relationships-panel.tsx
index 2185016..ed7e7d2 100644
--- a/assets/js/components/relationships-panel.tsx
+++ b/assets/js/components/relationships-panel.tsx
@@ -1,7 +1,6 @@
import React from 'react';
import { useSelect } from '@wordpress/data';
-import { store as editorStore } from '@wordpress/editor';
-import { PluginDocumentSettingPanel } from '@wordpress/edit-post';
+import { store as editorStore, PluginDocumentSettingPanel } from '@wordpress/editor';
import { store } from '../store';
import { RelationshipManager } from './relationship-manager';
diff --git a/assets/js/index.ts b/assets/js/index.ts
index 902b1dd..a1d087e 100644
--- a/assets/js/index.ts
+++ b/assets/js/index.ts
@@ -1,5 +1,6 @@
import './store';
import './hooks';
+import './block-extensions';
import { registerPlugin } from '@wordpress/plugins';
import { RelationshipsPanel } from './components/relationships-panel';
diff --git a/assets/js/store/index.ts b/assets/js/store/index.ts
index 60113c4..5dab1c2 100644
--- a/assets/js/store/index.ts
+++ b/assets/js/store/index.ts
@@ -1,4 +1,5 @@
import { createReduxStore, register, select, dispatch, createSelector } from '@wordpress/data';
+import { store as editorStore } from '@wordpress/editor';
import { addFilter } from '@wordpress/hooks';
import * as api from './api';
import { ContentConnectRelatedEntities, ContentConnectRelationships, ContentConnectState } from './types';
@@ -88,6 +89,13 @@ const actions = {
* @returns The action to mark the post as dirty.
*/
markPostAsDirty(postId: number): MarkPostAsDirtyAction {
+ // Trigger the block editor to mark the post as dirty.
+ dispatch(editorStore).editPost({
+ meta: {
+ _content_connect_edit_lock: Date.now()
+ }
+ });
+
return {
type: 'MARK_POST_AS_DIRTY',
postId,
@@ -124,19 +132,7 @@ const actions = {
const key = getRelatedEntitiesKey(postId, relKey);
- const relatedIds = entities.map(entity => {
- return typeof entity.id === 'string' ? parseInt(entity.id, 10) : entity.id;
- });
-
dispatch.setRelatedEntities(key, entities);
-
- await api.updateRelatedEntities(
- postId,
- relKey,
- relType,
- relatedIds
- );
-
dispatch.markPostAsDirty(postId);
};
},
@@ -183,7 +179,7 @@ export const store = createReduxStore(STORE_NAME, {
},
actions,
selectors: {
- getRelationships(state: ContentConnectState, postId: number | null, options?: api.GetRelationshipsOptions) {
+ getRelationships(state: ContentConnectState, postId: number | null, options?: api.GetRelationshipsOptions): Record {
if (postId === null) {
return {};
}
@@ -234,17 +230,21 @@ async function persistContentConnectionChanges() {
// Update each relationship for the post
await Promise.all(
- Object.entries(relationships).map(async ([relKey, relType]) => {
+ (Object.values(relationships) as Array<{ rel_key: string; rel_type: string }>).map(async (relationship) => {
const relatedEntities = select(STORE_NAME).getRelatedEntities(postId, {
- rel_key: relKey,
- rel_type: relType,
+ rel_key: relationship.rel_key,
+ rel_type: relationship.rel_type,
});
+ const relatedIds = relatedEntities.map(entity =>
+ typeof entity.id === 'string' ? parseInt(entity.id, 10) : entity.id
+ );
+
await api.updateRelatedEntities(
postId,
- relKey,
- relType as string,
- relatedEntities.map(post => post.id),
+ relationship.rel_key,
+ relationship.rel_type,
+ relatedIds
);
})
);
@@ -252,7 +252,7 @@ async function persistContentConnectionChanges() {
);
// Clear dirty entities after successful save
- dispatch(STORE_NAME).clearDirtyEntities();
+ (dispatch(STORE_NAME) as any).clearDirtyEntities();
}
// Add the pre-save hook to persist changes
diff --git a/includes/Plugin.php b/includes/Plugin.php
index e7420dc..80753b0 100644
--- a/includes/Plugin.php
+++ b/includes/Plugin.php
@@ -3,6 +3,7 @@
namespace TenUp\ContentConnect;
use TenUp\ContentConnect\API;
+use TenUp\ContentConnect\QueryIntegration\QueryBlockIntegration;
use TenUp\ContentConnect\QueryIntegration\UserQueryIntegration;
use TenUp\ContentConnect\QueryIntegration\WPQueryIntegration;
use TenUp\ContentConnect\Relationships\DeletedItems;
@@ -11,54 +12,33 @@
use TenUp\ContentConnect\UI\BlockEditor;
use TenUp\ContentConnect\UI\ClassicEditor;
+/**
+ * Class Plugin
+ *
+ * @package TenUp\ContentConnect
+ */
class Plugin {
/**
+ * The tables for the plugin.
+ *
* @var array
*/
public $tables = array();
/**
+ * The registry instance.
+ *
* @var Registry
*/
public $registry;
- /**
- * @var WPQueryIntegration
- */
- public $wp_query_integration;
-
- /**
- * @var UserQueryIntegration
- */
- public $user_query_integration;
-
- /**
- * @var MetaBox
- */
- public $meta_box;
-
- /**
- * @var BlockEditor
- */
- public $block_editor;
-
- /**
- * @var Search
- */
- public $search;
-
- /**
- * @var DeletedItems
- */
- public $deleted_items;
-
/**
* The single instance of the class.
*
* @var Plugin
*/
- private static $instance;
+ protected static $instance;
/**
* Get class instance.
@@ -73,11 +53,23 @@ public static function instance() {
return self::$instance;
}
+ /**
+ * Retrieves the registry instance.
+ *
+ * @return Registry
+ */
public function get_registry() {
return $this->registry;
}
+ /**
+ * Retrieves a table.
+ *
+ * @param string $table The table to retrieve.
+ * @return PostToPost|PostToUser|bool
+ */
public function get_table( $table ) {
+
if ( isset( $this->tables[ $table ] ) ) {
return $this->tables[ $table ];
}
@@ -85,6 +77,11 @@ public function get_table( $table ) {
return false;
}
+ /**
+ * Sets up the plugin.
+ *
+ * @return void
+ */
public function setup() {
$this->define_constants();
$this->register_tables();
@@ -95,6 +92,7 @@ public function setup() {
$modules = array(
new WPQueryIntegration(),
new UserQueryIntegration(),
+ new QueryBlockIntegration(),
new ClassicEditor(),
new BlockEditor(),
new DeletedItems(),
@@ -106,7 +104,10 @@ public function setup() {
);
foreach ( $modules as $module ) {
- $module->setup();
+
+ if ( method_exists( $module, 'setup' ) ) {
+ $module->setup();
+ }
}
add_action( 'init', array( $this, 'init' ), 100 );
diff --git a/includes/QueryIntegration/QueryBlockIntegration.php b/includes/QueryIntegration/QueryBlockIntegration.php
new file mode 100644
index 0000000..40f1a40
--- /dev/null
+++ b/includes/QueryIntegration/QueryBlockIntegration.php
@@ -0,0 +1,149 @@
+ true ) );
+ $excluded_post_types = array( 'attachment' );
+
+ foreach ( $post_types as $post_type ) {
+
+ if ( in_array( $post_type, $excluded_post_types, true ) ) {
+ continue;
+ }
+
+ add_filter( "rest_{$post_type}_query", array( $this, 'rest_post_query' ), 10, 2 );
+ }
+ }
+
+ /**
+ * Modifies the REST API query to support relationship-based filtering and ordering.
+ *
+ * @since 1.7.0
+ *
+ * @param array $args Array of arguments for \WP_Query.
+ * @param array $request The REST API request.
+ * @return array Modified query arguments.
+ */
+ public function rest_post_query( $args, $request ) {
+
+ if ( isset( $request['relationshipQuery'] ) ) {
+ $args['relationship_query'] = $request['relationshipQuery'];
+ }
+
+ $order_by_relationship = rest_sanitize_boolean( $request['orderByRelationship'] ?? false );
+
+ if ( ! empty( $order_by_relationship ) ) {
+ $args['orderby'] = 'relationship';
+ }
+
+ return $args;
+ }
+
+ /**
+ * Modifies the query loop arguments when the block is rendered on the front end.
+ *
+ * @since 1.7.0
+ *
+ * @param string $block_content The block content.
+ * @param array $block The block object.
+ * @return string
+ */
+ public function modify_query_loop_query( $block_content, $block ) {
+
+ if ( ! $this->is_query_block( $block ) ) {
+ return $block_content;
+ }
+
+ $this->parsed_block = $block;
+
+ add_filter( 'query_loop_block_query_vars', array( $this, 'get_query_by_attributes_once' ) );
+
+ return $block_content;
+ }
+
+ /**
+ * Applies custom query modifications based on block attributes, then removes itself.
+ *
+ * @since 1.7.0
+ *
+ * @param array $query_args Array containing parameters for `WP_Query`.
+ * @return array
+ */
+ public function get_query_by_attributes_once( $query_args ) {
+ if ( has_filter( 'query_loop_block_query_vars', array( $this, 'get_query_by_attributes_once' ) ) ) {
+ remove_filter( 'query_loop_block_query_vars', array( $this, 'get_query_by_attributes_once' ) );
+ }
+
+ return $this->get_query_by_attributes( $query_args, $this->parsed_block );
+ }
+
+ /**
+ * Generates a modified query based on the block attributes.
+ *
+ * @since 1.7.0
+ *
+ * @param array $query_args Array containing parameters for `WP_Query`.
+ * @param array $block The block being rendered.
+ * @return array
+ */
+ public function get_query_by_attributes( $query_args, $block ) {
+
+ if ( ! $this->is_query_block( $block ) ) {
+ return $query_args;
+ }
+
+ $query_attrs = $block['attrs']['query'] ?? [];
+
+ if ( ! empty( $query_attrs['relationshipQuery'] ) ) {
+ $query_args['relationship_query'] = $query_attrs['relationshipQuery'];
+ }
+
+ if ( ! empty( $query_attrs['orderByRelationship'] ) ) {
+ $query_args['orderby'] = 'relationship';
+ }
+
+ return $query_args;
+ }
+
+ /**
+ * Determines if a given block is a Query Loop block.
+ *
+ * @since 1.7.0
+ *
+ * @param array $block The block object.
+ * @return bool
+ */
+ public function is_query_block( $block ) {
+ return ! empty( $block['blockName'] ) && 'core/query' === $block['blockName'];
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index a13d784..1f9258c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,8 @@
"license": "GPL-3.0-or-later",
"dependencies": {
"@10up/block-components": "^1.20.1",
- "10up-toolkit": "^6.4.0"
+ "10up-toolkit": "^6.4.0",
+ "uuid": "^11.1.0"
},
"devDependencies": {
"@wordpress/api-fetch": "^7.18.0",
@@ -19222,6 +19223,14 @@
"websocket-driver": "^0.7.4"
}
},
+ "node_modules/sockjs/node_modules/uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
"node_modules/source-map": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
@@ -20749,11 +20758,15 @@
}
},
"node_modules/uuid": {
- "version": "8.3.2",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
- "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
+ "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
"bin": {
- "uuid": "dist/bin/uuid"
+ "uuid": "dist/esm/bin/uuid"
}
},
"node_modules/v8-to-istanbul": {
diff --git a/package.json b/package.json
index 3d3b6c9..0b5166f 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,8 @@
},
"dependencies": {
"@10up/block-components": "^1.20.1",
- "10up-toolkit": "^6.4.0"
+ "10up-toolkit": "^6.4.0",
+ "uuid": "^11.1.0"
},
"10up-toolkit": {
"entry": {