DEV Community

Cover image for Custom Soft Delete with Strapi and NextJs 14: Building a Recycle Bin
Theodore Kelechukwu Onyejiaku
Theodore Kelechukwu Onyejiaku

Posted on • Originally published at strapi.io

Custom Soft Delete with Strapi and NextJs 14: Building a Recycle Bin

Custom Soft Delete with Strapi and NextJs 14: Building a Recycle Bin

Soft deletion is a crucial technique to prevent data from being permanently erased in a database. Both MySQL and PostgreSQL offer support for this functionality. In this tutorial, we're going to walk you through how to craft a custom soft delete capability using Strapi.js. Additionally, we'll dive into integrating a user interface utilizing the newly released Next.Js 14 App Router. . By the conclusion of this guide, you'll have developed a fully operational system for organizing articles, equipped with its own recycle bin feature, similar to the one shown below.

Soft_delete_gif.gif

Prerequisites

The following are required to follow along with this tutorial:

  • NodeJs installation on our local machine.
  • A Good understanding of Strapi - get started with this quick guide.
  • Basic knowledge of Next.js App Router paradigm and React.js.
  • Basic understanding of Tailwind CSS.

What is Soft Delete?

Soft deletion is an approach where data or records are not permanently removed from a database upon deletion. Unlike immediate and irreversible removal, soft deletion entails marking records as inactive, hidden, or logically deleted. This method allows for potential recovery or restoration.

Here are some key reasons to consider the soft delete strategy:

  • Data Integrity: Soft deletion helps in preserving data integrity by ensuring that records are retainable by users. This offers protection against accidental deletions and potential data loss.
  • Recovery and Auditing: It enables users to retrieve deleted records for auditing and other purposes.
  • User Experience: This approach enhances user experience by providing a safety net; users can inadvertently delete crucial data and effortlessly restore it with minimal hassle.
  • Adoption: Many contemporary databases support soft deletion, making it a viable and popular option.

Implementing Soft Delete

We will enhance our soft delete functionality by adding two fields to our collection: deleted and deleted_at. The deleted field will be a boolean, indicating whether a record has been soft deleted. While this field is sufficient for basic soft delete logic, the addition of deleted_at, a date field, serves a crucial purpose.

The deleted_at field is particularly useful for scenarios where there's a need to clear the recycle bin or trash periodically, such as after 30 days. To automate this process, we will set up a cron job in Strapi, which we will explore in more detail later in this tutorial.

Create the Article Collection

After having creating a Strapi project that we will call my-project in this tutorial, proceed to create a collection named Article with these specific fields and their respective types:

  • Name: A text field. It must be set as required and unique, as it represents the title of the article.
  • Content: This should be a long text field, used for the main content of the article.
  • Deleted_at: A date type field, indicating the timestamp when an article is soft-deleted by a user.
  • Deleted: A boolean field, signaling whether the article has been soft-deleted.

These fields are designed to facilitate effective management and tracking of articles, particularly in implementing soft deletion functionality.

Collection in Strapi

Customize the Article Controller

A controller contains methods or actions that are executed when a request is made to a specific route, such as the /api/articles route in our case.

We need to develop controllers for various operations: performing a soft delete, executing a permanent delete, retrieving articles from the recycle bin, emptying the bin, recovering an article, finding articles, and fetching only those articles that have not been soft deleted.

Soft Delete Action

Replace the code inside the src/api/controllers/article.js file with the following code:


// path: ./src/api/controllers/article.js
// @ts-nocheck
"use strict";
/**
 * article controller
 */
const { createCoreController } = require("@strapi/strapi").factories;
const moment = require("moment");
module.exports = createCoreController("api::article.article", ({ strapi }) => ({
  async softDelete(ctx) {
    const { id } = ctx.params;
    // get the article
    const article = await strapi.service("api::article.article").findOne(id);
    // if article doesn't exist
    if (!article) {
      return ctx.notFound("Article does not exist", {
        details: "This article was not found. Check article id please.",
      });
    }
    // get current date
    const currentDate = moment(Date.now()).format("YYYY-MM-DD");
    // update article
    const entity = await strapi.service("api::article.article").update(id, {
      data: {
        deleted: true,
        deleted_at: currentDate,
      },
    });
    const sanitizedEntity = await this.sanitizeOutput(entity, ctx);
    return this.transformResponse(sanitizedEntity);
  },
}));
Enter fullscreen mode Exit fullscreen mode

In the code above, we created a custom action called softDelete() . This action retrieves an article by its id. It gets the current date and updates the article by setting its deleted field to a true value and deleted_at to the current date. Finally, it returns the sanitized article as a response.

Install the moment library by running the command npm i moment in your terminal.

Permanent Delete Action

This action will allow a user to delete an article when they don’t wish to have it anymore in the recycle bin or trash. Inside the src/api/controllers/article.js file, add the following action after the softDelete() action.


// path: ./src/api/controllers/article.js
async permanentDelete(ctx) {
  const { id } = ctx.params;
  // get article
  const article = await strapi.service("api::article.article").findOne(id);
  // if no article
  if (!article) {
    const sanitizedEntity = await this.sanitizeOutput(article, ctx);
    return this.transformResponse(sanitizedEntity);
  }
  //permanently delete article
  const entity = await strapi.service("api::article.article").delete(id);
  const sanitizedEntity = await this.sanitizeOutput(entity, ctx);
  return this.transformResponse(sanitizedEntity);
}
Enter fullscreen mode Exit fullscreen mode

In the code above, the action permanentDelete() retrieves an article and deletes it permanently from the Article collection.

Get Bin Action

This action should fetch and return all the articles that have been soft deleted. Inside the src/api/controllers/article.js file, add the following action after the permanentDelete() action.


// path: ./src/api/controllers/article.js
async getBin(ctx) {
  const bin = await strapi.entityService.findMany("api::article.article", {
    filters: {
      deleted: true,
    },
  });

  const sanitizedEntity = await this.sanitizeOutput(bin, ctx);
  return this.transformResponse(sanitizedEntity);
}
Enter fullscreen mode Exit fullscreen mode

Using the entityService.findMany() function, we can get all articles and filter those whose deleted fields are true. It will return all articles that have been soft deleted.

Recover Action

This action allows a user to recover an article in the recycle bin. Inside the src/api/controllers/article.js file, add the following action after the getBin() action.


// path: ./src/api/controllers/article.js
async recover(ctx) {
  const { id } = ctx.params;
  const article = await strapi.service("api::article.article").findOne(id);

  if (!article) {
    const sanitizedEntity = await this.sanitizeOutput(article, ctx);
    return this.transformResponse(sanitizedEntity);
  }

  // update article
  const entity = await strapi.service("api::article.article").update(id, {
    data: {
      deleted: false,
      deleted_at: null,
    },
  });

  const sanitizedEntity = await this.sanitizeOutput(entity, ctx);
  return this.transformResponse(sanitizedEntity);
}
Enter fullscreen mode Exit fullscreen mode

In the code above, we retrieved an article and modified its records by setting its deleted field to false and deleted_at to null.

Empty Trash Action

A user can decide to empty their trash or recycle bin. This function will delete all records whose deleted field is true. That way, the system will permanently delete all soft-deleted articles simultaneously. Inside the src/api/controllers/article.js file, add the following action after the recover() action.


// path: ./src/api/controllers/article.js
async emptyTrash(ctx) {
  const softDeletedArticles = await strapi.db
    .query("api::article.article")
    .deleteMany({
      where: {
        deleted: true,
      },
    });

  const sanitizedEntity = await this.sanitizeOutput(result, ctx);

  return this.transformResponse(sanitizedEntity);
}
Enter fullscreen mode Exit fullscreen mode

In the code above, we created the emptyTrash() action. Using the deleteMany() function of the database query, we retrieved all articles that had been soft deleted and finally deleted them from the Article collection of our database.

Replace The find() Action

When a user searches for all articles, what should be returned are articles that haven’t been soft deleted. If we don’t modify this action, it will return any article, whether soft deleted or not.
Inside the src/api/controllers/article.js file, add the following action after the emptyTrash() action.


// path: ./src/api/controllers/article.js
function find(ctx) {
  const articles = await strapi.entityService.findMany("api::article.article", {
    filters: {
      deleted: false,
    },
  });
  const sanitizedEntity = await this.sanitizeOutput(articles, ctx);

  return this.transformResponse(sanitizedEntity);
}
Enter fullscreen mode Exit fullscreen mode

In the code above, we made sure that when articles are requested, it is those that haven’t been soft deleted that the server can return.

Replace The findOne() Action

Just as a user should see only articles that have not been soft deleted when they request all articles, they should only see an article that has not been soft deleted.
Inside the src/api/controllers/article.js file, add the following action after the find() action.


// path: ./src/api/controllers/article.js
function findOne(ctx) {
  const { id } = ctx.params;
  const article = await strapi.service("api::article.article").findOne(id);

  if (!article || article.deleted) {
    article = null;
  }

  const sanitizedEntity = await this.sanitizeOutput(article, ctx);
  return this.transformResponse(sanitizedEntity);
}
Enter fullscreen mode Exit fullscreen mode

In the code above, we replace the original findOne() action by allowing it to return only an article that hasn’t been soft deleted.

Create Custom Routers

We already have the articles router in the src/api/routes/article.js file. Furthermore, we need to create two more custom routers. One is for the bin, and the other for deletion. Inside the src/api/routes/ folder, create the files 01-bin.js and 02-delete.js.

NOTE: We named the custom routes with prefixes 01 and 02 to ensure routes are loaded alphabetically. This will allow these custom routes to be reached before the core router.

Bin Custom Router

Inside the src/api/routes/01-bin.js file, add the following code:


// path: ./src/api/routes/01-bin.js
module.exports = {
  routes: [
    {
      method: "GET",
      path: "/articles/bin",
      handler: "article.getBin",
    },
    {
      method: "PUT",
      path: "/articles/bin/:id/recover",
      handler: "article.recover",
    },

    {
      method: "DELETE",
      path: "/articles/bin/empty",
      handler: "article.emptyTrash",
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

In the code above, we created a custom router for routes /articles/bin.

In the first route, we specified that GET requests to this route should invoke the getBin() action of the article controller function we created previously.

The second route specifies that any PUT request to the /articles/bin/:id/recover should invoke the recover() action of the article controller.

Finally, the final route /articles/bin/empty will invoke the emptyTrash() action of the article controller.

Delete Route

Inside the src/api/routes/01-delete.js file, add the following code:


// path: ./src/api/routes/01-delete.js
module.exports = {
  routes: [
    {
      method: "PUT",
      path: "/articles/:id/soft-delete",
      handler: "article.softDelete",
    },
    {
      method: "DELETE",
      path: "/articles/:id/permanent-delete",
      handler: "article.permanentDelete",
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

In the code above, the first route specifies that PUT requests to /articles/:id/soft-delete should invoke the function softDelete().

The second route specifies that DELETE requests to /articles/:id/permanent-delete should invoke the permanentDelete() action.

Set Up a Cron Job

As noted earlier, soft-deleted articles also have the date field deleted_at. It is so that we can automatically delete any article in the trash that has exceeded 30 days since its soft deletion.

Locate the config folder, create the file cron-tasks.js and add the following code:


//path: ./config/cron-tasks.js
const moment = require("moment");
module.exports = {
  /**
   * CRON JOB
   * Runs for every day
   */

  myJob: {
    task: async ({ strapi }) => {
      const thirtyDaysAgo = moment().subtract(30, "days").format("YYYY-MM-DD");
      // Add your own logic here (e.g. send a queue of email, create a database backup, etc.).
      await strapi.db.query("api::article.article").deleteMany({
        where: {
          deleted_at: {
            $lt: thirtyDaysAgo,
          },
        },
      });
    },
    options: {
      rule: "0 0 * * *", // run every day at midnight
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

In the code above, we set up a cron job that will run every day at midnight to delete articles that have stayed in the trash for more than 30 days.

In order for this cron job to run, we need to add it to our server.js file which is also inside the config folder of our app. Locate the server.js file and add the following code:


// path: ./config/server.js
const cronTasks = require("./cron-tasks");

module.exports = ({ env }) => ({
  host: env("HOST", "0.0.0.0"),
  port: env.int("PORT", 1337),
  app: {
    keys: env.array("APP_KEYS"),
  },
  webhooks: {
    populateRelations: env.bool("WEBHOOKS_POPULATE_RELATIONS", false),
  },
  cron: {
    enabled: true,
    tasks: cronTasks,
  },
});
Enter fullscreen mode Exit fullscreen mode

In the code above, we imported the cron job we have created and enabled it. It will run every single day at midnight.

Once we have set up our Strapi server, we have to set up the UI of our application.

Implementing the User Interface

For the frontend, we'll use Next.js

Bootstrapping a Next.js App Router Project with Strapi

We will make use of Next.js new app router. Run the command below to install Next.js.
npx create-next-app@latest

Make sure to choose the following:
Installing Next.js App Router

After the installation, cd into the Next.js project and run the command below to start the application:
npm run dev

We should see the following displayed on our browser:
Next.js Running on Localhost.png

Next, we will install the following dependencies for our project:
npm i react-icons react-toastify

Create Utils Folder with Next.js and Strapi

Inside the app folder, create a folder utils. Inside the new utils folder, create a new file urls.ts and add the following code:

// path: ./app/utils/urls.ts
export const serverURL = "http://127.0.0.1:1337/api"
Enter fullscreen mode Exit fullscreen mode

This represents the URL of our Strapi API. We will make use of it for every request to our Strapi server.

Creating Components

Inside the app folder, create a new folder components. Inside this new folder, create the following files AddArticleModal.tsx, SearchBar.tsx and Sidebar.tsx and a folder called Buttons.

Create Modal for Create an Article

Inside the /app/components/AddArticleModal.tsx file, add the following code:


// path : /app/components/AddArticleModal.tsx
"use client";

import { useState } from "react";
import { GoPlus } from "react-icons/go";
import { MdClear } from "react-icons/md";
import { serverURL } from "../utils/urls";
import { useRouter } from "next/navigation";
import { toast } from "react-toastify";

export interface IFormInputs {
  name: string;
  content: string;
}

export default function AddArticleModal() {
  const [open, setOpen] = useState<boolean>(false);
  const [newArticle, setNewArticle] = useState<IFormInputs>({
    name: "",
    content: "",
  });
  const [error, setError] = useState<string>("");

  const router = useRouter();

  const handleModal = () => {
    setOpen((prev) => !prev);
    setNewArticle({ name: "", content: "" });
    setError("");
  };

  const handleInputChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
  ) => {
    setError("");
    const { value, name } = e.target;
    setNewArticle((prev: IFormInputs) => ({ ...prev, [name]: value }));
  };

  const handleCreateArticle = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const { name, content } = newArticle;

    if (!name || !content) {
      setError("All fields are required!");
      return;
    }

    try {
      const response = await fetch(`${serverURL}/articles`, {
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ data: newArticle }),
        method: "POST",
      });

      const result = await response.json();
      if (result.data) {
        setOpen(false);
        setNewArticle({ name: "", content: "" });
        toast.success("Article created successfully!");
        router.refresh();
        return;
      }
      const error = result.error;
      if (error.name === "ValidationError") {
        toast.error(
          `An article with the name "${newArticle.name}" already exists!`,
        );
      } else {
        toast.error("Something went wrong");
      }
      setNewArticle({ name: "", content: "" });
    } catch (error: unknown) {
      if (error instanceof Error) setError(error?.message);
      setNewArticle({ name: "", content: "" });
    }
  };

  return (
    <div>
      <button
        onClick={handleModal}
        className=" py-4 px-5 w-28 border flex items-center justify-between shadow-lg bg-white hover:bg-secondary rounded-lg my-5 transition-all duration-500"
      >
        <GoPlus size={24} />
        <span className="text-[14px] text-black1">New</span>
      </button>
      <div
        className={`${
          open ? " visible " : " invisible "
        } h-screen fixed left-0 w-screen top-0 flex flex-col justify-center items-center bg-black bg-opacity-90 z-50 transition-all `}
      >
        <div className="bg-white p-10 w-1/2 rounded-lg">
          <form
            onSubmit={handleCreateArticle}
            className="w-full py-5 flex flex-col space-y-5"
          >
            <p className="text-center font-bold text-[20px]">
              Create an Article
            </p>
            <p className="text-red-500 text-center text-sm">{error}</p>
            <div>
              <label>Name of Article</label>
              <input
                onChange={handleInputChange}
                value={newArticle?.name}
                name="name"
                type="text"
                placeholder="Article Name"
                className="w-full border p-2 my-2 text-black2"
              />
            </div>
            <div>
              <label>Content of Article</label>
              <textarea
                onChange={handleInputChange}
                value={newArticle?.content}
                name="content"
                placeholder="Article Content"
                className="w-full border p-2 my-2 text-black1"
              ></textarea>
            </div>
            <button
              type="submit"
              className="rounded-full hover:bg-secondary w-fit px-5 py-3 shadow-lg"
            >
              Create Article
            </button>
          </form>
        </div>
        <button
          onClick={handleModal}
          className="text-white absolute top-20 right-1/2"
        >
          <MdClear size={24} />
        </button>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The code above is the AddArticleModal client component. This component allows us to create new articles through a modal, with client-side validation and interaction with a server API. It makes a POST request to the /articles route of our server.

Create Search Bar Component

This will serve as a way to search for articles. Inside the /app/components/SearchBar.tsx file, add the following code:


// path: /app/components/SearchBar.tsx
"use client";

import Link from "next/link";
import React, { useEffect, useState } from "react";
import { FaCircleInfo, FaRecycle } from "react-icons/fa6";
import { GoSearch } from "react-icons/go";
import { MdClear } from "react-icons/md";
import { serverURL } from "../utils/urls";

export interface IArticle {
  id: string;
  attributes: {
    name: string;
  };
}
export default function SearchBar() {
  const [keyword, setKeyword] = useState<string>("");
  const [articles, setArticles] = useState<IArticle[]>([]);
  const [error, setError] = useState<string>("");

  const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
    setKeyword(e.target.value);
  };

  useEffect(() => {
    const getArticles = async () => {
      try {
        const response = await fetch(`${serverURL}/articles`, {
          method: "GET",
        });
        const { data } = await response.json();
        setArticles(data);
      } catch (error: unknown) {
        if (error instanceof Error) setError(error.message);
      }
    };
    getArticles();
  }, []);

  return (
    <div className="relative">
      <div className="flex items-center justify-between space-x-10">
        <div className="w-3/4 relative">
          <div className="input-container focus-within:bg-white focus-within:shadow-lg bg-primary w-full flex items-center rounded-full px-5 h-14">
            <GoSearch size={24} />
            <input
              onChange={handleSearch}
              value={keyword}
              type="text"
              placeholder="Search"
              className="ml-3 bg-transparent outline-none border-none w-full capitalize"
            />
            <button
              onClick={() => {
                setKeyword("");
              }}
            >
              <MdClear size={24} />
            </button>
          </div>
          {keyword ? (
            <div className=" rounded-b-xl   h-[250px] shadow-lg bg-white pb-20 absolute z-50 w-full">
              <div className="py-3 bg-white  overflow-y-scroll  absolute w-full  h-[200px] p-5">
                {error ? (
                  <div className="text-red-500 flex items-center justify-center shadow-2xl p-3 rounded-md">
                    <FaCircleInfo size={30} />
                    <span className="ml-2">{error}</span>
                  </div>
                ) : (
                  <div>
                    {articles.find((article) =>
                      article.attributes.name
                        .toLowerCase()
                        .includes(keyword.trim().toLowerCase()),
                    ) ? null : (
                      <div className="text-center p-20">
                        <p className="text-red-500">No article found!</p>
                      </div>
                    )}
                    {keyword &&
                      articles
                        .filter((article) => {
                          if (keyword.trim() === "") {
                            return article;
                          }
                          if (
                            article?.attributes?.name
                              ?.toLowerCase()
                              .includes(keyword.trim().toLowerCase())
                          ) {
                            return article;
                          }
                          return null;
                        })
                        .map((article) => (
                          <Link
                            href={`/articles/${article.id}`}
                            key={article.id}
                            className="p-5 bg-primary hover:bg-secondary inset-1 flex items-center justify-betwee  py-2 px-3 w-full "
                          >
                            {article?.attributes?.name}
                          </Link>
                        ))}
                  </div>
                )}
              </div>
            </div>
          ) : null}
        </div>

        <div className="w-1/4 flex justify-end">
          <Link href="/bin" className="flex">
            <FaRecycle size={24} />
            <span className="ml-3">Recycle Bin</span>
          </Link>
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Create SideBar Component

This will serve as our side bar. It will also import the AddArticleModal component. Add the following code inside the /app/components/SideBar.tsx file:


// path: /app/components/SideBar.tsx
import Link from "next/link";
import { TfiWrite } from "react-icons/tfi";
import { SiAppwrite } from "react-icons/si";
import { FiTrash2 } from "react-icons/fi";
import AddArticleModal from "./AddArticleModal";
import { serverURL } from "../utils/urls";

const getArticles = async () => {
  try {
    const response = await fetch(`${serverURL}/articles`, {
      method: "GET",
      cache: "no-cache",
    });
    return response.json();
  } catch (error) {
    console.log(error);
  }
};

export default async function Sidebar() {
  const result = await getArticles();
  const noOfArticles = result?.data?.length;

  return (
    <div className=" font-poppins font-thin ">
      <div className="fixed top-0 z-[30] h-full visible  sm:w-[287px] bg-primary p-5">
        <Link href="/" className="flex">
          <TfiWrite size={40} />
          <span className="text-[22px] ml-3 font-poppins">Article System</span>
        </Link>

        <AddArticleModal />
        <div className="flex flex-col space-y-3 text-black2 text-[14px]">
          <Link
            href="/articles"
            className="hover:bg-secondary px-5 py-1 rounded-full flex items-center"
          >
            <SiAppwrite size={24} />
            <span className="ml-3">My Articles</span>
          </Link>
          <Link
            href="/bin"
            className="hover:bg-secondary px-5 py-1 rounded-full flex items-center "
          >
            <FiTrash2 size={24} />
            <span className="ml-3">Trash</span>
          </Link>
        </div>
        <div className="">
          <div className="mt-20 w-fit border border-black rounded-full px-5 py-1">
            <span className="text-blue-500 text-[14px] font-extrabold">
              Total Articles ({noOfArticles})
            </span>
          </div>
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The code above represents the SideBar server component. It serves as a fixed sidebar displaying our article management system. It includes links to the home page, a modal for adding articles ( the AddArticleModal component ), links to "My Articles" and the "Trash" pages, which we will create soon, and a display of the total number of articles fetched from the server using a getArticles() function.

Create the Button Components

Inside the /app/components/buttons folder, create the files BackButton.tsx, DeleteArticle.tsx, EmptyTrashButton.tsx, RecoverArticle.tsx and PermanentDeleteArticle.tsx. Inside the BackButton.tsx, add the following code:


// path: /app/components/buttons/BackButton.tsx
"use client";

import { useRouter } from "next/navigation";
import { RiArrowGoBackFill } from "react-icons/ri";
export default function BackButton() {
  const router = useRouter();
  const handleBack = () => {
    router.back();
  };
  return (
    <button
      onClick={handleBack}
      className="text-black1 hover:bg-primary px-5 my-3 py-2 bg-white  shadow-md primary-button-curved w-fit flex items-center text-lg"
    >
      <span className="">
        <RiArrowGoBackFill />
      </span>
      <span className="ml-1 font-barlow text-[14px]">Go Back</span>
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

The code above represents the BackButton button component which serves as a navigational button to go back to the previous page.
Now, add the following code Inside the DeleteArticle file.


// path : /app/components/buttons/DeleteArticle.tsx
"use client";

import { AiOutlineDelete } from "react-icons/ai";
import { toast } from "react-toastify";
import { serverURL } from "../../utils/urls";
import { useRouter } from "next/navigation";

export interface Props {
  articleId: string;
}
export default function DeleteArticleButton({ articleId }: Props) {
  const router = useRouter();

  const handleSoftDeleteArticle = async () => {
    try {
      const response = await fetch(
        `${serverURL}/articles/${articleId}/soft-delete`,
        {
          method: "PUT",
        },
      );
      const result = await response.json();

      if (result.data) {
        toast.success("Article Deleted!");
        router.refresh();
      } else {
        toast.error("Something went wrong!");
      }
    } catch (error: unknown) {
      if (error instanceof Error) toast.error(error.message);
    }
  };

  return (
    <button onClick={handleSoftDeleteArticle} className="ml-3">
      <AiOutlineDelete size={24} color="red" />
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

The code above represents a button for soft-deleting an article. The button triggers the handleSoftDeleteArticle function when clicked. This function sends a PUT request to the server to perform a soft delete on the specified article using its articleId. If the operation is successful, a success toast notification is displayed, and the router.refresh() function to refresh the page. In the event of an error, the app shows a toast.

Inside the EmptyTrashButton.tsx file, add the following:


// path : /app/components/buttons/EmptyTrashButton.tsx
"use client";

import { useRouter } from "next/navigation";
import { toast } from "react-toastify";
import { serverURL } from "../../utils/urls";

export default function EmptyTrashButton() {
  const router = useRouter();

  const handleEmptyTrash = async () => {
    try {
      const response = await fetch(`${serverURL}/articles/bin/empty`, {
        method: "DELETE",
      });
      const result = await response.json();

      if (result.data) {
        toast.success("Trash emptied successfully!");
        router.refresh();
      } else {
        toast.error("Something went wrong!");
      }
    } catch (error: unknown) {
      if (error instanceof Error) toast.error(error.message);
    }
  };
  return (
    <button onClick={handleEmptyTrash} className="text-red-500 ml-5 underline">
      Empty Trash
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

EmptyTrashButton in the code above represents a button for emptying the trash or recycle bin. The button, when clicked, invokes the handleEmptyTrash() function, which sends a DELETE request to the server to empty the trash. After the operation, the code checks the response, displays a success toast if it was successful, and refreshes the page using the router.refresh() function. In the event of an error, the system shows a toast error.

Inside the PermanentDeleteArticle.tsx file, add the following code:


// path : ./app/components/buttons/PermanentDeleteArticle.tsx
"use client";

import { useRouter } from "next/navigation";
import { serverURL } from "../../utils/urls";
import { toast } from "react-toastify";

export interface Props {
  articleId: string;
}

export default function PermanentDeleteArticle({ articleId }: Props) {
  const router = useRouter();

  const handleDelParmanently = async () => {
    try {
      // /articles/:id/permanent-delete
      const response = await fetch(
        `${serverURL}/articles/${articleId}/permanent-delete`,
        {
          method: "DELETE",
        },
      );
      const result = await response.json();

      if (result.data) {
        toast.success("Article Deleted Permanently");
        router.refresh();
      } else {
        toast.error("Something went wrong!");
      }
    } catch (error: unknown) {
      if (error instanceof Error) toast.error(error.message);
    }
  };

  return (
    <button
      onClick={handleDelParmanently}
      className="border p-3 rounded-full w-40 hover:bg-red-500 shadow-lg hover:text-white"
    >
      Delete Permanently
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

The code above represents a button for permanently deleting a specific article. The button is associated with the handleDelParmanently() function. It sends a DELETE request to the server to perform a permanent delete on the article specified by articleId. After the operation, the code checks the response, displays a success toast if it was successful, and refreshes the page using the router.refresh() function.

Inside the RecoverArticle.tsx file, add the following:


// path: app/components/buttons/RecoverArticle.tsx

"use client";

import { FaRecycle } from "react-icons/fa6";
import { useRouter } from "next/navigation";
import { toast } from "react-toastify";
import { serverURL } from "../../utils/urls";

export interface Props {
  articleId: string;
}

export default function RecoverArticle({ articleId }: Props) {
  const router = useRouter();

  const handleRecoverArticle = async () => {
    try {
      const response = await fetch(
        `${serverURL}/articles/bin/${articleId}/recover`,
        {
          method: "PUT",
        },
      );
      const result = await response.json();

      if (result.data) {
        toast.success("Article Recovered!");
        router.refresh();
      } else {
        toast.error("Something went wrong!");
      }
    } catch (error: unknown) {
      if (error instanceof Error) toast.error(error.message);
    }
  };
  return (
    <button
      onClick={handleRecoverArticle}
      className="ml-3 border p-3 rounded-full w-40 flex items-center justify-center hover:bg-secondary shadow-lg"
    >
      <FaRecycle />
      <span className="ml-3">Recover</span>
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

The code above represents a button for recovering (restoring) a previously deleted article. The button is associated with the handleRecoverArticle function, which sends a PUT request to the server to recover the article specified by articleId from the bin (trash). After the operation, the code checks the response, displays a success toast if it was successful, and refreshes the page using router.refresh().

Defining The Layout

Inside the app/layout.tsx file, add the following code to define the layout of this project.


// path: app/layout.tsx
import type { Metadata } from "next";
import { Poppins } from "next/font/google";
import "./globals.css";
import Sidebar from "./components/Sidebar";
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";

const poppins = Poppins({
  weight: ["400", "700"],
  style: ["normal", "italic"],
  subsets: ["latin"],
  display: "swap",
  variable: "--font-poppins",
});

export const metadata: Metadata = {
  title: "Strapi Soft Delete",
  description: "Implement Strapi Soft Delete",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={`${poppins.variable}`}>
        <ToastContainer />
        <div>
          <div>
            <div className="flex h-screen overflow-scroll">
              {/* Side Bar */}
              <div className="hidden sm:block">
                <Sidebar />
              </div>
              <div></div>
              {/* Main content */}
              <div className="flex-1 p-4 w-full ml-0 sm:ml-[287px] overflow-hidden  bg-white">
                <div className="mt-4">{children}</div>
              </div>
            </div>
          </div>
        </div>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

As can be seen in the code above, we imported the SideBar component into our layout.

Update the Home Page

Inside the app/page.tsx file, add the following code:


// path: app/page.tsx
import Link from "next/link";
import { CiBookmark } from "react-icons/ci";
import DeleteArticle from "./components/Buttons/DeleteArticle";
import { serverURL } from "./utils/urls";
import SearchBar from "./components/SearchBar";

export interface IArticle {
  id: string;
  attributes: {
    name: string;
    content: string;
    deleted: boolean;
  };
}

const getArticles = async () => {
  try {
    const response = await fetch(`${serverURL}/articles`, {
      method: "GET",
      cache: "no-cache",
    });
    return response.json();
  } catch (error) {
    console.error(error);
  }
};
export default async function Home() {
  const result = await getArticles();
  const articles = result?.data;

  return (
    <div>
      <SearchBar />
      <div className="grid grid-cols-3 gap-y-10 my-10 mx-5 pb-20 relative">
        {articles?.length > 0 ? (
          articles.map((article: IArticle) => (
            <div
              key={article?.id}
              className=" w-[270px] h-[250px] bg-primary shadow overflow-hidden px-1 pb-12 rounded-lg border-none hover:border hover:border-secondary hover:bg-tertiary"
            >
              <div className="flex items-center justify-between h-[30px] px-3 py-5">
                <CiBookmark size={24} />
                <div className="flex justify-end items-center">
                  <span className="text-[14px] font-bold">
                    {article?.attributes?.name.length <= 20
                      ? article?.attributes?.name
                      : article?.attributes?.name?.slice(0, 20) + "..."}
                  </span>
                  <DeleteArticle articleId={article?.id} />
                </div>
              </div>
              <Link
                href={`/articles/${article?.id}`}
                className="h-[90%] rounded-lg px-10 bg-white flex flex-col justify-center space-y-3 text-[5px] border"
              >
                <span className="font-bold text-[7px]">
                  {article?.attributes?.name}
                </span>
                <span> {article?.attributes?.content}</span>
              </Link>
            </div>
          ))
        ) : (
          <p className="text-center col-span-3 text-[14px] text-red-500 bg-tertiary p-5 w-full">
            No article available at the moment
          </p>
        )}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The code above imports the SearchBar , DeleteArticle button and the serverURL utility. It makes a request to the server to fetch articles.

The homepage should look like that:
Homepage

Create the Recycle Bin Page

Create a folder named bin and create a page.tsx file inside it. Add the following code inside the new file:


// path: app/bin/page.tsx
import RecoverArticle from "../components/Buttons/RecoverArticle";
import BackButton from "../components/Buttons/BackButton";
import { serverURL } from "../utils/urls";
import PermanentDeleteArticle from "../components/Buttons/PermanentDeleteArticle";
import EmptyTrashButton from "../components/Buttons/EmptyTrashButton";

export interface IArticle {
  id: string;
  attributes: {
    name: string;
    deleted: boolean;
  };
}

const getBin = async () => {
  try {
    const response = await fetch(`${serverURL}/articles/bin`, {
      method: "GET",
      cache: "no-cache",
    });

    return response.json();
  } catch (error) {
    return null;
  }
};

export default async function page() {
  const result = await getBin();
  const articles = result?.data;

  return (
    <div>
      <BackButton />
      <p className="text-lg font-bold my-7">Welcome to Bin</p>
      <p className="p-5 bg-tertiary my-7 text-center font-thin">
        Articles that have been in Bin more than 30 days will be automatically
        deleted.
        <EmptyTrashButton />
      </p>
      <div className="flex flex-col my-10 py-10   text-[14px]">
        {articles?.length > 0 ? (
          articles.map((article: IArticle) => (
            <div
              key={article.id}
              className="hover:bg-primary border-b p-2 flex items-center justify-between "
            >
              <div className="flex items-center">
                <span className="ml-3">{article?.attributes?.name}</span>
              </div>
              <div className="flex">
                <PermanentDeleteArticle articleId={article?.id} />
                <RecoverArticle articleId={article?.id} />
              </div>
            </div>
          ))
        ) : (
          <p className="text-center text-[14px] text-red-500 bg-tertiary p-5">
            No article in the bin at the moment!
          </p>
        )}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The code above fetches a list of articles from a server bin using the getBin() function, and then displays them. The page includes navigation elements (BackButton), a heading welcoming to the bin, information about automatic deletion of articles older than 30 days, and buttons (PermanentDeleteArticle and RecoverArticle) for interacting with individual articles in the bin. It also comes with the button EmptyTrashButton to allow a user empty their trash or bin. If there are no articles in the bin, a corresponding message is displayed.

Here is what it should look like:
Recycle Bin

Demo time!

By the end of this tutorial, you should have a working soft delete application that will enable you to recover an article, or delete it permanently:

Soft_delete.gif

Conclusion

In this article, we explored the necessity of soft delete functionality and demonstrated how to implement custom soft delete logic by creating a project with an integrated recycle bin. This was achieved by customizing Strapi's controllers with new actions specific to our soft delete logic. Additionally, we crafted custom routes to manage this functionality. To automate the process of permanently deleting soft-deleted articles after 30 days, we established a daily cron job scheduled to run at midnight.

Resources

Top comments (0)