Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose functionality through WP REST API #78

Open
5 tasks
fabiankaegy opened this issue Jun 19, 2024 · 2 comments
Open
5 tasks

Expose functionality through WP REST API #78

fabiankaegy opened this issue Jun 19, 2024 · 2 comments
Assignees
Milestone

Comments

@fabiankaegy
Copy link
Member

In order to make the plugin work with a native Block Editor UI as described in #45 all the actions need to be accessible through the REST API.

That means:

  • Getting all the connections that are available for the current post type
  • Getting the posts that are currently connected to an entity
  • Setting the posts that are currently connected to an entity
  • Adding a new connected post
  • Removing a connected post
@fabiankaegy
Copy link
Member Author

Not for self:
Figure out how WP makes it possible to use the data API to update some values but only actually persist them when the user saves the post.

In an ideal case this would also be how this eventually works.

I think the rest endpoints have nothing to do with that though. That all happens on the redux layer (@wordpress/data)

@fabiankaegy
Copy link
Member Author

My naive implementation of some rest endpoints looks like this:

add_action( 'rest_api_init', __NAMESPACE__ . '\register_rest_routes' ), 10, 0 );

/**
 * Register Rest Routes.
 *
 * @return void
 */
function register_rest_routes() {
	register_rest_route(
		'content-connect/v1',
		'(?P<post_id>\d+)?',
		[
			'methods'  => 'GET',
			'callback' => __NAMESPACE__ . '\get_connected_posts',
			'args'     => [
				'post_id' => [
					'validate_callback' => function ( $param ) {
						return is_numeric( $param );
					},
					'required'          => true,
				],
			],
		]
	);

	register_rest_route(
		'content-connect/v1',
		'(?P<post_id>\d+)?',
		[
			'methods'             => 'POST',
			'callback'            => __NAMESPACE__ . '\update_connected_posts',
			'permission_callback' => function () {
				return current_user_can( 'edit_posts' );
			},
		]
	);
}

/**
 * Get connected posts.
 *
 * @param \WP_REST_Request $request The request object.
 * @return \WP_REST_Response
 */
function get_connected_posts( $request ) {
	$current_post_id = $request->get_param( 'post_id' );
	$connection_name = $request->get_param( 'name' );
	$post_type_a     = $request->get_param( 'post_type_a' );
	$post_type_b     = $request->get_param( 'post_type_b' );

	$registry   = \TenUp\ContentConnect\Plugin::instance()->get_registry();
	$connection = $registry->get_post_to_post_relationship( $post_type_a, $post_type_b, $connection_name );

	if ( ! $connection ) {
		return new \WP_Error( 'invalid_connection', 'Invalid connection', array( 'status' => 404 ) );
	}

	$current_post_type = get_post_type( $current_post_id );

	if ( $current_post_type !== $post_type_a && $current_post_type !== $post_type_b ) {
		return new \WP_Error( 'invalid_post_type', 'Invalid post type', array( 'status' => 404 ) );
	}

	$post_type_to_query = $post_type_a === $current_post_type ? $post_type_b : $post_type_a;

	$connected_posts_query = new \WP_Query(
		[
			'post_type'          => $post_type_to_query,
			'posts_per_page'     => 99,
			'fields'             => 'ids',
			'relationship_query' => [
				[
					'related_to_post' => $current_post_id,
					'name'            => $connection_name,
				],
			],
			'orderby'            => 'relationship',
		]
	);

	$connected_posts = $connected_posts_query->posts;
	return new \WP_REST_Response( $connected_posts, 200 );
}

/**
 * Update connected posts.
 *
 * @param \WP_REST_Request $request The request object.
 * @return \WP_REST_Response
 */
function update_connected_posts( $request ) {
	$current_post_id     = $request->get_param( 'post_id' );
	$connection_name     = $request->get_param( 'name' );
	$post_type_a         = $request->get_param( 'post_type_a' );
	$post_type_b         = $request->get_param( 'post_type_b' );
	$new_connected_posts = $request->get_param( 'new_connected_posts' ) ?? [];

	$registry   = \TenUp\ContentConnect\Plugin::instance()->get_registry();
	$connection = $registry->get_post_to_post_relationship( $post_type_a, $post_type_b, $connection_name );

	if ( ! $connection ) {
		return new \WP_Error( 'invalid_connection', 'Invalid connection', array( 'status' => 404 ) );
	}

	$connection->replace_relationships( $current_post_id, $new_connected_posts );

	$current_post_type = get_post_type( $current_post_id );

	if ( $current_post_type !== $post_type_a && $current_post_type !== $post_type_b ) {
		return new \WP_Error( 'invalid_post_type', 'Invalid post type', array( 'status' => 404 ) );
	}

	$post_type_to_query = $post_type_a === $current_post_type ? $post_type_b : $post_type_a;

	$connected_posts_query = new \WP_Query(
		[
			'post_type'          => $post_type_to_query,
			'posts_per_page'     => 99,
			'fields'             => 'ids',
			'relationship_query' => [
				[
					'related_to_post' => $current_post_id,
					'name'            => $connection_name,
				],
			],
			'orderby'            => 'relationship',
		]
	);

	$connected_posts = $connected_posts_query->posts;
	return new \WP_REST_Response( $connected_posts, 200 );
}

Paired with a WP Data store that handles the logic in the editor:

import apiFetch from '@wordpress/api-fetch';
import { createReduxStore, register, useSelect, select, dispatch } from '@wordpress/data';
import { useState, useEffect } from '@wordpress/element';
import { addQueryArgs } from '@wordpress/url';
import { store as editorStore } from '@wordpress/editor';
import { registerPlugin } from '@wordpress/plugins';

/**
 * Store defaults
 */
const DEFAULT_STATE = {
	connections: {},
};

const CONTENT_CONNECT_ENDPOINT = '/content-connect/v1';

const actions = {
	setConnectedPosts(postId, connectionName, postTypeA, postTypeB, connectedPosts) {
		return {
			type: 'SET_CONNECTED_POSTS',
			postId,
			connectionName,
			postTypeA,
			postTypeB,
			connectedPosts,
		};
	},
	*saveConnectedPosts(postId, connectionName, postTypeA, postTypeB, newConnectedPosts) {
		const path = addQueryArgs(`${CONTENT_CONNECT_ENDPOINT}/${postId}`, {
			name: connectionName,
			post_type_a: postTypeA,
			post_type_b: postTypeB,
			new_connected_posts: newConnectedPosts,
		});

		yield actions.apiRequest(path, 'POST');
		// eslint-disable-next-line no-use-before-define
		dispatch(store).invalidateResolutionForStoreSelector(
			'getConnectedPosts',
			postId,
			connectionName,
			postTypeA,
			postTypeB,
		);

		return {
			type: 'SAVE_CONNECTED_POSTS',
		};
	},
	apiRequest(path, method = 'GET') {
		return {
			type: 'API_REQUEST',
			path,
			method,
		};
	},
};

export const store = createReduxStore('tenup/content-connect', {
	reducer(state = DEFAULT_STATE, action = '') {
		switch (action.type) {
			case 'SET_CONNECTED_POSTS':
				return {
					...state,
					connections: {
						...state.connections,
						[`${action.postTypeA}_${action.postTypeB}_${action.connectionName}_${action.postId}`]:
							action.connectedPosts,
					},
				};
			case 'SAVE_CONNECTED_POSTS':
				return state;
			default:
				break;
		}

		return state;
	},
	actions,
	selectors: {
		getConnections(state) {
			return state.connections;
		},
		getConnectedPosts(state, postId, connectionName, postTypeA, postTypeB) {
			const { connections } = state;
			const connectionKey = `${postTypeA}_${postTypeB}_${connectionName}_${postId}`;

			if (connections[connectionKey]) {
				return JSON.parse(JSON.stringify(connections[connectionKey]));
			}
			return [];
		},
	},
	controls: {
		API_REQUEST(action) {
			return apiFetch({ path: action.path, method: action.method });
		},
	},
	resolvers: {
		*getConnectedPosts(postId, connectionName, postTypeA, postTypeB) {
			if (!postId) {
				return actions.setConnectedPosts({});
			}

			const path = addQueryArgs(`${CONTENT_CONNECT_ENDPOINT}/${postId}`, {
				name: connectionName,
				post_type_a: postTypeA,
				post_type_b: postTypeB,
			});
			const connectedPosts = yield actions.apiRequest(path);

			return actions.setConnectedPosts(
				postId,
				connectionName,
				postTypeA,
				postTypeB,
				connectedPosts,
			);
		},
	},
});

register(store);

registerPlugin('tenup-content-connect', {
	render: function SaveContentConnect() {
		const { isSavingPost } = useSelect((select) => {
			return {
				isSavingPost: select(editorStore).isSavingPost(),
			};
		}, []);
		const [isSaving, setIsSaving] = useState(false);

		if (isSavingPost && !isSaving) {
			setIsSaving(true);
		} else if (!isSavingPost && isSaving) {
			setIsSaving(false);
		}

		useEffect(() => {
			if (isSaving) {
				const connections = select(store).getConnections();
				Object.keys(connections).forEach((connectionKey) => {
					const [postTypeA, postTypeB, connectionName, postId] = connectionKey.split('_');
					const connectedPostIds = connections[connectionKey] || [];
					dispatch(store).saveConnectedPosts(
						postId,
						connectionName,
						postTypeA,
						postTypeB,
						connectedPostIds,
					);
				});
			}
		}, [isSaving]);

		return null;
	},
});

To make it easier to work with I then created this custom hook:

import { useSelect, useDispatch } from '@wordpress/data';
import { useCallback } from '@wordpress/element';
import { store as contentConnectStore } from '../stores/content-connect';

export function useContentConnection(postId, connectionName, postTypeA, postTypeB) {
	const { connectedPosts, hasResolved } = useSelect(
		(select) => {
			return {
				connectedPosts: select(contentConnectStore).getConnectedPosts(
					postId,
					connectionName,
					postTypeA,
					postTypeB,
				),
				hasResolved: select(contentConnectStore).hasFinishedResolution(
					'getConnectedPosts',
					[postId, connectionName, postTypeA, postTypeB],
				),
			};
		},
		[postId, connectionName, postTypeA, postTypeB],
	);

	const { setConnectedPosts } = useDispatch(contentConnectStore);

	const setNewConnectedPosts = useCallback(
		(newConnectedPosts) => {
			setConnectedPosts(postId, connectionName, postTypeA, postTypeB, newConnectedPosts);
		},
		[postId, setConnectedPosts, connectionName, postTypeA, postTypeB],
	);

	return {
		connectedPosts,
		hasResolvedConnectedPosts: hasResolved,
		setConnectedPosts: setNewConnectedPosts,
	};
}

Which then again can be used in more semantic hooks:

import { useContentConnection } from './use-content-connection';

export function useResourceAuthors(postId) {
	const {
		connectedPosts: authors,
		hasResolvedConnectedPosts: hasResolvedAuthors,
		setConnectedPosts: setAuthors,
	} = useContentConnection(postId, 'post-author', 'post', 'clinician');

	return {
		authors,
		hasResolvedAuthors,
		setAuthors,
	};
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants