Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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(<WebsiteContentDraftListingPage contentType="news" />)

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),
}),
)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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;
Expand Down Expand Up @@ -112,6 +120,17 @@ const DraftItem: React.FC<{ contentItem: WebsiteContent; type: string }> = ({
</>
)}
</Card.Footer>
<Card.Actions>
<StyledDeleteButton
variant="text"
size="small"
aria-label={`Delete ${contentItem.title}`}
startIcon={<RiDeleteBinLine />}
onClick={() => showDeleteWebsiteContentDialog(contentItem)}
>
Delete
</StyledDeleteButton>
</Card.Actions>
Comment on lines +123 to +133
</DraftContentCard>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<NewsEditor newsItem={newsItem} onSave={mockOnSave} />,
{
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(
<NewsEditor newsItem={newsItem} onSave={mockOnSave} />,
{
user,
},
)

await screen.findByTestId("editor")
expect(
screen.queryByRole("button", { name: "Delete" }),
).not.toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -210,6 +213,7 @@ const WebsiteContentEditor = ({
uploadImageRef.current = uploadImage

const queryClient = useQueryClient()
const router = useRouter()
const isArticleEditor = useUserHasPermission(Permission.ArticleEditor)

const uploadHandler = useCallback<UploadHandler>(
Expand Down Expand Up @@ -408,6 +412,21 @@ const WebsiteContentEditor = ({
) : (
<StyledToolbar>
<MainToolbarContent editor={editor} />
{contentItem && !contentItem.is_published ? (
<Button
variant="text"
size="small"
disabled={isPending}
startIcon={<RiDeleteBinLine />}
onClick={() =>
showDeleteWebsiteContentDialog(contentItem, () =>
router.push(websiteContentDraftsView(contentType)),
)
}
>
Delete
</Button>
) : null}
{!contentItem?.is_published ? (
<Button
variant="secondary"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React, { useCallback } from "react"
import NiceModal, { muiDialogV5 } from "@ebay/nice-modal-react"
import { Dialog } from "ol-components"
import { useWebsiteContentDestroy } from "api/hooks/website_content"
import type { WebsiteContent } from "api/v1"

type DeletableContent = Pick<WebsiteContent, "id" | "title" | "content_type">

type DeleteWebsiteContentDialogProps = {
contentItem: DeletableContent
/** Called after the item is successfully deleted (e.g. to redirect). */
onDestroy?: () => void
}

const CONTENT_TYPE_LABELS: Record<string, string> = {
article: "Article",
news: "News",
}

/**
* Confirmation dialog for soft-deleting a draft article/news item. Only drafts
* are deletable (enforced by the backend); this dialog is only surfaced on
* draft-editing views.
*/
const DeleteWebsiteContentDialog = NiceModal.create(
({ contentItem, onDestroy }: DeleteWebsiteContentDialogProps) => {
const modal = NiceModal.useModal()
const hideModal = modal.hide
const destroy = useWebsiteContentDestroy()
const label = CONTENT_TYPE_LABELS[contentItem.content_type ?? ""] ?? "Item"

const handleConfirm = useCallback(async () => {
await destroy.mutateAsync(contentItem.id)
hideModal()
onDestroy?.()
}, [destroy, hideModal, contentItem, onDestroy])
Comment on lines +27 to +36

return (
<Dialog
{...muiDialogV5(modal)}
onConfirm={handleConfirm}
title={`Delete ${label}`}
confirmText="Yes, delete"
>
Are you sure you want to delete &ldquo;{contentItem.title}&rdquo;?
</Dialog>
)
},
)

/** Open the delete-confirmation dialog for a draft content item. */
export const showDeleteWebsiteContentDialog = (
contentItem: DeletableContent,
onDestroy?: () => void,
) => NiceModal.show(DeleteWebsiteContentDialog, { contentItem, onDestroy })

export default DeleteWebsiteContentDialog
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("website_content", "0005_add_cover_image_to_websitecontent"),
]

operations = [
migrations.AddField(
model_name="websitecontent",
name="deleted_on",
field=models.DateTimeField(blank=True, default=None, null=True),
),
]
4 changes: 4 additions & 0 deletions website_content/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ class WebsiteContent(TimestampedModel):
default=WebsiteContentType.news.name,
)
cover_image = models.URLField(max_length=2083, blank=True, default="")
# Soft-delete marker. When set, the item is treated as deleted: it is
# excluded from all API listings/detail views but kept in the database for
# recovery/audit. Only unpublished (draft) items may be soft-deleted.
deleted_on = models.DateTimeField(null=True, blank=True, default=None)

def save(self, *args, **kwargs):
"""Auto-populate slug and cover_image before persisting."""
Expand Down
17 changes: 15 additions & 2 deletions website_content/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.conf import settings
from django.db import transaction
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.decorators import method_decorator
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import (
Expand All @@ -11,6 +12,7 @@
)
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
Expand Down Expand Up @@ -61,7 +63,9 @@ class WebsiteContentViewSet(viewsets.ModelViewSet):
filterset_class = WebsiteContentFilter

def get_queryset(self):
qs = WebsiteContent.objects.all()
# Soft-deleted items are hidden everywhere (list, retrieve,
# detail-by-id-or-slug all route through get_queryset).
qs = WebsiteContent.objects.filter(deleted_on__isnull=True)
if not (is_admin_user(self.request) or is_website_content_editor(self.request)):
qs = qs.filter(is_published=True)

Expand Down Expand Up @@ -94,7 +98,16 @@ def perform_update(self, serializer):
content_published_actions(content=content)

def perform_destroy(self, instance):
super().perform_destroy(instance)
# Only drafts may be deleted. Published content is out of scope and
# deleting it is rejected rather than hidden.
if instance.is_published:
msg = "Published content cannot be deleted."
raise ValidationError(msg)
# Soft delete: mark the row rather than removing it, and bypass the
# model's save() override (which recomputes slug/cover_image) so this
# is a targeted single-field write.
now = timezone.now()
WebsiteContent.objects.filter(pk=instance.pk).update(deleted_on=now)
transaction.on_commit(clear_views_cache)

@extend_schema(
Expand Down
Loading
Loading