10up / wp-content-connect

WordPress library that enables direct relationships for posts to posts and posts to users.
GNU General Public License v3.0
83 stars 21 forks source link

Expose functionality through WP REST API #78

Open fabiankaegy opened 3 months ago

fabiankaegy commented 3 months ago

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:

fabiankaegy commented 3 months ago

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 commented 3 months ago

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