diff --git a/RELEASE.rst b/RELEASE.rst index 304cfb70a5..96d0da349b 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -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) --------------- diff --git a/articles/migrations/0003_remove_article_html_article_content.py b/articles/migrations/0003_remove_article_html_article_content.py new file mode 100644 index 0000000000..54860c8f1e --- /dev/null +++ b/articles/migrations/0003_remove_article_html_article_content.py @@ -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={}), + ), + ] diff --git a/articles/models.py b/articles/models.py index 8534a7c898..abbdba7d6f 100644 --- a/articles/models.py +++ b/articles/models.py @@ -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) diff --git a/articles/serializers.py b/articles/serializers.py index 1adf4f0aba..464faf039d 100644 --- a/articles/serializers.py +++ b/articles/serializers.py @@ -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"] diff --git a/articles/validators.py b/articles/validators.py index 131f7d5e03..aa7997f340 100644 --- a/articles/validators.py +++ b/articles/validators.py @@ -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"}, } diff --git a/articles/views_test.py b/articles/views_test.py index e6bc5b63cd..748eda8a5e 100644 --- a/articles/views_test.py +++ b/articles/views_test.py @@ -13,12 +13,12 @@ def test_article_creation(staff_client, user): url = reverse("articles:v1:articles-list") data = { - "html": "

", + "content": {}, "title": "Some title", } resp = staff_client.post(url, data) json = resp.json() - assert json["html"] == "

" + assert json["content"] == {} assert json["title"] == "Some title" diff --git a/docker-compose.opensearch.base.yml b/docker-compose.opensearch.base.yml index 0b4c59d0d0..0c8a71727d 100644 --- a/docker-compose.opensearch.base.yml +++ b/docker-compose.opensearch.base.yml @@ -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 diff --git a/frontends/.eslintrc.js b/frontends/.eslintrc.js index 074b4b193e..30fb736252 100644 --- a/frontends/.eslintrc.js +++ b/frontends/.eslintrc.js @@ -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": { diff --git a/frontends/.stylelintrc.yaml b/frontends/.stylelintrc.yaml index 947b90026b..de27ffb702 100644 --- a/frontends/.stylelintrc.yaml +++ b/frontends/.stylelintrc.yaml @@ -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" diff --git a/frontends/api/src/generated/v1/api.ts b/frontends/api/src/generated/v1/api.ts index ce9ce600b1..a19401073e 100644 --- a/frontends/api/src/generated/v1/api.ts +++ b/frontends/api/src/generated/v1/api.ts @@ -4772,10 +4772,10 @@ export interface PatchedLearningResourceRelationshipRequest { export interface PatchedRichTextArticleRequest { /** * - * @type {string} + * @type {any} * @memberof PatchedRichTextArticleRequest */ - html?: string + content?: any /** * * @type {string} @@ -7157,10 +7157,10 @@ export type ResourceTypeEnum = export interface RichTextArticle { /** * - * @type {string} + * @type {any} * @memberof RichTextArticle */ - html: string + content?: any /** * * @type {number} @@ -7182,10 +7182,10 @@ export interface RichTextArticle { export interface RichTextArticleRequest { /** * - * @type {string} + * @type {any} * @memberof RichTextArticleRequest */ - html: string + content?: any /** * * @type {string} diff --git a/frontends/api/src/test-utils/factories/articles.ts b/frontends/api/src/test-utils/factories/articles.ts index 46afe999ae..7abaeadeaf 100644 --- a/frontends/api/src/test-utils/factories/articles.ts +++ b/frontends/api/src/test-utils/factories/articles.ts @@ -6,7 +6,10 @@ import type { RichTextArticle } from "../../generated/v1" const article: Factory = (overrides = {}) => ({ id: faker.number.int(), title: faker.lorem.sentence(), - html: faker.lorem.paragraph(), + content: { + text: faker.lorem.paragraph(), + author: faker.person.fullName(), + }, ...overrides, }) diff --git a/frontends/main/jest.config.ts b/frontends/main/jest.config.ts index b523048ae7..0c1424ee75 100644 --- a/frontends/main/jest.config.ts +++ b/frontends/main/jest.config.ts @@ -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"), diff --git a/frontends/main/next.config.js b/frontends/main/next.config.js index dac2a8467d..240d63e645 100644 --- a/frontends/main/next.config.js +++ b/frontends/main/next.config.js @@ -128,7 +128,7 @@ const nextConfig = { allowCollectingMemory: true, }) } - // Important: return the modified config + return config }, } diff --git a/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx b/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx new file mode 100644 index 0000000000..6832e53b57 --- /dev/null +++ b/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx @@ -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 ( + + + + + + + + ) +} + +export { NewArticlePage } diff --git a/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx b/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx new file mode 100644 index 0000000000..e8f73d53d8 --- /dev/null +++ b/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx @@ -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 + } + if (!data) { + return notFound() + } + return ( + + + + + {data?.title} + + + + + Edit + + + + {JSON.stringify(data.content, null, 2)} + + + ) +} diff --git a/frontends/main/src/app-pages/Articles/ArticleEditPage.test.tsx b/frontends/main/src/app-pages/Articles/ArticleEditPage.test.tsx new file mode 100644 index 0000000000..2f1bf9b013 --- /dev/null +++ b/frontends/main/src/app-pages/Articles/ArticleEditPage.test.tsx @@ -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() + + 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() + + 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() + + 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() + }) +}) diff --git a/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx b/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx new file mode 100644 index 0000000000..dc4c8a5a35 --- /dev/null +++ b/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx @@ -0,0 +1,138 @@ +"use client" +import React, { useEffect, useState, ChangeEvent } from "react" +import { Permission } from "api/hooks/user" +import { useRouter } from "next-nprogress-bar" +import { useArticleDetail, useArticlePartialUpdate } from "api/hooks/articles" +import { Button, Input, Alert } from "@mitodl/smoot-design" +import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" +import { Container, Typography, styled, LoadingSpinner } from "ol-components" +import { notFound } from "next/navigation" +import { articlesView } from "@/common/urls" + +const SaveButton = styled.div({ + textAlign: "right", + margin: "10px", +}) + +const ClientContainer = styled.div({ + width: "100%", + margin: "10px 0", +}) + +const TitleInput = styled(Input)({ + width: "100%", + margin: "10px 0", +}) + +const ArticleEditPage = ({ articleId }: { articleId: string }) => { + const router = useRouter() + + const id = Number(articleId) + const { data: article, isLoading } = useArticleDetail(id) + + const [title, setTitle] = useState("") + const [text, setText] = useState("") + const [json, setJson] = useState({}) + const [alertText, setAlertText] = useState("") + + const { mutate: updateArticle, isPending } = useArticlePartialUpdate() + + const handleSave = () => { + const payload = { + id: id, + title: title.trim(), + content: json, + } + + updateArticle(payload, { + onSuccess: (article) => { + router.push(articlesView(article.id)) + }, + onError: (error) => { + setAlertText(`❌ ${error.message}`) + }, + }) + } + + useEffect(() => { + if (article && !title) { + setTitle(article.title) + setText(article.content ? JSON.stringify(article.content, null, 2) : "") + setJson(article.content) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [article]) + + if (isLoading) { + return + } + if (!article) { + return notFound() + } + + const handleChange = (e: ChangeEvent) => { + const value = e.target.value + setText(value) + + try { + const parsed = JSON.parse(value) + setJson(parsed) + } catch { + setJson({}) + } + } + return ( + + + + Edit Article + + {alertText && ( + + + {alertText} + + + )} + { + console.log("Title input changed:", e.target.value) + setTitle(e.target.value) + setAlertText("") + }} + placeholder="Enter article title" + className="input-field" + /> + + +