Skip to content

Commit 5fc50e0

Browse files
ahtesham-quraishAhtesham Quraish
andauthored
feat: Implement the Article CRUD except listing (#2686)
* feat: add article feature crud except listing Co-authored-by: Ahtesham Quraish <ahtesham.quraish@192.168.1.4>
1 parent 3ba27bd commit 5fc50e0

File tree

16 files changed

+619
-22
lines changed

16 files changed

+619
-22
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 4.2.25 on 2025-11-07 11:53
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("articles", "0002_alter_article_created_on"),
9+
]
10+
11+
operations = [
12+
migrations.RemoveField(
13+
model_name="article",
14+
name="html",
15+
),
16+
migrations.AddField(
17+
model_name="article",
18+
name="content",
19+
field=models.JSONField(default={}),
20+
),
21+
]

articles/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@ class Article(TimestampedModel):
1010
Stores rich-text content created by staff members.
1111
"""
1212

13-
html = models.TextField()
13+
content = models.JSONField(default={})
1414
title = models.CharField(max_length=255)

articles/serializers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ class RichTextArticleSerializer(serializers.ModelSerializer):
2020
Serializer for LearningResourceInstructor model
2121
"""
2222

23-
html = SanitizedHtmlField()
23+
content = serializers.JSONField(default={})
2424
title = serializers.CharField(max_length=255)
2525

2626
class Meta:
2727
model = models.Article
28-
fields = ["html", "id", "title"]
28+
fields = ["content", "id", "title"]

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

articles/views_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ def test_article_creation(staff_client, user):
1313

1414
url = reverse("articles:v1:articles-list")
1515
data = {
16-
"html": "<p><script>console.log('hax')</script></p>",
16+
"content": {},
1717
"title": "Some title",
1818
}
1919
resp = staff_client.post(url, data)
2020
json = resp.json()
21-
assert json["html"] == "<p></p>"
21+
assert json["content"] == {}
2222
assert json["title"] == "Some title"
2323

2424

frontends/api/src/generated/v1/api.ts

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontends/api/src/test-utils/factories/articles.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import type { RichTextArticle } from "../../generated/v1"
66
const article: Factory<RichTextArticle> = (overrides = {}) => ({
77
id: faker.number.int(),
88
title: faker.lorem.sentence(),
9-
html: faker.lorem.paragraph(),
9+
content: {
10+
text: faker.lorem.paragraph(),
11+
author: faker.person.fullName(),
12+
},
1013
...overrides,
1114
})
1215

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"use client"
2+
3+
import React from "react"
4+
import { useArticleDetail } from "api/hooks/articles"
5+
import { Container, LoadingSpinner, styled, Typography } from "ol-components"
6+
import { ButtonLink } from "@mitodl/smoot-design"
7+
import { notFound } from "next/navigation"
8+
import { Permission } from "api/hooks/user"
9+
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
10+
import { articlesEditView } from "@/common/urls"
11+
12+
const Page = styled(Container)({
13+
marginTop: "40px",
14+
marginBottom: "40px",
15+
})
16+
17+
const ControlsContainer = styled.div({
18+
display: "flex",
19+
justifyContent: "flex-end",
20+
margin: "10px",
21+
})
22+
const WrapperContainer = styled.div({
23+
borderBottom: "1px solid rgb(222, 208, 208)",
24+
paddingBottom: "10px",
25+
})
26+
27+
const PreTag = styled.pre({
28+
background: "#f6f6f6",
29+
padding: "16px",
30+
borderRadius: "8px",
31+
fontSize: "14px",
32+
overflowX: "auto",
33+
})
34+
35+
export const ArticleDetailPage = ({ articleId }: { articleId: number }) => {
36+
const id = Number(articleId)
37+
const { data, isLoading } = useArticleDetail(id)
38+
39+
const editUrl = articlesEditView(id)
40+
41+
if (isLoading) {
42+
return <LoadingSpinner color="inherit" loading={isLoading} size={32} />
43+
}
44+
if (!data) {
45+
return notFound()
46+
}
47+
return (
48+
<RestrictedRoute requires={Permission.ArticleEditor}>
49+
<Page>
50+
<WrapperContainer>
51+
<Typography variant="h3" component="h1">
52+
{data?.title}
53+
</Typography>
54+
55+
<ControlsContainer>
56+
<ButtonLink href={editUrl} variant="primary">
57+
Edit
58+
</ButtonLink>
59+
</ControlsContainer>
60+
</WrapperContainer>
61+
<PreTag>{JSON.stringify(data.content, null, 2)}</PreTag>
62+
</Page>
63+
</RestrictedRoute>
64+
)
65+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import React from "react"
2+
import { screen, renderWithProviders, setMockResponse } from "@/test-utils"
3+
import { waitFor, fireEvent } from "@testing-library/react"
4+
import userEvent from "@testing-library/user-event"
5+
import { factories, urls } from "api/test-utils"
6+
import { ArticleEditPage } from "./ArticleEditPage"
7+
8+
const pushMock = jest.fn()
9+
10+
jest.mock("next-nprogress-bar", () => ({
11+
useRouter: () => ({
12+
push: pushMock,
13+
}),
14+
}))
15+
16+
describe("ArticleEditPage", () => {
17+
test("renders editor when user has ArticleEditor permission", async () => {
18+
const user = factories.user.user({
19+
is_authenticated: true,
20+
is_article_editor: true,
21+
})
22+
setMockResponse.get(urls.userMe.get(), user)
23+
24+
const article = factories.articles.article({
25+
id: 42,
26+
title: "Existing Title",
27+
content: { id: 1, content: "Existing content" },
28+
})
29+
setMockResponse.get(urls.articles.details(article.id), article)
30+
31+
renderWithProviders(<ArticleEditPage articleId={"42"} />)
32+
33+
expect(await screen.findByText("Edit Article")).toBeInTheDocument()
34+
expect(screen.getByTestId("editor")).toBeInTheDocument()
35+
expect(screen.getByDisplayValue("Existing Title")).toBeInTheDocument()
36+
})
37+
38+
test("submits article successfully and redirects", async () => {
39+
const user = factories.user.user({
40+
is_authenticated: true,
41+
is_article_editor: true,
42+
})
43+
setMockResponse.get(urls.userMe.get(), user)
44+
45+
const article = factories.articles.article({
46+
id: 123,
47+
title: "Existing Title",
48+
content: { id: 1, content: "Existing content" },
49+
})
50+
setMockResponse.get(urls.articles.details(article.id), article)
51+
52+
// ✅ Mock successful update response
53+
const updated = { ...article, title: "Updated Title" }
54+
setMockResponse.patch(urls.articles.details(article.id), updated)
55+
56+
renderWithProviders(<ArticleEditPage articleId={"123"} />)
57+
58+
const titleInput = await screen.findByPlaceholderText("Enter article title")
59+
60+
fireEvent.change(titleInput, { target: { value: "Updated Title" } })
61+
62+
await waitFor(() => expect(titleInput).toHaveValue("Updated Title"))
63+
64+
await userEvent.click(screen.getByText(/save article/i))
65+
66+
// ✅ Wait for redirect after update success
67+
await waitFor(() => expect(pushMock).toHaveBeenCalledWith("/articles/123"))
68+
})
69+
70+
test("shows error alert on failure", async () => {
71+
const user = factories.user.user({
72+
is_authenticated: true,
73+
is_article_editor: true,
74+
})
75+
setMockResponse.get(urls.userMe.get(), user)
76+
77+
const article = factories.articles.article({
78+
id: 7,
79+
title: "Old Title",
80+
content: { id: 1, content: "Bad content" },
81+
})
82+
setMockResponse.get(urls.articles.details(article.id), article)
83+
84+
// ✅ Mock failed update (500)
85+
setMockResponse.patch(
86+
urls.articles.details(article.id),
87+
{ detail: "Server Error" },
88+
{ code: 500 },
89+
)
90+
91+
renderWithProviders(<ArticleEditPage articleId={"7"} />)
92+
93+
const titleInput = await screen.findByPlaceholderText("Enter article title")
94+
fireEvent.change(titleInput, { target: { value: "Bad Article" } })
95+
96+
await userEvent.click(screen.getByText(/save article/i))
97+
98+
expect(await screen.findByText(/Mock Error/i)).toBeInTheDocument()
99+
})
100+
})

0 commit comments

Comments
 (0)