Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
df648c9
Initial Tiptap Editor
jonkafton Nov 10, 2025
a98a0bc
Update lockfile
jonkafton Nov 10, 2025
65dd417
Ignore stylelint errors in vendor sheets
jonkafton Nov 10, 2025
8d1ae8c
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 10, 2025
ff020e4
Run fmt-fix
jonkafton Nov 10, 2025
a28ac14
Eslint ignore vendor code
jonkafton Nov 10, 2025
1a2c714
Revert main package.json
jonkafton Nov 10, 2025
335c7b9
Remove aliases from Tiptap template code imports
jonkafton Nov 10, 2025
f49a96e
Prettier fixes
jonkafton Nov 10, 2025
0f94110
CodeQL fix
jonkafton Nov 10, 2025
638411a
React in scope fixes
jonkafton Nov 10, 2025
8ca8b24
Format fix
jonkafton Nov 10, 2025
ae1b0f5
Transform react-hotkeys-hook to resolve esm error
jonkafton Nov 10, 2025
41f8931
Merge branch 'main' into jk/tiptap-editor
Nov 11, 2025
7783bac
feat: adding tiptap integration with article crud operation
Nov 11, 2025
4ada6d3
feat: add upload image control in toolbar
Nov 12, 2025
d602fcb
rebase with master
Nov 12, 2025
a42a4c5
made wrapper component for editor
Nov 12, 2025
72b84ae
Refactor ArticleEditor. Layour changed (wip)
jonkafton Nov 13, 2025
923196e
Refactor and layout fixes
jonkafton Nov 13, 2025
4a3941f
Remove initial editor route
jonkafton Nov 13, 2025
89fe5ac
Reinstate edit page redirect. Update tests
jonkafton Nov 13, 2025
3dc59f6
Remove unused value prop
jonkafton Nov 13, 2025
9166166
Lint
jonkafton Nov 13, 2025
111b54a
Invalidate article detail cache
jonkafton Nov 13, 2025
f53e1af
Detail page layout. Fix race condition on content display after save
jonkafton Nov 13, 2025
c2eebfd
Merge branch 'main' into jk/article-editor-layout
jonkafton Nov 13, 2025
1c502d5
Remove EditorContainer
jonkafton Nov 13, 2025
9453e69
Update test
jonkafton Nov 13, 2025
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
2 changes: 1 addition & 1 deletion frontends/api/src/hooks/articles/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ describe("Article CRUD", () => {
const { id, ...patchData } = article
expect(makeRequest).toHaveBeenCalledWith("patch", url, patchData)
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: articleKeys.root,
queryKey: articleKeys.detail(article.id),
})
})

Expand Down
6 changes: 3 additions & 3 deletions frontends/api/src/hooks/articles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const useArticleList = (
}

/**
* Query is diabled if id is undefined.
* Query is disabled if id is undefined.
*/
const useArticleDetail = (id: number | undefined) => {
return useQuery({
Expand Down Expand Up @@ -58,8 +58,8 @@ const useArticlePartialUpdate = () => {
PatchedRichTextArticleRequest: data,
})
.then((response) => response.data),
onSuccess: (_data) => {
client.invalidateQueries({ queryKey: articleKeys.root })
onSuccess: (article: Article) => {
client.invalidateQueries({ queryKey: articleKeys.detail(article.id) })
},
})
}
Expand Down
36 changes: 0 additions & 36 deletions frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx

This file was deleted.

59 changes: 10 additions & 49 deletions frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,66 +2,27 @@

import React from "react"
import { useArticleDetail } from "api/hooks/articles"
import {
Container,
LoadingSpinner,
styled,
Typography,
TiptapEditorContainer,
} from "ol-components"
import { ButtonLink } from "@mitodl/smoot-design"
import { LoadingSpinner, ArticleEditor } from "ol-components"
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",
})

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

const editUrl = articlesEditView(id)

if (isLoading) {
return <LoadingSpinner color="inherit" loading={isLoading} size={32} />
if (isLoading || isFetching) {
return <LoadingSpinner color="inherit" loading size={32} />
}
if (!data) {
if (!article) {
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>
<TiptapEditorContainer
data-testid="editor"
value={data.content}
readOnly
/>
</Page>
<ArticleEditor article={article} readOnly />
</RestrictedRoute>
)
}
37 changes: 23 additions & 14 deletions frontends/main/src/app-pages/Articles/ArticleEditPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ 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 { factories, urls, makeRequest } from "api/test-utils"
import { ArticleEditPage } from "./ArticleEditPage"

const pushMock = jest.fn()
const mockPush = jest.fn()

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

Expand Down Expand Up @@ -38,12 +38,12 @@ describe("ArticleEditPage", () => {

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

expect(await screen.findByText("Edit Article")).toBeInTheDocument()
expect(screen.getByTestId("editor")).toBeInTheDocument()
await screen.findByTestId("editor")

expect(screen.getByDisplayValue("Existing Title")).toBeInTheDocument()
})

test("submits article successfully and redirects", async () => {
test("submits article successfully", async () => {
const user = factories.user.user({
is_authenticated: true,
is_article_editor: true,
Expand All @@ -65,22 +65,30 @@ describe("ArticleEditPage", () => {
})
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")
await screen.findByTestId("editor")

const titleInput = await screen.findByPlaceholderText("Article title")

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

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

await userEvent.click(screen.getByText(/save article/i))
await userEvent.click(screen.getByRole("button", { name: "Save" }))

// ✅ Wait for redirect after update success
await waitFor(() => expect(pushMock).toHaveBeenCalledWith("/articles/123"))
await waitFor(() =>
expect(makeRequest).toHaveBeenCalledWith(
"patch",
urls.articles.details(article.id),
expect.objectContaining({ title: "Updated Title" }),
),
)

await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/articles/123"))
})

test("shows error alert on failure", async () => {
Expand All @@ -105,7 +113,6 @@ describe("ArticleEditPage", () => {
})
setMockResponse.get(urls.articles.details(article.id), article)

// ✅ Mock failed update (500)
setMockResponse.patch(
urls.articles.details(article.id),
{ detail: "Server Error" },
Expand All @@ -114,10 +121,12 @@ describe("ArticleEditPage", () => {

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

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

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

await userEvent.click(screen.getByText(/save article/i))
await userEvent.click(screen.getByRole("button", { name: "Save" }))

expect(await screen.findByText(/Mock Error/i)).toBeInTheDocument()
})
Expand Down
127 changes: 26 additions & 101 deletions frontends/main/src/app-pages/Articles/ArticleEditPage.tsx
Original file line number Diff line number Diff line change
@@ -1,125 +1,50 @@
"use client"
import React, { useEffect, useState } from "react"
import { Permission } from "api/hooks/user"

import React from "react"
import { useRouter } from "next-nprogress-bar"
import { useArticleDetail, useArticlePartialUpdate } from "api/hooks/articles"
import { Button, Alert } from "@mitodl/smoot-design"
import { notFound } from "next/navigation"
import { Permission } from "api/hooks/user"
import { useArticleDetail } from "api/hooks/articles"
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
import {
Container,
Typography,
styled,
LoadingSpinner,
TiptapEditorContainer,
JSONContent,
ArticleEditor,
HEADER_HEIGHT,
} 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 PageContainer = styled.div(({ theme }) => ({
color: theme.custom.colors.darkGray2,
display: "flex",
height: `calc(100vh - ${HEADER_HEIGHT}px - 132px)`,
}))

const ArticleEditPage = ({ articleId }: { articleId: string }) => {
const {
data: article,
isLoading,
isFetching,
} = useArticleDetail(Number(articleId))
const router = useRouter()

const id = Number(articleId)
const { data: article, isLoading } = useArticleDetail(id)

const [title, setTitle] = useState<string>("")
const [json, setJson] = useState<JSONContent>({
type: "doc",
content: [{ type: "paragraph", content: [] }],
})
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)
setJson(article.content)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [article])

if (isLoading) {
if (isLoading || isFetching) {
return <LoadingSpinner color="inherit" loading={isLoading} size={32} />
}
if (!article) {
return notFound()
}

const handleChange = (json: object) => {
setJson(json)
}

return (
<RestrictedRoute requires={Permission.ArticleEditor}>
<Container>
<Typography variant="h3" component="h1">
Edit Article
</Typography>
{alertText && (
<Alert
key={alertText}
severity="error"
className="info-alert"
closable
>
<Typography variant="body2" color="textPrimary">
{alertText}
</Typography>
</Alert>
)}

<ClientContainer>
<TiptapEditorContainer
data-testid="editor"
value={json}
onChange={handleChange}
title={title}
setTitle={(e) => {
setTitle(e.target.value)
setAlertText("")
}}
/>
</ClientContainer>

<SaveButton>
<Button
variant="primary"
disabled={isPending || !title.trim()}
onClick={handleSave}
>
{isPending ? "Saving..." : "Save Article"}
</Button>
</SaveButton>
</Container>
<PageContainer>
<ArticleEditor
article={article}
onSave={(article) => {
router.push(articlesView(article.id))
}}
/>
</PageContainer>
</RestrictedRoute>
)
}
Expand Down
Loading
Loading