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": {