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,73 @@
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 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),
}),
)
})
})

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(<WebsiteContentDraftListingPage contentType="news" />)

await screen.findByText(published.title)
expect(
screen.queryByRole("button", { name: `Delete ${published.title}` }),
).not.toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,23 @@ import {
} from "ol-components"
import { Permission } from "api/hooks/user"
import { useWebsiteContentList } from "api/hooks/website_content"
import type {
WebsiteContent,
WebsiteContentApiWebsiteContentListRequest as WebsiteContentListRequest,
import {
WebsiteContentContentTypeEnum,
type WebsiteContent,
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 { CONTENT_TYPE_LABELS } from "@/common/website_content"
import { websiteContentEditView, websiteContentCreateView } from "@/common/urls"
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
import { ButtonLink } from "@mitodl/smoot-design"
import { ActionButton, ButtonLink } from "@mitodl/smoot-design"
import { showDeleteWebsiteContentDialog } from "@/page-components/WebsiteContentDialogs/DeleteWebsiteContentDialog"

const PAGE_SIZE = 20

Expand All @@ -38,14 +45,16 @@ const PageWrapper = styled.div`
padding: 40px 0;
}
`
const StyledActionButton = styled(ActionButton)`
padding-top: 16px;
`

const PageHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 40px;
`

const DraftContentCard = styled(Card)`
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -79,15 +88,10 @@ const DraftBadge = styled.span`
font-weight: ${theme.typography.fontWeightMedium};
`

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

const DraftItem: React.FC<{ contentItem: WebsiteContent; type: string }> = ({
contentItem,
type,
}) => {
const DraftItem: React.FC<{
contentItem: WebsiteContent
type: WebsiteContentContentTypeEnum
}> = ({ contentItem, type }) => {
const itemUrl = contentItem.is_published
? `/${type === "article" ? "articles" : type}/${contentItem.slug || contentItem.id}`
: websiteContentEditView(type, contentItem.id)
Expand All @@ -112,30 +116,48 @@ const DraftItem: React.FC<{ contentItem: WebsiteContent; type: string }> = ({
</>
)}
</Card.Footer>
{/*
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 && (
<Card.Actions>
<StyledActionButton
variant="text"
size="small"
aria-label={`Delete ${contentItem.title}`}
onClick={() => showDeleteWebsiteContentDialog(contentItem)}
>
<RiDeleteBinLine size={16} />
</StyledActionButton>
</Card.Actions>
)}
</DraftContentCard>
)
}

interface WebsiteContentDraftListingPageProps {
/**
* Content type to show drafts for (e.g. 'article', 'news').
* Content type to show drafts for. Defaults to news.
*/
contentType?: string
contentType?: WebsiteContentContentTypeEnum
}

const WebsiteContentDraftListingPage: React.FC<
WebsiteContentDraftListingPageProps
> = ({ contentType }) => {
const [page, setPage] = useState(1)
const scrollRef = useRef<HTMLDivElement>(null)
const type = contentType || "news"
const label = CONTENT_TYPE_LABELS[type] ?? type
const type = contentType ?? WebsiteContentContentTypeEnum.News
const label = CONTENT_TYPE_LABELS[type]

const listParams: WebsiteContentListRequest = {
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
draft: true,
content_type: type as WebsiteContentListRequest["content_type"],
content_type: type,
}

const { data: contentItems, isLoading: isLoadingContentItems } =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from "react"
import { Metadata } from "next"
import { standardizeMetadata } from "@/common/metadata"
import { WebsiteContentDraftListingPage } from "@/app-pages/WebsiteContent/WebsiteContentDraftListingPage"
import { toContentType } from "@/common/website_content"

export const metadata: Metadata = standardizeMetadata({
title: "MIT Learn | Drafts",
Expand All @@ -14,7 +15,9 @@ const Page = async ({
searchParams: Promise<{ content_type?: string }>
}) => {
const { content_type: contentType } = await searchParams
return <WebsiteContentDraftListingPage contentType={contentType} />
return (
<WebsiteContentDraftListingPage contentType={toContentType(contentType)} />
)
}

export default Page
30 changes: 29 additions & 1 deletion frontends/main/src/common/website_content.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
import type { JSONContent } from "@tiptap/react"
import type { WebsiteContent } from "api/v1"
import { WebsiteContentContentTypeEnum, type WebsiteContent } from "api/v1"

/**
* Display labels for website content types.
*
* Keyed on the generated enum rather than `Record<string, string>` so that
* adding a content type is a type error here until a label is supplied.
*
* Deliberately not the generated `WebsiteContentContentTypeEnumDescriptions`:
* those values come from the `WebsiteContentType` labels in
* `website_content/constants.py`, so a backend rename would quietly change UI
* copy. Take the type from generated code, keep the strings here.
*/
export const CONTENT_TYPE_LABELS: Record<
WebsiteContentContentTypeEnum,
string
> = {
[WebsiteContentContentTypeEnum.News]: "News",
[WebsiteContentContentTypeEnum.Article]: "Article",
}

/**
* Narrow an arbitrary query-param string to a known content type, or undefined
* if it isn't one.
*/
export const toContentType = (
value: string | undefined,
): WebsiteContentContentTypeEnum | undefined =>
Object.values(WebsiteContentContentTypeEnum).find((type) => type === value)

export const extractWebsiteContentDescription = (
content: WebsiteContent,
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,24 @@ 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}
{/* Separates the destructive action from the save actions to
reduce misclicks. */}
<Spacer />
{!contentItem?.is_published ? (
<Button
variant="secondary"
Expand Down
Loading
Loading