diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.test.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.test.tsx
new file mode 100644
index 0000000000..c797f11ffd
--- /dev/null
+++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.test.tsx
@@ -0,0 +1,78 @@
+import React from "react"
+import { renderWithProviders, screen, user, waitFor } from "@/test-utils"
+import { setMockResponse, factories, urls, makeRequest } from "api/test-utils"
+import { WebsiteContentDraftListingPage } from "./WebsiteContentDraftListingPage"
+
+const mockPush = jest.fn()
+jest.mock("next/navigation", () => ({
+ useRouter: () => ({ push: mockPush }),
+}))
+
+const setupEditor = () => {
+ setMockResponse.get(
+ urls.userMe.get(),
+ factories.user.user({ is_authenticated: true, is_article_editor: true }),
+ )
+}
+
+describe("WebsiteContentDraftListingPage delete", () => {
+ test("editor can delete a draft after confirming", async () => {
+ setupEditor()
+ const draft = factories.websiteContent.websiteContent({
+ title: "My Draft",
+ content_type: "news",
+ is_published: false,
+ })
+ setMockResponse.get(expect.stringContaining("/api/v1/website_content/"), {
+ count: 1,
+ next: null,
+ previous: null,
+ results: [draft],
+ })
+ setMockResponse.delete(urls.websiteContent.details(draft.id), null)
+
+ renderWithProviders()
+
+ const deleteButton = await screen.findByRole("button", {
+ name: `Delete ${draft.title}`,
+ })
+ await user.click(deleteButton)
+
+ // Confirmation dialog appears; clicking "Yes, delete" fires the request.
+ const confirm = await screen.findByRole("button", { name: "Yes, delete" })
+ await user.click(confirm)
+
+ await waitFor(() => {
+ expect(makeRequest).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: "delete",
+ url: urls.websiteContent.details(draft.id),
+ }),
+ )
+ })
+ })
+
+ test("offers no delete action for published content", async () => {
+ setupEditor()
+ // The API only rejects this with a 400, so the button must not be offered
+ // even if a published item reaches this list.
+ const published = factories.websiteContent.websiteContent({
+ title: "Already Published",
+ content_type: "news",
+ is_published: true,
+ })
+ setMockResponse.get(expect.stringContaining("/api/v1/website_content/"), {
+ count: 1,
+ next: null,
+ previous: null,
+ results: [published],
+ })
+
+ renderWithProviders()
+
+ await screen.findByText(published.title)
+ expect(
+ screen.queryByRole("button", { name: `Delete ${published.title}` }),
+ ).not.toBeInTheDocument()
+ })
+})
diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx
index 5a08eb4841..aab32e5d15 100644
--- a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx
+++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx
@@ -19,11 +19,16 @@ import type {
WebsiteContentApiWebsiteContentListRequest as WebsiteContentListRequest,
} from "api/v1"
import { LocalDate } from "ol-utilities"
-import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react"
+import {
+ RiArrowLeftLine,
+ RiArrowRightLine,
+ RiDeleteBinLine,
+} from "@remixicon/react"
import { extractFirstImage } from "@/common/websiteContentUtils"
import { websiteContentEditView, websiteContentCreateView } from "@/common/urls"
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
-import { ButtonLink } from "@mitodl/smoot-design"
+import { Button, ButtonLink } from "@mitodl/smoot-design"
+import { showDeleteWebsiteContentDialog } from "@/page-components/WebsiteContentDialogs/DeleteWebsiteContentDialog"
const PAGE_SIZE = 20
@@ -45,6 +50,9 @@ const PageHeader = styled.div`
align-items: center;
margin-bottom: 40px;
`
+const StyledDeleteButton = styled(Button)`
+ padding-bottom: 0;
+`
const DraftContentCard = styled(Card)`
display: flex;
@@ -112,6 +120,25 @@ const DraftItem: React.FC<{ contentItem: WebsiteContent; type: string }> = ({
>
)}
+ {/*
+ Only drafts are deletable — the API rejects deleting published content
+ with a 400. This page filters to drafts, so the guard is belt-and-braces:
+ it keeps the UI honest about the backend rule if a published item ever
+ reaches this list.
+ */}
+ {!contentItem.is_published && (
+
+ }
+ onClick={() => showDeleteWebsiteContentDialog(contentItem)}
+ >
+ Delete
+
+
+ )}
)
}
diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx
index 3d2493ab96..1f72049a56 100644
--- a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx
+++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx
@@ -1598,3 +1598,77 @@ describe("NewsEditor - Byline publish date", () => {
expect(screen.queryByText("Jan 1, 2020")).not.toBeInTheDocument()
})
})
+
+describe("NewsEditor - Delete draft", () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test("editor can delete a draft from the edit toolbar", async () => {
+ const user = factories.user.user({
+ is_authenticated: true,
+ is_article_editor: true,
+ })
+ setMockResponse.get(urls.userMe.get(), user)
+
+ const newsItem = factories.websiteContent.websiteContent({
+ id: 321,
+ title: "Draft to delete",
+ content_type: "news",
+ is_published: false,
+ })
+ setMockResponse.get(urls.websiteContent.details(newsItem.id), newsItem)
+ setMockResponse.delete(urls.websiteContent.details(newsItem.id), null)
+
+ renderWithProviders(
+ ,
+ {
+ user,
+ },
+ )
+
+ await screen.findByTestId("editor")
+
+ await userEvent.click(await screen.findByRole("button", { name: "Delete" }))
+ await userEvent.click(
+ await screen.findByRole("button", { name: "Yes, delete" }),
+ )
+
+ await waitFor(() => {
+ expect(makeRequest).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: "delete",
+ url: urls.websiteContent.details(newsItem.id),
+ }),
+ )
+ })
+ })
+
+ test("delete button is not shown for published content", async () => {
+ const user = factories.user.user({
+ is_authenticated: true,
+ is_article_editor: true,
+ })
+ setMockResponse.get(urls.userMe.get(), user)
+
+ const newsItem = factories.websiteContent.websiteContent({
+ id: 322,
+ title: "Published item",
+ content_type: "news",
+ is_published: true,
+ })
+ setMockResponse.get(urls.websiteContent.details(newsItem.id), newsItem)
+
+ renderWithProviders(
+ ,
+ {
+ user,
+ },
+ )
+
+ await screen.findByTestId("editor")
+ expect(
+ screen.queryByRole("button", { name: "Delete" }),
+ ).not.toBeInTheDocument()
+ })
+})
diff --git a/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx b/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx
index c92fa885d7..620e045538 100644
--- a/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx
+++ b/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx
@@ -16,6 +16,9 @@ import { Alert, Button, ButtonLink } from "@mitodl/smoot-design"
import { useUserHasPermission, Permission } from "api/hooks/user"
import { useQueryClient, type QueryClient } from "@tanstack/react-query"
import dynamic from "next/dynamic"
+import { useRouter } from "next-nprogress-bar"
+import { RiDeleteBinLine } from "@remixicon/react"
+import { showDeleteWebsiteContentDialog } from "@/page-components/WebsiteContentDialogs/DeleteWebsiteContentDialog"
import { Toolbar } from "../vendor/components/tiptap-ui-primitive/toolbar"
import { TiptapEditor, MainToolbarContent, TipTapViewer } from "../TiptapEditor"
@@ -210,6 +213,7 @@ const WebsiteContentEditor = ({
uploadImageRef.current = uploadImage
const queryClient = useQueryClient()
+ const router = useRouter()
const isArticleEditor = useUserHasPermission(Permission.ArticleEditor)
const uploadHandler = useCallback(
@@ -408,6 +412,21 @@ const WebsiteContentEditor = ({
) : (
+ {contentItem && !contentItem.is_published ? (
+ }
+ onClick={() =>
+ showDeleteWebsiteContentDialog(contentItem, () =>
+ router.push(websiteContentDraftsView(contentType)),
+ )
+ }
+ >
+ Delete
+
+ ) : null}
{!contentItem?.is_published ? (