DEV Community

Cover image for Build a Custom Strapi Plugin with Medium and Dev.to APIs - Part 2
Theodore Kelechukwu Onyejiaku for Strapi

Posted on • Originally published at strapi.io

Build a Custom Strapi Plugin with Medium and Dev.to APIs - Part 2

In the previous part of this tutorial, we learned about how to develop Strapi plugins. We also delved into initializing and customizing a Strapi plugin. Furthermore, we looked at how to create content types for a Strapi plugin and pass data in a Strapi plugin. Finally, we consumed the Dev.to API using our Strapi plugin to publish posts to Dev.

This is the final part, where you will be able to publish posts to Medium, add pagination and search features.

Tutorial Outline

This article is in two parts:

Publish To Medium Using Medium API

With Strapi headless CMS, we can do much more. Let's publish a post to Medium from Strapi CMS!

Server Side: Create Plugin Service and Modify Plugin Controller Method

1. Create a Service for Publishing to Medium
We will create a service that will help us publish posts to Medium. Locate the service file ./src/plugins/content-publisher/server/src/services/service.ts and add a new service method called publishPostToMedium as shown below:

// Path: ./src/plugins/content-publisher/server/src/services/service.ts

// ...other codes

/**
 * Publish Post to Medium
 */
async publishPostToMedium(post: any) {
  try {
    // destructuring the post object
    const { title, content, canonicalUrl, tags, banner } = post.blog;
    // get the blog tags
    const blogTags = tags.map((tag) => tag.blogTag);

    // payload to be sent to medium
    const mediumPayload = {
      title,
      content: `${title}\n![Image banner](${banner})\n${content}`,
      canonicalUrl,
      tags: blogTags,
      contentFormat: "markdown",
    };

    // post
    const response = await axios.post(
      `https://api.medium.com/v1/users/${process.env.MEDIUM_USER_ID}/posts`,
      mediumPayload,
      {
        headers: {
          Authorization: `Bearer ${process.env.MEDIUM_API_TOKEN}`,
          "Content-Type": "application/json",
        },
      },
    );

    // get the medium url
    const mediumUrl = response.data?.data?.url;

    // update the post with the medium link
    await strapi.documents("plugin::content-publisher.post").update({
      documentId: post.documentId,
      data: {
        mediumLink: mediumUrl,
      } as Partial<any>,
    });

    // return the response
    return response.data;
  } catch (error) {
    throw error;
  }
}

Enter fullscreen mode Exit fullscreen mode

πŸ‘‰ See complete code of the snippet above

Similar to publishPostToDevTo, the publishPostToMedium function above does the following:

  • Takes a post object as an argument.
  • Extracts relevant information from the post.blog object, including title, content, canonicalUrl, tags, and banner.
  • Prepares a payload for the Medium API, formatting the content and tags appropriately.
  • Makes a POST request to the Medium API to create a new post.
  • After successfully posting to Medium, it retrieves the URL of the newly created Medium post.
  • Uses Strapi's Document Service API to update the post entry. It calls the update method on the strapi.documents service for the "plugin::content-publisher.post content-type. It provides the documentId of the post to update. In the data object, the mediumLink field is set to the URL of the newly created Medium post.

2. Update publishPostToMedium Controller
Update the plugin controller method publishPostToMedium with the following code:

// Path: ./src/plugins/content-publisher/server/src/controllers/controller.ts

import type { Core } from '@strapi/strapi';

const controller = ({ strapi }: { strapi: Core.Strapi }) => ({
  // get blog entries
  async getBlogs(ctx) {},

  // get posts
  async getPosts(ctx) {
    ctx.body = await strapi.plugin('content-publisher').service('service').getPosts();
  },

  // publish a blog post to dev.to
  async publishPostToDevTo(ctx) {
    ctx.body = await strapi
      .plugin('content-publisher')
      .service('service')
      .publishPostToDevTo(ctx.request.body);
  },

  // publish a blog post to medium
  async publishPostToMedium(ctx) {
    ctx.body = await strapi
      .plugin('content-publisher')
      .service('service')
      .publishPostToMedium(ctx.request.body);
  },

  // search for a post
  async getSearchQuery(ctx) {},

  // delete a post
  async deletePost(ctx) {},

  // get a single post
  async getSinglePost(ctx) {},
});

export default controller;

Enter fullscreen mode Exit fullscreen mode

The publishPostToMedium controller method is now modified to call the publishPostToMedium service we created above.

Now, go ahead and publish a post to Medium.

Publish Post to Medium

Inject a Strapi Plugin to a Collection Type Using Injection Zones

So far, we have only been able to publish a post from the home page of our plugin. In this section, let's demonstrate the use of injection zones in a Strapi plugin.

The injection zone feature allows plugins to add custom UI elements to specific areas of the Strapi admin panel. In this case, our content-publisher plugin will add a component to the right side of the edit view in the Content Manager. This is so we can publish directly from the Blog content manager.

Here is what it should look like:

Injected component.png
Injected component

As shown in the image above, we injected a component to help us publish a blog directly in the content manager.

Step 1: Create a Component for Injection

We will start by creating the component we want to inject into the component manager.

Mind you, we only want this component to be visible on the Blog collection type. So, we will need to use the unstable_useContentManagerContext hook, which is a replacement for the useCMEditViewDataManager. This hook accesses data and functionality related to the Edit View in the Content Manager such as the ID and slug of the content type.

Inside the ./src/plugins/content-publisher/admin/src/components folder, create the file InjectedComponent.tsx and add the following code:

// Path: ./src/plugins/content-publisher/admin/src/components/InjectedComponent.tsx

import React from 'react';
import { Box, Flex, Typography, Divider } from '@strapi/design-system';
import { unstable_useContentManagerContext } from '@strapi/strapi/admin';

import axios from 'axios';
import { useEffect, useState } from 'react';
import PublishButton from './PublishButton';
import MediumIcon from './MediumIcon';
import DevToIcon from './DevToIcon';

export default function InjectedComponent() {
  // get the blog id
  const { slug, id } = unstable_useContentManagerContext();
  const [post, setPost] = useState({
    mediumLink: '',
    devToLink: '',
    blog: null,
  });

  // fetch single post
  const fetchSinglePost = async () => {
    const post = await axios.get(`/content-publisher/single-post?blogId=${id}`);
    setPost(post.data);
  };

  // fetch single post
  useEffect(() => {
    fetchSinglePost();
  }, []);

  // check if the slug is not blog
  if (slug !== 'api::blog.blog') return null;

  return (
    <Box>
      {post ? (
        <>
          <Typography variant="beta" padding={30}>
            Publish to:
          </Typography>
          <Box marginTop={5}>
            <Flex
              gap={{
                large: 2,
              }}
              direction={{
                initial: 'row',
              }}
              alignItems={{
                initial: 'center',
              }}
            >
              <Typography variant="sigma">Medium</Typography>
            </Flex>

            <PublishButton post={post} type="medium" />
            <Divider />
          </Box>

          <Box padding={30}>
            <Divider marginBottom={4} />
            <Flex
              gap={{
                large: 2,
              }}
              direction={{
                initial: 'row',
              }}
              alignItems={{
                initial: 'center',
              }}
            >
              <Typography variant="sigma">Dev.to</Typography>
            </Flex>
            <PublishButton post={post} type="devto" />
          </Box>
        </>
      ) : null}
    </Box>
  );
}

Enter fullscreen mode Exit fullscreen mode

Let's break down the code above:

  • The component uses the hook unstable_useContentManagerContext from Strapi's admin panel which retrieves the id and slug of the blog entry.
  • It fetches data about a single post using the API endpoint /content-publisher/single-post, which we created in the first part of this tutorial series. We will soon create the service and update the controller for this. It sends a request by adding the parameter blogId=${id}. Recall that the id was gotten using the unstable_useContentManagerContext() hook.
  • The component only renders content with the slug as api::blog.blog, which corresponds to Blog collection type.
  • We imported the PublishButton, which accepts the post we fetched as a parameter. This will allow us to be able to publish to Dev and Medium from the Blog collection type.

Step 2: Create Service and Update Controller To Fetch Single Post

Update getSinglePost Controller Method
Do you recall the getSinglePost controller method we created in Part 1 of this tutorial that should be called when we make a GET request to the /single-post endpoint? We will update it with the following code:

// Path: ./src/plugins/content-publisher/server/src/controllers/controller.ts

import type { Core } from '@strapi/strapi';

const controller = ({ strapi }: { strapi: Core.Strapi }) => ({
  // get blog entries
  async getBlogs(ctx) {},

  // get posts
  async getPosts(ctx) {
    ctx.body = await strapi.plugin('content-publisher').service('service').getPosts();
  },

  // publish a blog post to dev.to
  async publishPostToDevTo(ctx) {
    ctx.body = await strapi
      .plugin('content-publisher')
      .service('service')
      .publishPostToDevTo(ctx.request.body);
  },

  // publish a blog post to medium
  async publishPostToMedium(ctx) {
    ctx.body = await strapi
      .plugin('content-publisher')
      .service('service')
      .publishPostToMedium(ctx.request.body);
  },

  // get a single post
  async getSinglePost(ctx) {
    ctx.body = await strapi
      .plugin('content-publisher')
      .service('service')
      .getSinglePost(ctx.request.query);
  },
  // search for a post
  async getSearchQuery(ctx) {},

  // delete a post
  async deletePost(ctx) {},
});

export default controller;

Enter fullscreen mode Exit fullscreen mode

In the code above:

  • We used Strapi's plugin API to access a service method. strapi.plugin('content-publisher') accesses the "content-publisher" plugin.
  • .service('service') retrieves the main service of the plugin.
  • getSinglePost(ctx.request.query) calls the getSinglePost method of the service, which we will create soon, passing in the query parameters from the request. Recall that the query parameter is the blog ID (blogId=${id}) as explained in the injected component above.
  • The result of the service method call is then assigned to ctx.body, which sets the response body for the HTTP request.

Create getSinglePost Service
Inside the the service file, create the getSinglePost service method.

// ./src/plugins/content-publisher/server/src/services/service.ts

// ... other codes

/**
 * FETCH SINGLE Post
 */
async getSinglePost(query: any) {
  // get blogId from query
  const { blogId } = query;
  try {
    // find the post
    const post = await strapi.documents('plugin::content-publisher.post').findFirst({
      populate: {
        blog: {
          populate: ['tags'],
        },
      },
      // filter the post by blogId
      filters: {
        blog: {
          documentId: {
            $eq: blogId,
          },
        },
      },
    });

    // return the post
    return post;
  } catch (error) {
    throw error;
  }
}


// ... other codes
Enter fullscreen mode Exit fullscreen mode

πŸ‘‰ See complete code of the snippet above.

Here is what the code above does:

  • The getSinglePost method takes a query parameter of type any.
  • It extracts the blogId from the query parameter. The main functionality is wrapped in a try-catch block for error handling.
  • strapi.documents('plugin::medium-publisher.post') accesses the Post content-type of the 'content-publisher' plugin.
  • findFirst() is used to find the first document matching the given criteria. It populates the 'blog' field and its 'tags'. It looks for a post where the related blog's documentId equals the provided blogId using the $eq operator. Learn more about filtering.
  • The found post is then returned.
  • If an error occurs, it's caught in the catch block and re-thrown.

Step 1: Register an Injection Zone in Strapi

Inside the ./src/plugins/content-publisher/admin/src/index.ts file, which is the Admin Panel API entry file, register an injection zone:

// ./src/plugins/content-publisher/admin/src/index.ts

// from this
app.registerPlugin({
  id: PLUGIN_ID,
  initializer: Initializer,
  isReady: false,
  name: PLUGIN_ID,
});


// to this
app.registerPlugin({
  id: PLUGIN_ID,
  initializer: Initializer,
  isReady: false,
  name: PLUGIN_ID,
  injectionZones: {
    editView: {
      "right-links": [],
    },
  },
});

Enter fullscreen mode Exit fullscreen mode

We modified the registerPlugin function by declaring an injection zone named 'right-links' in the 'editView' of the Content Manager. It's initially empty (an empty array). This will allow us to inject the component on the right side of the Blog content manager.

Step 2: Inject Component Using bootstrap() Lifecycle

Let's inject the InjectedComponent into the 'right-links' zone of the 'editView' using the bootstrap function.

async bootstrap(app: any) {
  app.getPlugin("content-manager").injectComponent("editView", "right-links", {
    name: "content-publisher",
    Component: () => <InjectedComponent />,
  });
}

Enter fullscreen mode Exit fullscreen mode

This injects the InjectedComponent into the 'right-links' zone of the 'editView'. The InjectedComponent component will now be rendered in the injection zone.

Here is the full code of our modified Admin Panel API entry file:

// Path: ./src/plugins/content-publisher/admin/src/index.ts

import React from 'react';
import { getTranslation } from './utils/getTranslation';
import { PLUGIN_ID } from './pluginId';
import { Initializer } from './components/Initializer';
import { PluginIcon } from './components/PluginIcon';
import InjectedComponent from './components/InjectedComponent';

export default {
  register(app: any) {
    app.addMenuLink({
      to: `plugins/${PLUGIN_ID}`,
      icon: PluginIcon,
      intlLabel: {
        id: `${PLUGIN_ID}.plugin.name`,
        defaultMessage: PLUGIN_ID,
      },
      Component: async () => {
        const { App } = await import('./pages/App');

        return App;
      },
    });

    app.registerPlugin({
      id: PLUGIN_ID,
      initializer: Initializer,
      isReady: false,
      name: PLUGIN_ID,
      injectionZones: {
        editView: {
          'right-links': [],
        },
      },
    });
  },

  async bootstrap(app: any) {
    app.getPlugin('content-manager').injectComponent('editView', 'right-links', {
      name: 'content-publisher',
      Component: () => <InjectedComponent />,
    });
  },

  async registerTrads({ locales }: { locales: string[] }) {
    return Promise.all(
      locales.map(async (locale) => {
        try {
          const { default: data } = await import(`./translations/${locale}.json`);

          return { data, locale };
        } catch {
          return { data: {}, locale };
        }
      })
    );
  },
};

Enter fullscreen mode Exit fullscreen mode

Step 3: Publish content directly from the Content Manager

Now, we can publish content directly from the Blog content manager of the admin panel.

Publish from Injected component

πŸ–οΈ NOTE: If you can't get your component to be displayed. You can follow the instructions below.

Instruction on How to Display Injected Component

  • Try changing the ./src/plugins/content-publisher/server/src/index.ts file to ./src/plugins/content-publisher/server/src/index.tsx - change .ts to .tsx.
  • Correspondingly, modify"source": "./admin/src/index.ts" inside the package.json file of your plugin(./src/plugins/content-publisher/package.json) ... to "source": "./admin/src/index.tsx" - change .ts to .tsx.
// ... other codes
"exports": {
    "./package.json": "./package.json",
    "./strapi-admin": {
      "types": "./dist/admin/src/index.d.ts",
      "source": "./admin/src/index.tsx", // change ts => tsx
      "import": "./dist/admin/index.mjs",
      "require": "./dist/admin/index.js",
      "default": "./dist/admin/index.js"
    },
// ... other codes
Enter fullscreen mode Exit fullscreen mode
  • Build your Strapi application by stopping the development server and running strapi build. Do the same for the plugin.
  • Restart your Strapi dev server with npm run develop. CD into the plugin folder and start your plugin in watch mode with npm run watch.

Add Pagination to Strapi Plugin

Now that we can publish posts to external platforms such as Medium and Dev, what happens when we have so many posts to publish. Let's add pagination in order to solve this issue.

Strapi Plugin Pagination Logic

We want to display only 5 posts per page. So, we will create the following variables:

  • currentPage: Tracks the current page number.
  • pageCount: Total number of pages, calculated based on the total posts and posts per page.
  • postsPerPage: Number of posts to display per page (set to 5).

We will also create the function handleFetchPosts to calculate the start index for the API request based on the currentPage. Then, an API request will be made to /content-publisher/posts with query parameter for pagination: start=${start}.

Lastly, we will render buttons based on the pagination number.

Here is what our pagination should look like:

Plugin Pagination Tabs.png
Plugin Pagination Tabs

Step 1: Update getPosts Controller Method for Pagination

Let's update the getPosts controller method to accept the parameters for pagination.

// ./src/plugins/content-publisher/server/src/controllers/controller.ts

import type { Core } from '@strapi/strapi';

const controller = ({ strapi }: { strapi: Core.Strapi }) => ({
  // get blog entries
  async getBlogs(ctx) {},

  // get posts
  async getPosts(ctx) {
    ctx.body = await strapi
      .plugin('content-publisher')
      .service('service')
      .getPosts(ctx.query);
  },

  // publish a blog post to dev.to
  async publishPostToDevTo(ctx) {
    ctx.body = await strapi
      .plugin('content-publisher')
      .service('service')
      .publishPostToDevTo(ctx.request.body);
  },

  // publish a blog post to medium
  async publishPostToMedium(ctx) {
    ctx.body = await strapi
      .plugin('content-publisher')
      .service('service')
      .publishPostToMedium(ctx.request.body);
  },

  // get a single post
  async getSinglePost(ctx) {
    ctx.body = await strapi
      .plugin('content-publisher')
      .service('service')
      .getSinglePost(ctx.request.query);
  },
  // search for a post
  async getSearchQuery(ctx) {},

  // delete a post
  async deletePost(ctx) {},
});

export default controller;
Enter fullscreen mode Exit fullscreen mode

In the code above, we updated the getPosts controller method to accept query parameters.

Step 2: Update getPosts Service for Pagination

Update the getPosts service method with the code below in order to return paginations.

// ./src/plugins/content-publisher/server/src/services/service.ts

// ... other codes

const postPerPage = 5;

// ... other codes

/**
   * GET Posts with Pagination
   */
  async getPosts(query: any) {
    try {
      // get start from query params
      const { start } = query;

      // get total posts count
      const totalPosts = await strapi.documents('plugin::content-publisher.post').count({});

      // get posts
      const posts = await strapi.documents('plugin::content-publisher.post').findMany({
        populate: {
          blog: {
            populate: ['tags'],
          },
        },
        // return only 5 posts from the start index
        start,
        limit: postPerPage,
      });

      // return the posts and total posts
      return { posts, totalPosts };
    } catch (error) {
      throw error;
    }
  },


// ... other codes
Enter fullscreen mode Exit fullscreen mode

πŸ‘‰ See complete code of the snippet above

We created a constant postPerPage and gave it a value of 5, the number of posts we want to see for a page. We updated the getPosts method.

Here is what the getPosts method does:

  • Takes a query parameter, which we get the start index for pagination.
  • Counts the total number of posts using the count method and assigns the value to totalPosts.
  • Retrieves the posts based on findMany.
  • The findMany takes parameters populate, which populates the blog relation and further populates the tags relation within the blog. It also takes parameters start, the starting index for pagination, and parameter limit, which is the number of posts to return (defined by the postPerPage variable).

Step 3: Update PublishingTable for Pagination

Here, we will update the PublishingTable component to render as a pagination.

Import Pagination Components from Strapi Design System

Locate the ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx file. We will start by importing components for pagination.

  • PageLink for a page we want to visit.
  • previousLink for a previous page.
  • Pagination as parent component for all other components.
// Path: ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx 

import {
  // ... other components

  PageLink,
  Pagination,
  PreviousLink,
  NextLink,
} from '@strapi/design-system';

// ... other codes
Enter fullscreen mode Exit fullscreen mode

Create Pagination Variables

We will create state variables pageCount, currentPage, and postsPerpage as we mentioned earlier in our pagination logic.

// Path: `./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx` 

// ... other codes

const [pageCount, setPageCount] = useState(1);
const [currentPage, setCurrentPage] = useState(1);

const postsPerPage = 5;

// ... other codes
Enter fullscreen mode Exit fullscreen mode

Update handleFetchPosts function
We will update the handleFetchPosts function to make a request with the start query parameter.

/// Path: ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx

// ... other codes

const handleFetchPosts = async (page: number) => {
  // Calculate the start index
  const start = (page - 1) * postsPerPage;
  try {
    const response = await axios.get(
      `/content-publisher/posts?start=${start}`,
    );
    setPosts(response.data.posts);
    setPageCount(Math.ceil(response.data.totalPosts / postsPerPage));
  } catch (error) {
    console.error("Error fetching posts:", error);
  }
};

// .. other codes
Enter fullscreen mode Exit fullscreen mode

Create Function to Handle Page Change
We want a function to handle page changes when we switch between pagination tabs. Create the handlePageChange function below.

// ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx

// ... other codes

const handlePageChange = (e: FormEvent, page: number) => {
  // Prevent the default behavior of the link
  if (page < 1 || page > pageCount) return;
  setCurrentPage(page);

  handleFetchPosts(page);
};

// ... other codes
Enter fullscreen mode Exit fullscreen mode

The handlePage function takes a parameter which is the page number and the event. It prevents the default behaviour of the button in which it will be invoked, sets current page to the page number clicked and then fetches posts.

Render Pagination

// ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx

// ... other codes

{
  posts.length > 0 ? (
    <Pagination activePage={currentPage} pageCount={pageCount}>
      <PreviousLink
        onClick={(e: FormEvent) => handlePageChange(e, currentPage - 1)}
      >
        Go to previous page
      </PreviousLink>
      {Array.from({ length: pageCount }, (_, index) => (
        <PageLink
          key={index}
          number={index + 1}
          onClick={(e: FormEvent) => handlePageChange(e, index + 1)}
        >
          Go to page {index + 1}
        </PageLink>
      ))}
      <NextLink
        onClick={(e: FormEvent) => handlePageChange(e, currentPage + 1)}
      >
        Go to next page
      </NextLink>
    </Pagination>
  ) : null;
}

// ... other codes
Enter fullscreen mode Exit fullscreen mode

Here is what we did in the code above:

  • The <Pagination> component takes two props: activePage and pageCount
  • <PreviousLink> is used to navigate to the previous page. When clicked, it calls handlePageChange with the current page number minus 1.
  • An array of page links is generated using Array.from().
  • For each page, a <PageLink> component is created. It's given a unique key based on the index. The number prop is set to the page number. When clicked, it calls handlePageChange with the corresponding page number.
  • <NextLink> is used to navigate to the next page. When clicked, it calls handlePageChange with the current page number plus 1.

Let's see this in action:

Custom Strapi Plugin With Pagination

Congratulations! We have added pagination. Next, let's add the Search functionality.

πŸ‘‰ See full code of the snippet for the PublishingTable Component above.

Add Search Feature to Plugin

Pagination has now been added to our plugin, but what happens when we want to find a particular post by blog title? For this reason, let's add a search functionality.

Search Feature Logic

We want to implement a search feature to filter posts based on user input. So, we will create the following:

  • searchValue: A variable that tracks the user's search input.
  • handleSearchPost: A function to make an API request to /content-publisher/search with the search term (searchValue) and fetch matching posts. The request is sent using the query parameter: search=${searchValue}. If the search input is empty, the function will exit early to avoid unnecessary API calls.
  • The returned results will update the posts state to display only the matching posts.

Here is what the search bar should look like:

Plugin Search Bar  .png
Plugin Search Bar

Step 1: Update getSearchQuery Controller Method for Search

We will update the getSearchQuery Controller method to accept query parameters.

// Path: ./src/plugins/content-publisher/server/src/controllers/controller.ts

import type { Core } from '@strapi/strapi';

const controller = ({ strapi }: { strapi: Core.Strapi }) => ({
  // get blog entries
  async getBlogs(ctx) {},

  // get posts
  async getPosts(ctx) {
    ctx.body = await strapi.plugin('content-publisher').service('service').getPosts(ctx.query);
  },

  // publish a blog post to dev.to
  async publishPostToDevTo(ctx) {
    ctx.body = await strapi
      .plugin('content-publisher')
      .service('service')
      .publishPostToDevTo(ctx.request.body);
  },

  // publish a blog post to medium
  async publishPostToMedium(ctx) {
    ctx.body = await strapi
      .plugin('content-publisher')
      .service('service')
      .publishPostToMedium(ctx.request.body);
  },

  // get a single post
  async getSinglePost(ctx) {
    ctx.body = await strapi
      .plugin('content-publisher')
      .service('service')
      .getSinglePost(ctx.request.query);
  },

  // search for a post
  async getSearchQuery(ctx) {
    ctx.body = await strapi
      .plugin('content-publisher')
      .service('service')
      .getSearchQuery(ctx.request.query);
  },

  // delete a post
  async deletePost(ctx) {},
});

export default controller;
Enter fullscreen mode Exit fullscreen mode

Step 2: Create a getSearchQuery Service Method for Search Feature

Inside the ./src/plugins/content-publisher/server/src/services/service.ts file, we will create the getSearchQuery method to help us fetch the requested query.

// Path: ./src/plugins/content-publisher/server/src/services/service.ts

// ... other codes

/**
 * Search Query
 */
async getSearchQuery(query: { search: string; start: number }) {
  try {
    // get search and start from query
    const { search, start } = query;

    // find total posts
    const totalPosts = await strapi
      .documents("plugin::medium-publisher.post")
      .findMany({
        filters: {
          blog: { title: { $contains: search } },
        },
      });

    // find posts
    const posts = await strapi
      .documents("plugin::medium-publisher.post")
      .findMany({
        populate: {
          blog: {
            populate: ["tags"],
          },
        },
        // filter only blog titles that contains the search query
        filters: {
          blog: { title: { $contains: search } },
        },
        // return only 5 posts from the start index
        start: start * postPerPage,
        limit: postPerPage,
      });

    // return the posts and total posts
    return { posts, totalPosts: totalPosts.length };
  } catch (error) {
    throw error;
  }
},

// ... other codes
Enter fullscreen mode Exit fullscreen mode

πŸ‘‰ See complete code of the snippet above

Here is what the method above does:

  • Takes a query object as an argument, which contains search (the search term) and start (the starting index for pagination) properties.
  • Uses the $contains filter to find posts where the blog title contains the search term.
  • The second query includes pagination (start and limit) and populates the blog relation along with its tags.
  • The method then returns an object containing the found posts and the total number of posts matching the search criteria.

Step 3: Update PublishingTable Component for the Search Feature

Let's update the PublishingTable component file ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx so as to implement and display the search feature.

Import Search Bar Components
We will import components from the Strapi design system that exists for the search user interface.

// Path: ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx

// ... other codes

import {
  // ... other codes

  SearchForm,
  Searchbar,
}

// ... other codes
Enter fullscreen mode Exit fullscreen mode

In the code above, we imported the SearchForm component which is the form for the search. Also, we imported the Searchbar component which will serves as the input of our search value or key.

Create handleSearchPost Method
Create a method called handleSearchPost, which will handle search requests for posts.

// Path: ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx

// ... other codes

const handleSearchPost = async (event: FormEvent, page: number | null) => {
  event.preventDefault(); // Prevent the default form submission behavior
  let start;
  if (page === null) {
    start = 0;
  } else {
    start = (page - 1) * postsPerPage; // Calculate the start index for the paginated search
  }

  // If the search input is empty, return early
  if (!searchValue.trim()) {
    return;
  }

  try {
    // Make a GET request to the search endpoint
    const response = await axios.get(
      `/content-publisher/search?start=${start}&search=${searchValue}`,
    );

    setPosts(response.data.posts); // Assuming the API returns matching posts
    setPageCount(Math.ceil(response.data.totalPosts / postsPerPage));
  } catch (error) {
    console.error("Error searching posts:", error);
  }
};

// ... other codes
Enter fullscreen mode Exit fullscreen mode

In the code above, the handleSearchPost sends a search query to /content-publisher/search with pagination (start and search query parameters). It updates posts and pageCount based on the API response.

Modify handlePageChange Method to Include Search
Let's modify the handlePageChange method to include the handleSearchPost function.

// Path: ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx

// ... other codes

const handlePageChange = (e: FormEvent, page: number) => {
  if (page < 1 || page > pageCount) return;
  setCurrentPage(page);

  if (searchValue) {
    handleSearchPost(e, page);
  } else {
    handleFetchPosts(page);
  }
};

// ... other codes
Enter fullscreen mode Exit fullscreen mode

The handlePageChange now updates currentPage and fetches data for the selected page. It determines whether to fetch default posts (handleFetchPosts) or search results (handleSearchPost) based on searchValue.

Render Search Bar
Let's render the search bar in the PublishingTable component.

// Path: ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx

// ... other codes

<SearchForm
  onSubmit={(e: FormEvent) => {
    handleSearchPost(e, null);
  }}
>
  <Searchbar
    size="M"
    name="searchbar"
    onClear={() => {
      setSearchValue("");
      handleFetchPosts(1);
    }}
    value={searchValue}
    onChange={(e: any) => setSearchValue(e.target.value)}
    clearLabel="Clearing the plugin search"
    placeholder="e.g: blog title"
  >
    Searching for a plugin
  </Searchbar>
</SearchForm>;

// ... other codes
Enter fullscreen mode Exit fullscreen mode

The code above renders a search form with a search bar to filter posts based on user input. When the form is submitted, it triggers the handleSearchPost function to fetch filtered results. The onClear handler resets the search value and fetches the default posts for page 1, while onChange updates the searchValue state as the user types.

Here is how the search works:

Search Feature

πŸ‘‰ See full code snippet for the PublishingTable Component

Add Delete Feature

What happens when we don't need a particular post entry?

For this reason, we want to be able to delete a post. So, let's implement a delete feature. All we need to do is send a DELETE request with the post ID as a query parameter to the /delete-post endpoint.

Update deletePost Controller Method

In the controller file ./src/plugins/content-publisher/server/src/controllers/controller.ts, update the deletePost controller method to accept query parameters:

// Path: ./src/plugins/content-publisher/server/src/controllers/controller.ts


import type { Core } from '@strapi/strapi';

const controller = ({ strapi }: { strapi: Core.Strapi }) => ({
  // get blog entries
  async getBlogs(ctx) {},

  // get posts
  async getPosts(ctx) {
    ctx.body = await strapi.plugin('content-publisher').service('service').getPosts(ctx.query);
  },

  // publish a blog post to dev.to
  async publishPostToDevTo(ctx) {
    ctx.body = await strapi
      .plugin('content-publisher')
      .service('service')
      .publishPostToDevTo(ctx.request.body);
  },

  // publish a blog post to medium
  async publishPostToMedium(ctx) {
    ctx.body = await strapi
      .plugin('content-publisher')
      .service('service')
      .publishPostToMedium(ctx.request.body);
  },

  // get a single post
  async getSinglePost(ctx) {
    ctx.body = await strapi
      .plugin('content-publisher')
      .service('service')
      .getSinglePost(ctx.request.query);
  },

  // search for a post
  async getSearchQuery(ctx) {
    ctx.body = await strapi
      .plugin('content-publisher')
      .service('service')
      .getSearchQuery(ctx.request.query);
  },

  // delete a post
  async deletePost(ctx) {
    ctx.body = await strapi
      .plugin('content-publisher')
      .service('service')
      .deletePost(ctx.request.query);
  },
});

export default controller;

Enter fullscreen mode Exit fullscreen mode

Create a Service For Deleting a Post

Inside the ./src/plugins/content-publisher/server/src/services/service.ts, create a service to delete a post.

// Path: ./src/plugins/content-publisher/server/src/services/service.ts

// ... other codes

/**
 * DELETE Post
 */
async deletePost(query: { postId: string }) {
  try {
    // get postId from query
    const { postId } = query;

    // delete the post
    await strapi
      .documents("content::medium-publisher.post")
      .delete({ documentId: postId });

    return "Post deleted";
  } catch (error) {
    console.error("Error deleting post:", error);
    throw error;
  }
},

// ... other codes
Enter fullscreen mode Exit fullscreen mode

πŸ‘‰ See complete code of the snippet above

In the deletePost function above, we did the following:

  • Takes a query parameter with a postId property of type string.
  • Inside a try-catch block, it extracts the postId from the query object using destructuring.
  • It then uses Strapi's Document Service API to delete the post.

Update PublishingTable for Deletion of Posts

Inside the PublishingTable component, we will have to create a method to handle the deletion of posts. Create the method below:


// Path: ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx


// ... other codes


const handleDeletePost = async (id: string) => {
  const response: Response = await axios.delete(
    `/content-publisher/delete-post?postId=${id}`,
    {},
  );
  handleFetchPosts(1)
};

// ... other codes
Enter fullscreen mode Exit fullscreen mode

Now, add the onClick event listener to the Trash icon to call the handleDeletePost function when it is clicked.

// Path: ./src/plugins/content-publisher/admin/src/components/PublishingTable.tsx

// ... other codes

<Trash
  onClick={() => {
    handleDeletePost(post.documentId);
  }}
  style={{ cursor: "pointer", color: "red" }}
  width={20}
  height={20}
/>;


// ... other codes
Enter fullscreen mode Exit fullscreen mode

Here is what deleting a post looks like:

Delete a Post

πŸ‘‰ See code to full code of the snippet above for the PublishingTable component

What's Next?

Congratulations on building your first Strapi CMS plugin. Here are some suggestions on how to improve the Content Publisher plugin.

  • Implement batch publishing and deletion.
  • Add toast notifications when actions are performed.
  • Handle errors properly and display them to users.
  • Add other external platforms of your choice.

We can't wait to see you build amazing plugins.

Github Project Repo

You can find the complete project for this tutorial in this Github repo.

In the repo, you will find branches part-1 and part-2. They both represent the complete code for each part of this tutorial. The main branch will hold future changes to this project.

Conclusion

In this final part of the Strapi plugin tutorial, we have learnt how to publish posts to Medium, add pagination, implement search functionality, and use injection zones in the Strapi admin panel and more.

What are you waiting for? Let's start building plugins!

Aside from building amazing plugins, Strapi headless CMS offers a range of features tailored to meet your business needs. Try Strapi today.

Strapi Open Office Hours

If you have any questions about Strapi 5 or just would like to stop by and say hi, you can join us at Strapi's Discord Open Office Hours Monday through Friday at 12:30 pm - 1:30 pm CST: Strapi Discord Open Office Hours

Strapi 5 is available now! Start building today!

Top comments (0)