Skip to content
Merged
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
10 changes: 10 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
Release Notes
=============

Version 0.47.12
---------------

- chore(deps): update dependency bpython to ^0.26 (#2668)
- Initial Tiptap Editor (#2691)
- fix search error with staleness and incompleteness (#2688)
- log error if a shard has a failure (#2690)
- feat: Implement the Article CRUD except listing (#2686)
- embeddings healthcheck (#2676)

Version 0.47.11 (Released November 10, 2025)
---------------

Expand Down
21 changes: 21 additions & 0 deletions articles/migrations/0003_remove_article_html_article_content.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 4.2.25 on 2025-11-07 11:53

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("articles", "0002_alter_article_created_on"),
]

operations = [
migrations.RemoveField(
model_name="article",
name="html",
),
migrations.AddField(
model_name="article",
name="content",
field=models.JSONField(default={}),
),
]
2 changes: 1 addition & 1 deletion articles/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ class Article(TimestampedModel):
Stores rich-text content created by staff members.
"""

html = models.TextField()
content = models.JSONField(default={})
title = models.CharField(max_length=255)
4 changes: 2 additions & 2 deletions articles/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ class RichTextArticleSerializer(serializers.ModelSerializer):
Serializer for LearningResourceInstructor model
"""

html = SanitizedHtmlField()
content = serializers.JSONField(default={})
title = serializers.CharField(max_length=255)

class Meta:
model = models.Article
fields = ["html", "id", "title"]
fields = ["content", "id", "title"]
6 changes: 4 additions & 2 deletions articles/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@
# - On all tags: https://docs.rs/ammonia/latest/ammonia/struct.Builder.html#method.generic_attributes
"attributes": {
"a": {"href", "hreflang"},
"img": {"alt", "height", "src", "width", "srcset", "sizes"},
"figure": {"class"},
"img": {"alt", "height", "src", "width", "srcset", "sizes", "style"},
"figure": {"class", "style"},
"oembed": {"url"},
},
# 👇 Allow data: URLs for src attributes
"url_schemes": {"data"},
}


Expand Down
4 changes: 2 additions & 2 deletions articles/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ def test_article_creation(staff_client, user):

url = reverse("articles:v1:articles-list")
data = {
"html": "<p><script>console.log('hax')</script></p>",
"content": {},
"title": "Some title",
}
resp = staff_client.post(url, data)
json = resp.json()
assert json["html"] == "<p></p>"
assert json["content"] == {}
assert json["title"] == "Some title"


Expand Down
2 changes: 1 addition & 1 deletion docker-compose.opensearch.base.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
opensearch:
image: opensearchproject/opensearch:2.19.3
image: opensearchproject/opensearch:3.1.0
environment:
- "cluster.name=opensearch-cluster"
- "bootstrap.memory_lock=true" # along with the memlock settings below, disables swapping
Expand Down
4 changes: 4 additions & 0 deletions frontends/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ module.exports = {
"mit-learn",
"github-pages",
"storybook-static",
"**/TiptapEditor/components/**/*.tsx",
"**/TiptapEditor/components/**/*.ts",
"**/TiptapEditor/hooks/**/*.ts",
"**/TiptapEditor/lib/**/*.ts",
],
settings: {
"import/resolver": {
Expand Down
2 changes: 2 additions & 0 deletions frontends/.stylelintrc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ rules:
- message: "Expected class selector to be kebab-case"
ignoreFiles:
- "**/*.vendor.css"
- "**/TiptapEditor/components/**/*.scss"
- "**/TiptapEditor/styles/**/*.scss"
overrides:
- files:
- "**/*.scss"
Expand Down
12 changes: 6 additions & 6 deletions frontends/api/src/generated/v1/api.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion frontends/api/src/test-utils/factories/articles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import type { RichTextArticle } from "../../generated/v1"
const article: Factory<RichTextArticle> = (overrides = {}) => ({
id: faker.number.int(),
title: faker.lorem.sentence(),
html: faker.lorem.paragraph(),
content: {
text: faker.lorem.paragraph(),
author: faker.person.fullName(),
},
...overrides,
})

Expand Down
4 changes: 3 additions & 1 deletion frontends/main/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ const config: Config.InitialOptions = {
...baseConfig.setupFilesAfterEnv,
"./test-utils/setupJest.tsx",
],
transformIgnorePatterns: ["node_modules/(?!@faker-js).+"],
transformIgnorePatterns: [
"node_modules/(?!(@faker-js|react-hotkeys-hook)).+",
],
moduleNameMapper: {
...baseConfig.moduleNameMapper,
"^@/(.*)$": path.resolve(__dirname, "src/$1"),
Expand Down
2 changes: 1 addition & 1 deletion frontends/main/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ const nextConfig = {
allowCollectingMemory: true,
})
}
// Important: return the modified config

return config
},
}
Expand Down
36 changes: 36 additions & 0 deletions frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"use client"

import React from "react"
import { TiptapEditor, theme, styled, HEADER_HEIGHT } from "ol-components"
import { Permission } from "api/hooks/user"
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"

const PageContainer = styled.div({
color: theme.custom.colors.darkGray2,
display: "flex",
height: `calc(100vh - ${HEADER_HEIGHT}px - 132px)`,
})

const EditorContainer = styled.div({
minHeight: 0,
})

const StyledTiptapEditor = styled(TiptapEditor)({
width: "70vw",
height: `calc(100% - ${HEADER_HEIGHT}px - 132px)`,
overscrollBehavior: "contain",
})

const NewArticlePage: React.FC = () => {
return (
<RestrictedRoute requires={Permission.ArticleEditor}>
<PageContainer>
<EditorContainer>
<StyledTiptapEditor />
</EditorContainer>
</PageContainer>
</RestrictedRoute>
)
}

export { NewArticlePage }
65 changes: 65 additions & 0 deletions frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"use client"

import React from "react"
import { useArticleDetail } from "api/hooks/articles"
import { Container, LoadingSpinner, styled, Typography } from "ol-components"
import { ButtonLink } from "@mitodl/smoot-design"
import { notFound } from "next/navigation"
import { Permission } from "api/hooks/user"
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
import { articlesEditView } from "@/common/urls"

const Page = styled(Container)({
marginTop: "40px",
marginBottom: "40px",
})

const ControlsContainer = styled.div({
display: "flex",
justifyContent: "flex-end",
margin: "10px",
})
const WrapperContainer = styled.div({
borderBottom: "1px solid rgb(222, 208, 208)",
paddingBottom: "10px",
})

const PreTag = styled.pre({
background: "#f6f6f6",
padding: "16px",
borderRadius: "8px",
fontSize: "14px",
overflowX: "auto",
})

export const ArticleDetailPage = ({ articleId }: { articleId: number }) => {
const id = Number(articleId)
const { data, isLoading } = useArticleDetail(id)

const editUrl = articlesEditView(id)

if (isLoading) {
return <LoadingSpinner color="inherit" loading={isLoading} size={32} />
}
if (!data) {
return notFound()
}
return (
<RestrictedRoute requires={Permission.ArticleEditor}>
<Page>
<WrapperContainer>
<Typography variant="h3" component="h1">
{data?.title}
</Typography>

<ControlsContainer>
<ButtonLink href={editUrl} variant="primary">
Edit
</ButtonLink>
</ControlsContainer>
</WrapperContainer>
<PreTag>{JSON.stringify(data.content, null, 2)}</PreTag>
</Page>
</RestrictedRoute>
)
}
100 changes: 100 additions & 0 deletions frontends/main/src/app-pages/Articles/ArticleEditPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import React from "react"
import { screen, renderWithProviders, setMockResponse } from "@/test-utils"
import { waitFor, fireEvent } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { factories, urls } from "api/test-utils"
import { ArticleEditPage } from "./ArticleEditPage"

const pushMock = jest.fn()

jest.mock("next-nprogress-bar", () => ({
useRouter: () => ({
push: pushMock,
}),
}))

describe("ArticleEditPage", () => {
test("renders editor when user has ArticleEditor permission", async () => {
const user = factories.user.user({
is_authenticated: true,
is_article_editor: true,
})
setMockResponse.get(urls.userMe.get(), user)

const article = factories.articles.article({
id: 42,
title: "Existing Title",
content: { id: 1, content: "Existing content" },
})
setMockResponse.get(urls.articles.details(article.id), article)

renderWithProviders(<ArticleEditPage articleId={"42"} />)

expect(await screen.findByText("Edit Article")).toBeInTheDocument()
expect(screen.getByTestId("editor")).toBeInTheDocument()
expect(screen.getByDisplayValue("Existing Title")).toBeInTheDocument()
})

test("submits article successfully and redirects", async () => {
const user = factories.user.user({
is_authenticated: true,
is_article_editor: true,
})
setMockResponse.get(urls.userMe.get(), user)

const article = factories.articles.article({
id: 123,
title: "Existing Title",
content: { id: 1, content: "Existing content" },
})
setMockResponse.get(urls.articles.details(article.id), article)

// ✅ Mock successful update response
const updated = { ...article, title: "Updated Title" }
setMockResponse.patch(urls.articles.details(article.id), updated)

renderWithProviders(<ArticleEditPage articleId={"123"} />)

const titleInput = await screen.findByPlaceholderText("Enter article title")

fireEvent.change(titleInput, { target: { value: "Updated Title" } })

await waitFor(() => expect(titleInput).toHaveValue("Updated Title"))

await userEvent.click(screen.getByText(/save article/i))

// ✅ Wait for redirect after update success
await waitFor(() => expect(pushMock).toHaveBeenCalledWith("/articles/123"))
})

test("shows error alert on failure", async () => {
const user = factories.user.user({
is_authenticated: true,
is_article_editor: true,
})
setMockResponse.get(urls.userMe.get(), user)

const article = factories.articles.article({
id: 7,
title: "Old Title",
content: { id: 1, content: "Bad content" },
})
setMockResponse.get(urls.articles.details(article.id), article)

// ✅ Mock failed update (500)
setMockResponse.patch(
urls.articles.details(article.id),
{ detail: "Server Error" },
{ code: 500 },
)

renderWithProviders(<ArticleEditPage articleId={"7"} />)

const titleInput = await screen.findByPlaceholderText("Enter article title")
fireEvent.change(titleInput, { target: { value: "Bad Article" } })

await userEvent.click(screen.getByText(/save article/i))

expect(await screen.findByText(/Mock Error/i)).toBeInTheDocument()
})
})
Loading
Loading