Skip to content

Commit dc89ff1

Browse files
author
Ahtesham Quraish
committed
feat: add article detail and edit page
1 parent 92ea580 commit dc89ff1

File tree

11 files changed

+387
-13
lines changed

11 files changed

+387
-13
lines changed

articles/validators.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,12 @@
3232
# - On all tags: https://docs.rs/ammonia/latest/ammonia/struct.Builder.html#method.generic_attributes
3333
"attributes": {
3434
"a": {"href", "hreflang"},
35-
"img": {"alt", "height", "src", "width", "srcset", "sizes"},
36-
"figure": {"class"},
35+
"img": {"alt", "height", "src", "width", "srcset", "sizes", "style"},
36+
"figure": {"class", "style"},
3737
"oembed": {"url"},
3838
},
39+
# 👇 Allow data: URLs for src attributes
40+
"url_schemes": {"data"},
3941
}
4042

4143

frontends/main/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"lint": "next lint"
1111
},
1212
"dependencies": {
13+
"@ckeditor/ckeditor5-react": "^6.3.0",
1314
"@ebay/nice-modal-react": "^1.2.13",
1415
"@emotion/cache": "^11.13.1",
1516
"@emotion/styled": "^11.11.0",
@@ -23,6 +24,7 @@
2324
"@tanstack/react-query": "^5.66",
2425
"api": "workspace:*",
2526
"async_hooks": "^1.0.0",
27+
"ckeditor5": "^42.0.0",
2628
"classnames": "^2.5.1",
2729
"formik": "^2.4.6",
2830
"iso-639-1": "^3.1.4",
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"use client"
2+
3+
import React from "react"
4+
import { useArticleDetail } from "api/hooks/articles"
5+
import { Container, LoadingSpinner, styled } from "ol-components"
6+
import { notFound } from "next/navigation"
7+
import Link from "next/link"
8+
9+
import "ckeditor5/ckeditor5.css"
10+
11+
const ArticleTitle = styled.h1({
12+
fontSize: "24px",
13+
marginBottom: "12px",
14+
})
15+
const EditButton = styled.div({
16+
textAlign: "right",
17+
margin: "10px",
18+
})
19+
const WrapperContainer = styled.div({
20+
borderBottom: "1px solid rgb(222, 208, 208)",
21+
paddingBottom: "10px",
22+
})
23+
24+
const EditButtonLink = styled(Link)({
25+
cursor: "pointer",
26+
minWidth: "100px",
27+
boxSizing: "border-box",
28+
borderWidth: "1px",
29+
padding: "11px 16px",
30+
fontFamily: "neue-haas-grotesk-text, sans-serif",
31+
fontStyle: "normal",
32+
fontSize: "0.875rem",
33+
lineHeight: "1.125rem",
34+
textTransform: "none",
35+
backgroundColor: "#750014",
36+
color: "#FFFFFF",
37+
})
38+
39+
export const ArticleDetailPage = ({ articleId }: { articleId: number }) => {
40+
const id = Number(articleId)
41+
const { data, isLoading } = useArticleDetail(id)
42+
43+
if (isLoading) {
44+
return <LoadingSpinner color="inherit" loading={isLoading} size={32} />
45+
}
46+
if (!data) {
47+
return notFound()
48+
}
49+
return (
50+
<Container>
51+
<WrapperContainer>
52+
<ArticleTitle className="article-title">{data?.title}</ArticleTitle>
53+
54+
<EditButton>
55+
<EditButtonLink
56+
href={`/articles/${data.id}/edit`}
57+
className="btn btn-edit"
58+
color="red"
59+
>
60+
Edit
61+
</EditButtonLink>
62+
</EditButton>
63+
</WrapperContainer>
64+
<div
65+
className="ck-content"
66+
dangerouslySetInnerHTML={{ __html: data?.html }}
67+
/>
68+
</Container>
69+
)
70+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import React from "react"
2+
import { screen, renderWithProviders, setMockResponse } from "@/test-utils"
3+
import { waitFor, fireEvent } from "@testing-library/react"
4+
import { factories, urls } from "api/test-utils"
5+
import { ArticleEditPage } from "./ArticleEditPage"
6+
7+
const pushMock = jest.fn()
8+
9+
jest.mock("next-nprogress-bar", () => ({
10+
useRouter: () => ({
11+
push: pushMock,
12+
}),
13+
}))
14+
15+
// Mock API hooks
16+
const mockUpdateMutate = jest.fn()
17+
jest.mock("api/hooks/articles", () => ({
18+
useArticleDetail: (id: number) => ({
19+
data: {
20+
id,
21+
title: "Existing Title",
22+
html: "<p>Existing content</p>",
23+
},
24+
isLoading: false,
25+
}),
26+
useArticlePartialUpdate: () => ({
27+
mutate: mockUpdateMutate,
28+
isPending: false,
29+
}),
30+
}))
31+
32+
// Mock CKEditor
33+
jest.mock("ol-ckeditor", () => ({
34+
CKEditorClient: ({ onChange }: { onChange: (content: string) => void }) => (
35+
<textarea
36+
data-testid="editor"
37+
onChange={(e) => onChange(e.target.value)}
38+
value="mock content"
39+
/>
40+
),
41+
}))
42+
43+
describe("ArticleEditPage", () => {
44+
beforeEach(() => {
45+
jest.clearAllMocks()
46+
})
47+
48+
test("renders editor when user has ArticleEditor permission", async () => {
49+
const user = factories.user.user({
50+
is_authenticated: true,
51+
is_article_editor: true,
52+
})
53+
setMockResponse.get(urls.userMe.get(), user)
54+
55+
renderWithProviders(<ArticleEditPage articleId={"42"} />)
56+
57+
expect(await screen.findByText("Write Article")).toBeInTheDocument()
58+
expect(screen.getByTestId("editor")).toBeInTheDocument()
59+
expect(screen.getByDisplayValue("Existing Title")).toBeInTheDocument()
60+
})
61+
62+
test("submits article successfully and redirects", async () => {
63+
const user = factories.user.user({
64+
is_authenticated: true,
65+
is_article_editor: true,
66+
})
67+
setMockResponse.get(urls.userMe.get(), user)
68+
69+
renderWithProviders(<ArticleEditPage articleId={"123"} />)
70+
71+
const titleInput = await screen.findByPlaceholderText("Enter article title")
72+
73+
// Change title
74+
fireEvent.change(titleInput, { target: { value: "Updated Title" } })
75+
await waitFor(() => expect(titleInput).toHaveValue("Updated Title"))
76+
77+
// Mock success response
78+
mockUpdateMutate.mockImplementation((_data, opts) => {
79+
opts.onSuccess({ id: 123 })
80+
})
81+
82+
// Click save
83+
fireEvent.click(screen.getByText(/save article/i))
84+
85+
// Assert payload
86+
await waitFor(() => {
87+
expect(mockUpdateMutate).toHaveBeenCalledWith(
88+
{
89+
id: 123,
90+
title: "Updated Title",
91+
html: "<p>Existing content</p>",
92+
},
93+
expect.any(Object),
94+
)
95+
})
96+
97+
// Assert redirect
98+
expect(pushMock).toHaveBeenCalledWith("/articles/123")
99+
})
100+
101+
test("shows error alert on failure", async () => {
102+
const user = factories.user.user({
103+
is_authenticated: true,
104+
is_article_editor: true,
105+
})
106+
setMockResponse.get(urls.userMe.get(), user)
107+
108+
renderWithProviders(<ArticleEditPage articleId={"7"} />)
109+
110+
const titleInput = await screen.findByPlaceholderText("Enter article title")
111+
fireEvent.change(titleInput, { target: { value: "Bad Article" } })
112+
113+
mockUpdateMutate.mockImplementation((_data, opts) => {
114+
opts.onError?.()
115+
})
116+
117+
fireEvent.click(screen.getByText(/save article/i))
118+
119+
expect(
120+
await screen.findByText(/Failed to save article/i),
121+
).toBeInTheDocument()
122+
})
123+
})
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"use client"
2+
import React, { useEffect } from "react"
3+
import { Permission } from "api/hooks/user"
4+
import { CKEditorClient } from "ol-ckeditor"
5+
import { useRouter } from "next-nprogress-bar"
6+
import { useArticleDetail, useArticlePartialUpdate } from "api/hooks/articles"
7+
import { Button, Input, Alert } from "@mitodl/smoot-design"
8+
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
9+
import { Container, Typography, styled, LoadingSpinner } from "ol-components"
10+
import { notFound } from "next/navigation"
11+
12+
const SaveButton = styled.div({
13+
textAlign: "right",
14+
margin: "10px",
15+
})
16+
17+
const ClientContainer = styled.div({
18+
width: "100%",
19+
margin: "10px 0",
20+
})
21+
22+
const TitleInput = styled(Input)({
23+
width: "100%",
24+
margin: "10px 0",
25+
})
26+
27+
const ArticleEditPage = ({ articleId }: { articleId: string }) => {
28+
const router = useRouter()
29+
30+
const id = Number(articleId)
31+
const { data: article, isLoading } = useArticleDetail(id)
32+
33+
const [title, setTitle] = React.useState<string>("")
34+
const [editorContent, setEditorContent] = React.useState<string>("")
35+
const [editorKey] = React.useState(0)
36+
const [alertText, setAlertText] = React.useState("")
37+
const [severity, setSeverity] = React.useState<"success" | "error">("success")
38+
39+
const { mutate: updateArticle, isPending } = useArticlePartialUpdate()
40+
41+
const handleSave = () => {
42+
setAlertText("")
43+
44+
const payload = {
45+
id: id,
46+
title: title.trim(),
47+
html: editorContent,
48+
}
49+
50+
updateArticle(
51+
payload as {
52+
id: number
53+
html: string
54+
title: string
55+
},
56+
{
57+
onSuccess: (article) => {
58+
router.push(`/articles/${article.id}`)
59+
},
60+
onError: () => {
61+
setAlertText("❌ Failed to save article")
62+
setSeverity("error")
63+
},
64+
},
65+
)
66+
}
67+
68+
useEffect(() => {
69+
if (article && !title && !editorContent) {
70+
console.log("Article data:", article)
71+
setTitle(article.title)
72+
setEditorContent(article.html)
73+
}
74+
// eslint-disable-next-line react-hooks/exhaustive-deps
75+
}, [article])
76+
77+
if (isLoading) {
78+
return <LoadingSpinner color="inherit" loading={isLoading} size={32} />
79+
}
80+
if (!article) {
81+
return notFound()
82+
}
83+
84+
return (
85+
<RestrictedRoute requires={Permission.ArticleEditor}>
86+
<Container className="article-wrapper">
87+
<h1>Write Article</h1>
88+
{alertText && (
89+
<Alert
90+
key={alertText}
91+
severity={severity}
92+
className="info-alert"
93+
closable
94+
>
95+
<Typography variant="body2" color="textPrimary">
96+
{alertText}
97+
</Typography>
98+
</Alert>
99+
)}
100+
<TitleInput
101+
type="text"
102+
value={title}
103+
onChange={(e) => {
104+
console.log("Title input changed:", e.target.value)
105+
setTitle(e.target.value)
106+
setAlertText("")
107+
}}
108+
placeholder="Enter article title"
109+
className="input-field"
110+
/>
111+
112+
<ClientContainer className="editor-box">
113+
<CKEditorClient
114+
key={editorKey}
115+
value={editorContent}
116+
onChange={(content) => setEditorContent(content)}
117+
/>
118+
</ClientContainer>
119+
120+
<SaveButton>
121+
<Button
122+
variant="primary"
123+
disabled={isPending || !title.trim()}
124+
onClick={handleSave}
125+
>
126+
{isPending ? "Saving..." : "Save Article"}
127+
</Button>
128+
</SaveButton>
129+
</Container>
130+
</RestrictedRoute>
131+
)
132+
}
133+
134+
export { ArticleEditPage }

frontends/main/src/app-pages/Articles/NewArticlePage.test.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import { waitFor, fireEvent } from "@testing-library/react"
44
import { factories, urls } from "api/test-utils"
55
import { NewArticlePage } from "./NewArticlePage"
66

7+
const pushMock = jest.fn()
8+
jest.mock("next/navigation", () => ({
9+
useRouter: () => ({
10+
push: pushMock,
11+
}),
12+
}))
13+
714
class TestErrorBoundary extends React.Component<{ children: React.ReactNode }> {
815
state = { hasError: false }
916

@@ -102,17 +109,14 @@ describe("NewArticlePage", () => {
102109
opts.onSuccess({ id: 101 })
103110
})
104111

105-
// Click save
106112
fireEvent.click(screen.getByText(/save article/i))
107113

108114
expect(mockMutate).toHaveBeenCalledWith(
109115
{ title: "My Article", html: "" }, // mock editor starts empty
110116
expect.any(Object),
111117
)
112118

113-
expect(
114-
await screen.findByText(/Article saved successfully/i),
115-
).toBeInTheDocument()
119+
expect(pushMock).toHaveBeenCalledWith("/articles/101")
116120
})
117121

118122
test("shows error on failure", async () => {

0 commit comments

Comments
 (0)