diff --git a/.gitignore b/.gitignore index 0e1605fad4..efd94139b2 100644 --- a/.gitignore +++ b/.gitignore @@ -147,3 +147,5 @@ backups/ /playwright/.cache/ .claude + +load_testing/data/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fb9b5cd8df..bfbb41ee12 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -90,7 +90,7 @@ repos: - "config/keycloak/realms/ol-local-realm.json" additional_dependencies: ["gibberish-detector"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.15.12" + rev: "v0.15.14" hooks: - id: ruff-format - id: ruff diff --git a/RELEASE.rst b/RELEASE.rst index 424a4b68b2..c2c082d6ab 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,19 @@ Release Notes ============= +Version 0.68.7 +-------------- + +- Send rejected event so Leek picks it up (#3374) +- Update styled enrolled button not to show arrow (#3372) +- Add authentication to load tests (#3246) +- refactor: make internal editor code more generic (#3344) +- Update dependency pyarrow to v23 [SECURITY] (#3358) +- Task rate limits (#3363) +- [pre-commit.ci] pre-commit autoupdate (#3370) +- Product page: list prices and actual prices (#3346) +- [pre-commit.ci] pre-commit autoupdate (#3352) + Version 0.68.5 (Released May 26, 2026) -------------- diff --git a/frontends/api/package.json b/frontends/api/package.json index d76f4d52a5..a229531b38 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -29,7 +29,7 @@ "ol-test-utilities": "0.0.0" }, "dependencies": { - "@mitodl/mitxonline-api-axios": "2026.5.1", + "@mitodl/mitxonline-api-axios": "2026.5.14", "@tanstack/react-query": "^5.66.0", "axios": "^1.12.2", "tiny-invariant": "^1.3.3" diff --git a/frontends/api/src/hooks/articles/queries.ts b/frontends/api/src/hooks/articles/queries.ts deleted file mode 100644 index 38d7193479..0000000000 --- a/frontends/api/src/hooks/articles/queries.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { queryOptions } from "@tanstack/react-query" -import { websiteContentApi } from "../../clients" -import type { WebsiteContentApiWebsiteContentListRequest as ArticleListRequest } from "../../generated/v1" - -const articleKeys = { - root: ["articles"], - listRoot: () => [...articleKeys.root, "list"], - list: (params: ArticleListRequest) => [...articleKeys.listRoot(), params], - detailRoot: () => [...articleKeys.root, "detail"], - detail: (id: number) => [...articleKeys.detailRoot(), id], - articlesDetailRetrieve: (identifier: string) => [ - ...articleKeys.detailRoot(), - identifier, - ], -} - -const articleQueries = { - list: (params: ArticleListRequest) => - queryOptions({ - queryKey: articleKeys.list(params), - queryFn: () => - websiteContentApi.websiteContentList(params).then((res) => res.data), - }), - detail: (id: number) => - queryOptions({ - queryKey: articleKeys.detail(id), - queryFn: () => - websiteContentApi - .websiteContentRetrieve({ id }) - .then((res) => res.data), - }), - articlesDetailRetrieve: (identifier: string) => - queryOptions({ - queryKey: articleKeys.articlesDetailRetrieve(identifier), - queryFn: () => - websiteContentApi - .websiteContentDetailRetrieve({ identifier }) - .then((res) => res.data), - }), -} - -export { articleQueries, articleKeys } diff --git a/frontends/api/src/hooks/user/index.ts b/frontends/api/src/hooks/user/index.ts index 603246e399..be8c99e3ee 100644 --- a/frontends/api/src/hooks/user/index.ts +++ b/frontends/api/src/hooks/user/index.ts @@ -3,6 +3,10 @@ import type { User } from "../../generated/v0/api" import { userQueries } from "./queries" enum Permission { + /** + * Controls access to all website_content types (both "news" and "article" + * content_type). Despite the name, this is not limited to article content. + */ ArticleEditor = "is_article_editor", Authenticated = "is_authenticated", LearningPathEditor = "is_learning_path_editor", diff --git a/frontends/api/src/hooks/articles/index.test.ts b/frontends/api/src/hooks/website_content/index.test.ts similarity index 66% rename from frontends/api/src/hooks/articles/index.test.ts rename to frontends/api/src/hooks/website_content/index.test.ts index 43f8c6f037..7c031cfb85 100644 --- a/frontends/api/src/hooks/articles/index.test.ts +++ b/frontends/api/src/hooks/website_content/index.test.ts @@ -1,16 +1,16 @@ import { renderHook, waitFor } from "@testing-library/react" import { setupReactQueryTest } from "../test-utils" -import { articleKeys } from "./queries" +import { websiteContentKeys } from "./queries" import { setMockResponse, urls, makeRequest } from "../../test-utils" import { UseQueryResult } from "@tanstack/react-query" -import { articles as factory } from "../../test-utils/factories" +import { websiteContent as factory } from "../../test-utils/factories" import { - useArticleList, - useArticleDetail, - useArticleCreate, - useArticlePartialUpdate, - useArticleDestroy, + useWebsiteContentList, + useWebsiteContentDetail, + useWebsiteContentCreate, + useWebsiteContentPartialUpdate, + useWebsiteContentDestroy, } from "./index" /** @@ -28,86 +28,86 @@ const assertApiCalled = async ( expect(result.current.data).toEqual(data) } -describe("useArticleList", () => { +describe("useWebsiteContentList", () => { it.each([undefined, { limit: 5 }, { limit: 5, offset: 10 }])( "Calls the correct API", async (params) => { - const data = factory.articles({ count: 3 }) + const data = factory.websiteContents({ count: 3 }) const url = urls.websiteContent.list(params) const { wrapper } = setupReactQueryTest() setMockResponse.get(url, data) - const useTestHook = () => useArticleList(params) + const useTestHook = () => useWebsiteContentList(params) const { result } = renderHook(useTestHook, { wrapper }) assertApiCalled(result, url, "GET", data) }, ) }) -describe("useArticleDetail", () => { +describe("useWebsiteContentDetail", () => { it("Calls the correct API", async () => { - const data = factory.article() + const data = factory.websiteContent() const url = urls.websiteContent.details(data.id) const { wrapper } = setupReactQueryTest() setMockResponse.get(url, data) - const useTestHook = () => useArticleDetail(data.id) + const useTestHook = () => useWebsiteContentDetail(data.id) const { result } = renderHook(useTestHook, { wrapper }) assertApiCalled(result, url, "GET", data) }) }) -describe("Article CRUD", () => { - test("useArticleCreate calls correct API", async () => { +describe("Website Content CRUD", () => { + test("useWebsiteContentCreate calls correct API", async () => { const url = urls.websiteContent.list() - const data = factory.article() - const { id, ...requestData } = factory.article() + const data = factory.websiteContent() + const { id, ...requestData } = factory.websiteContent() setMockResponse.post(url, data) const { wrapper, queryClient } = setupReactQueryTest() jest.spyOn(queryClient, "invalidateQueries") - const { result } = renderHook(useArticleCreate, { wrapper }) + const { result } = renderHook(useWebsiteContentCreate, { wrapper }) result.current.mutate(requestData) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(makeRequest).toHaveBeenCalledWith("post", url, requestData) expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: articleKeys.listRoot(), + queryKey: websiteContentKeys.listRoot(), }) }) - test("useArticlePartialUpdate calls correct API", async () => { - const article = factory.article() + test("useWebsiteContentPartialUpdate calls correct API", async () => { + const article = factory.websiteContent() const url = urls.websiteContent.details(article.id) setMockResponse.patch(url, article) const { wrapper, queryClient } = setupReactQueryTest() jest.spyOn(queryClient, "invalidateQueries") - const { result } = renderHook(useArticlePartialUpdate, { wrapper }) + const { result } = renderHook(useWebsiteContentPartialUpdate, { wrapper }) result.current.mutate(article) await waitFor(() => expect(result.current.isSuccess).toBe(true)) const { id, ...patchData } = article expect(makeRequest).toHaveBeenCalledWith("patch", url, patchData) expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: articleKeys.detail(article.id), + queryKey: websiteContentKeys.detail(article.id), }) }) - test("useArticleDestroy calls correct API", async () => { - const { id } = factory.article() + test("useWebsiteContentDestroy calls correct API", async () => { + const { id } = factory.websiteContent() const url = urls.websiteContent.details(id) setMockResponse.delete(url, null) const { wrapper, queryClient } = setupReactQueryTest() jest.spyOn(queryClient, "invalidateQueries") - const { result } = renderHook(useArticleDestroy, { wrapper }) + const { result } = renderHook(useWebsiteContentDestroy, { wrapper }) result.current.mutate(id) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(makeRequest).toHaveBeenCalledWith("delete", url, undefined) expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: articleKeys.listRoot(), + queryKey: websiteContentKeys.listRoot(), }) }) }) diff --git a/frontends/api/src/hooks/articles/index.ts b/frontends/api/src/hooks/website_content/index.ts similarity index 63% rename from frontends/api/src/hooks/articles/index.ts rename to frontends/api/src/hooks/website_content/index.ts index 65b92ea258..adeedce1fb 100644 --- a/frontends/api/src/hooks/articles/index.ts +++ b/frontends/api/src/hooks/website_content/index.ts @@ -4,17 +4,17 @@ import type { AxiosProgressEvent } from "axios" import { websiteContentApi, mediaApi } from "../../clients" import type { - WebsiteContentApiWebsiteContentListRequest as ArticleListRequest, - WebsiteContent as Article, + WebsiteContentApiWebsiteContentListRequest as WebsiteContentListRequest, + WebsiteContent, } from "../../generated/v1" -import { articleQueries, articleKeys } from "./queries" +import { websiteContentQueries, websiteContentKeys } from "./queries" -const useArticleList = ( - params: ArticleListRequest = {}, +const useWebsiteContentList = ( + params: WebsiteContentListRequest = {}, opts?: { enabled?: boolean }, ) => { return useQuery({ - ...articleQueries.list(params), + ...websiteContentQueries.list(params), ...opts, }) } @@ -22,26 +22,26 @@ const useArticleList = ( /** * Query is disabled if id is undefined. */ -const useArticleDetail = (id: number | undefined) => { +const useWebsiteContentDetail = (id: number | undefined) => { return useQuery({ - ...articleQueries.detail(id ?? -1), + ...websiteContentQueries.detail(id ?? -1), enabled: id !== undefined, }) } -const useArticleDetailRetrieve = (identifier: string | undefined) => { +const useWebsiteContentDetailRetrieve = (identifier: string | undefined) => { return useQuery({ - ...articleQueries.articlesDetailRetrieve(identifier ?? ""), + ...websiteContentQueries.websiteContentDetailRetrieve(identifier ?? ""), enabled: identifier !== undefined, }) } -const useArticleCreate = () => { +const useWebsiteContentCreate = () => { const client = useQueryClient() return useMutation({ mutationFn: ( data: Omit< - Article, + WebsiteContent, "id" | "user" | "created_on" | "updated_on" | "publish_date" >, ) => @@ -49,7 +49,7 @@ const useArticleCreate = () => { .websiteContentCreate({ WebsiteContentRequest: data }) .then((response) => response.data), onSuccess: () => { - client.invalidateQueries({ queryKey: articleKeys.listRoot() }) + client.invalidateQueries({ queryKey: websiteContentKeys.listRoot() }) }, }) } @@ -97,41 +97,46 @@ export const useMediaUpload = () => { } } -const useArticleDestroy = () => { +const useWebsiteContentDestroy = () => { const client = useQueryClient() return useMutation({ mutationFn: (id: number) => websiteContentApi.websiteContentDestroy({ id }), onSuccess: () => { - client.invalidateQueries({ queryKey: articleKeys.listRoot() }) + client.invalidateQueries({ queryKey: websiteContentKeys.listRoot() }) }, }) } -const useArticlePartialUpdate = () => { +const useWebsiteContentPartialUpdate = () => { const client = useQueryClient() return useMutation({ - mutationFn: ({ id, ...data }: Partial
& Pick) => + mutationFn: ({ + id, + ...data + }: Partial & Pick) => websiteContentApi .websiteContentPartialUpdate({ id, PatchedWebsiteContentRequest: data, }) .then((response) => response.data), - onSuccess: (article: Article) => { - client.invalidateQueries({ queryKey: articleKeys.detail(article.id) }) - const identifier = article.slug || article.id.toString() + onSuccess: (websiteContent: WebsiteContent) => { client.invalidateQueries({ - queryKey: articleKeys.articlesDetailRetrieve(identifier), + queryKey: websiteContentKeys.detail(websiteContent.id), + }) + const identifier = websiteContent.slug || websiteContent.id.toString() + client.invalidateQueries({ + queryKey: websiteContentKeys.websiteContentDetailRetrieve(identifier), }) }, }) } export { - useArticleList, - useArticleDetail, - useArticleCreate, - useArticleDestroy, - useArticlePartialUpdate, - articleQueries, - useArticleDetailRetrieve, + useWebsiteContentList, + useWebsiteContentDetail, + useWebsiteContentCreate, + useWebsiteContentDestroy, + useWebsiteContentPartialUpdate, + websiteContentQueries, + useWebsiteContentDetailRetrieve, } diff --git a/frontends/api/src/hooks/website_content/queries.ts b/frontends/api/src/hooks/website_content/queries.ts new file mode 100644 index 0000000000..35085ab650 --- /dev/null +++ b/frontends/api/src/hooks/website_content/queries.ts @@ -0,0 +1,45 @@ +import { queryOptions } from "@tanstack/react-query" +import { websiteContentApi } from "../../clients" +import type { WebsiteContentApiWebsiteContentListRequest as WebsiteContentListRequest } from "../../generated/v1" + +const websiteContentKeys = { + root: ["website_content"], + listRoot: () => [...websiteContentKeys.root, "list"], + list: (params: WebsiteContentListRequest) => [ + ...websiteContentKeys.listRoot(), + params, + ], + detailRoot: () => [...websiteContentKeys.root, "detail"], + detail: (id: number) => [...websiteContentKeys.detailRoot(), id], + websiteContentDetailRetrieve: (identifier: string) => [ + ...websiteContentKeys.detailRoot(), + identifier, + ], +} + +const websiteContentQueries = { + list: (params: WebsiteContentListRequest) => + queryOptions({ + queryKey: websiteContentKeys.list(params), + queryFn: () => + websiteContentApi.websiteContentList(params).then((res) => res.data), + }), + detail: (id: number) => + queryOptions({ + queryKey: websiteContentKeys.detail(id), + queryFn: () => + websiteContentApi + .websiteContentRetrieve({ id }) + .then((res) => res.data), + }), + websiteContentDetailRetrieve: (identifier: string) => + queryOptions({ + queryKey: websiteContentKeys.websiteContentDetailRetrieve(identifier), + queryFn: () => + websiteContentApi + .websiteContentDetailRetrieve({ identifier }) + .then((res) => res.data), + }), +} + +export { websiteContentQueries, websiteContentKeys } diff --git a/frontends/api/src/mitxonline/test-utils/factories/programs.ts b/frontends/api/src/mitxonline/test-utils/factories/programs.ts index 694af34a85..0836dcb6cb 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/programs.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/programs.ts @@ -40,6 +40,7 @@ const program: PartialFactory = (overrides = {}) => { length: `${faker.number.int({ min: 1, max: 12 })} weeks`, effort: `${faker.number.int({ min: 1, max: 10 })} hours/week`, price: faker.commerce.price(), + list_price: faker.commerce.price(), }, program_type: faker.helpers.arrayElement([ "certificate", diff --git a/frontends/api/src/test-utils/factories/index.ts b/frontends/api/src/test-utils/factories/index.ts index 53e0733990..d349f89087 100644 --- a/frontends/api/src/test-utils/factories/index.ts +++ b/frontends/api/src/test-utils/factories/index.ts @@ -1,4 +1,4 @@ -export * as articles from "./articles" +export * as websiteContent from "./websiteContent" export * as channels from "./channels" export * as hubspot from "./hubspot" export * as learningResources from "./learningResources" diff --git a/frontends/api/src/test-utils/factories/articles.ts b/frontends/api/src/test-utils/factories/websiteContent.ts similarity index 80% rename from frontends/api/src/test-utils/factories/articles.ts rename to frontends/api/src/test-utils/factories/websiteContent.ts index 2583690c91..9e19593357 100644 --- a/frontends/api/src/test-utils/factories/articles.ts +++ b/frontends/api/src/test-utils/factories/websiteContent.ts @@ -3,7 +3,7 @@ import { makePaginatedFactory } from "ol-test-utilities" import type { Factory } from "ol-test-utilities" import type { WebsiteContent } from "../../generated/v1" -const article: Factory = (overrides = {}) => ({ +const websiteContent: Factory = (overrides = {}) => ({ id: faker.number.int(), title: faker.lorem.sentence(), content: { @@ -25,6 +25,6 @@ const article: Factory = (overrides = {}) => ({ ...overrides, }) -const articles = makePaginatedFactory(article) +const websiteContents = makePaginatedFactory(websiteContent) -export { article, articles } +export { websiteContent, websiteContents } diff --git a/frontends/main/next.config.js b/frontends/main/next.config.js index 303a8b29f4..ec6918ed40 100644 --- a/frontends/main/next.config.js +++ b/frontends/main/next.config.js @@ -57,18 +57,6 @@ const nextConfig = { destination: "/enrollmentcode/:code", permanent: true, }, - { - // can be removed once fastly redirect is in place - source: "/articles/:slug*", - destination: "/news/:slug*", - permanent: true, - }, - { - // can be removed once fastly redirect is in place - source: "/articles", - destination: "/news", - permanent: true, - }, ] }, diff --git a/frontends/main/package.json b/frontends/main/package.json index 6774f02fb2..02c587c5a9 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -14,7 +14,7 @@ "@emotion/styled": "^11.11.0", "@floating-ui/react": "^0.27.16", "@mitodl/course-search-utils": "^3.5.2", - "@mitodl/mitxonline-api-axios": "2026.5.1", + "@mitodl/mitxonline-api-axios": "2026.5.14", "@mitodl/smoot-design": "^6.27.0", "@mui/material": "^6.4.5", "@mui/material-nextjs": "^6.4.3", diff --git a/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx b/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx deleted file mode 100644 index 5a0bc49ed5..0000000000 --- a/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client" - -import React from "react" -import { useArticleDetailRetrieve } from "api/hooks/articles" -import { LoadingSpinner, styled } from "ol-components" -import { ArticleEditor } from "@/page-components/TiptapEditor/ArticleEditor" -import { notFound } from "next/navigation" -import { LearningResourceProvider } from "@/page-components/TiptapEditor/extensions/node/LearningResource/LearningResourceDataProvider" - -const PageContainer = styled.div({ - display: "flex", - height: "100%", -}) - -const Spinner = styled(LoadingSpinner)({ - margin: "auto", - position: "absolute", - top: "40%", - left: "50%", - transform: "translate(-50%, -50%)", -}) - -export const ArticleDetailPage = ({ - articleId, - learningResourceIds = [], -}: { - articleId: string - learningResourceIds?: number[] -}) => { - const { data: article, isLoading } = useArticleDetailRetrieve(articleId) - - if (isLoading) { - return ( - - - - ) - } - if (!article) { - return notFound() - } - - return ( - - - - - - ) -} diff --git a/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx b/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx deleted file mode 100644 index aaab2df643..0000000000 --- a/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client" - -import React from "react" -import { useRouter } from "next-nprogress-bar" -import { notFound } from "next/navigation" -import { Permission } from "api/hooks/user" -import { useArticleDetailRetrieve } from "api/hooks/articles" -import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" -import { styled, LoadingSpinner } from "ol-components" -import { ArticleEditor } from "@/page-components/TiptapEditor/ArticleEditor" -import { articlesView, articlesDraftView } from "@/common/urls" -import invariant from "tiny-invariant" - -const PageContainer = styled.div(({ theme }) => ({ - color: theme.custom.colors.darkGray2, - display: "flex", - height: "100%", -})) - -const Spinner = styled(LoadingSpinner)({ - margin: "auto", - position: "absolute", - top: "40%", - left: "50%", - transform: "translate(-50%, -50%)", -}) - -const ArticleEditPage = ({ articleId }: { articleId: string }) => { - const { - data: article, - isLoading, - isFetching, - } = useArticleDetailRetrieve(articleId) - const router = useRouter() - - if (isLoading || isFetching) { - return - } - if (!article) { - return notFound() - } - - return ( - - - { - if (article.is_published) { - invariant(article.slug, "Published article must have a slug") - return router.push(articlesView(article.slug)) - } else { - router.push(articlesDraftView(String(article.id))) - } - }} - /> - - - ) -} - -export { ArticleEditPage } diff --git a/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx b/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx index 4386640b6b..ff96d8da45 100644 --- a/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx +++ b/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx @@ -1,33 +1,33 @@ "use client" -import React from "react" -import Image from "next/image" -import { useSearchParams } from "@mitodl/course-search-utils/next" +import React, { useState, useRef, useEffect } from "react" import { Container, styled, theme, - Typography, Grid2, + Card, Pagination, PaginationItem, - css, LoadingSpinner, - PlainList, + Typography, } from "ol-components" -import Link from "next/link" +import { ButtonLink } from "@mitodl/smoot-design" +import { useWebsiteContentList } from "api/hooks/website_content" +import { useUserHasPermission, Permission } from "api/hooks/user" +import type { WebsiteContent } from "api/v1" +import { LocalDate } from "ol-utilities" import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react" +import { extractFirstImage } from "@/common/websiteContentUtils" import { - useNewsEventsList, - NewsEventsListFeedTypeEnum, -} from "api/hooks/newsEvents" -import type { NewsFeedItem } from "api/v0" -import { LocalDate } from "ol-utilities" -import { linkifyText } from "@/common/utils" -import { ArticleBanner } from "./ArticleBanner" + articleView, + ARTICLES_LISTING, + websiteContentCreateView, +} from "@/common/urls" const PAGE_SIZE = 20 const MAX_PAGE = 50 +const ARTICLE_CREATE_URL = websiteContentCreateView("article") export const DEFAULT_BACKGROUND_IMAGE_URL = "/images/backgrounds/banner_background.webp" @@ -37,644 +37,146 @@ const getLastPage = (count: number): number => { return pages > MAX_PAGE ? MAX_PAGE : pages } -const Section = styled.section` - background: ${theme.custom.colors.white}; - padding: 80px 0; - ${theme.breakpoints.down("sm")} { - padding: 0; - } -` - -const FeaturedStorySection = styled.div` - max-width: 1000px; - margin: -290px auto 40px; - position: relative; - z-index: 10; - - ${theme.breakpoints.down("md")} { - margin: -250px auto 24px; - } - ${theme.breakpoints.down("sm")} { - margin: 24px auto; - max-width: 100%; - } -` - -const MainStoryCard = styled.div` +const PageHeader = styled.div` display: flex; - border-bottom: 1px solid ${theme.custom.colors.lightGray2}; - background: ${theme.custom.colors.darkGray2}; - border-top: 4px solid #a31f34; - border-radius: 10px; - - &:hover { - h2 { - text-decoration: underline; - } - } - - ${theme.breakpoints.down("sm")} { - flex-direction: column; - gap: 0; - } -` - -const MainStoryImage = styled.div` - width: 50%; - min-height: 400px; - background-color: ${theme.custom.colors.darkGray1}; - border-radius: 10px 0 0 10px; - overflow: hidden; - position: relative; - - ${theme.breakpoints.down("md")} { - min-height: 300px; - } - - ${theme.breakpoints.down("sm")} { - width: 100%; - aspect-ratio: 16 / 9; - min-height: auto; - border-radius: 10px 10px 0 0; - } -` - -const MainStoryContent = styled.div` - width: 50%; - display: flex; - flex-direction: column; - gap: 16px; - padding: 40px; - color: white; - justify-content: space-between; - - ${theme.breakpoints.down("md")} { - padding: 24px; - } - - ${theme.breakpoints.down("sm")} { - width: 100%; - padding: 24px; - } -` -const RegularStoryTitleWrapper = styled.div` - display: flex; - flex-direction: column; - gap: 10px; - - ${theme.breakpoints.down("sm")} { - gap: 8px; - } -` - -const MainStoryContentContainer = styled.div` - display: flex; - flex-direction: column; - gap: 24px; - ${theme.breakpoints.down("md")} { - gap: 16px; - } -` - -const MainStoryTitle = styled.h2` - color: ${theme.custom.colors.white}; - ${{ ...theme.typography.h3 }} - margin: 0; - - a { - color: ${theme.custom.colors.white}; - text-decoration: none; - cursor: pointer; - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; - - &:hover { - color: ${theme.custom.colors.white}; - } - } - - ${theme.breakpoints.down("md")} { - ${{ ...theme.typography.h5 }} - } - ${theme.breakpoints.down("sm")} { - ${{ ...theme.typography.h5 }} - } -` - -const MainStorySummary = styled.p` - color: ${theme.custom.colors.white}; - ${{ ...theme.typography.body1 }} - margin: 0; - line-height: 22px; - display: -webkit-box; - -webkit-line-clamp: 4; - -webkit-box-orient: vertical; - overflow: hidden; - overflow-wrap: break-word; - - a { - color: ${theme.custom.colors.white}; - text-decoration: underline; - - &:hover { - opacity: 0.8; - } - } - - ${theme.breakpoints.down("md")} { - ${{ ...theme.typography.body1 }} - } - ${theme.breakpoints.down("sm")} { - ${{ ...theme.typography.body2 }} - } -` - -const MainStoryDate = styled(Typography)` - color: ${theme.custom.colors.white}; - ${{ ...theme.typography.body3 }} - margin: 0; -` - -// Regular story card for grid -const StoryCard = styled.div` - display: flex; - flex-direction: row; - gap: 24px; - background: white; - border-radius: 8px; - padding: 16px 16px 16px 24px; - overflow: hidden; - border: 1px solid transparent; - - &:hover { - border-radius: 8px; - border: 1px solid ${theme.custom.colors.lightGray2}; - background: ${theme.custom.colors.white}; - box-shadow: 0 8px 20px 0 rgb(120 147 172 / 10%); - - h2 { - color: ${theme.custom.colors.red}; - } - } - - ${theme.breakpoints.down("sm")} { - flex-direction: row; - gap: 12px; - padding: 16px 0; - background: transparent; - border: none; - border-bottom: 1px solid ${theme.custom.colors.lightGray2}; - border-radius: 0; - - &:hover { - border: none; - border-bottom: 1px solid ${theme.custom.colors.lightGray2}; - box-shadow: none; - } - } -` - -const StoryImage = styled.div` - width: 280px; - min-width: 280px; - max-width: 280px; - height: 180px; - flex-shrink: 0; - background-color: ${theme.custom.colors.lightGray1}; - border-radius: 8px; - order: 2; - align-self: flex-end; - overflow: hidden; - position: relative; - - ${theme.breakpoints.down("sm")} { - width: 100px; - min-width: 100px; - max-width: 100px; - height: 80px; - order: 2; - align-self: flex-start; - border-radius: 4px; - } -` - -const StoryContent = styled.div` - display: flex; - flex-direction: column; + align-items: center; justify-content: space-between; - flex: 1; - order: 1; - min-height: 180px; - min-width: 0; - overflow: hidden; - - ${theme.breakpoints.down("sm")} { - order: 1; - min-height: auto; - min-width: 0; - justify-content: space-between; - gap: 8px; - } -` - -const StoryTitle = styled.h2` - color: ${theme.custom.colors.darkGray2}; - ${{ ...theme.typography.h5 }} - margin: 0; - margin-top: 16px; - - a { - color: ${theme.custom.colors.darkGray2}; - text-decoration: none; - cursor: pointer; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - - &:hover { - color: ${theme.custom.colors.red}; - } - } - - ${theme.breakpoints.down("md")} { - ${{ ...theme.typography.subtitle1 }} - } - - ${theme.breakpoints.down("sm")} { - ${{ ...theme.typography.subtitle2 }} - margin-top: 0; - -webkit-line-clamp: 3; - } + margin-bottom: 32px; ` -const StorySummary = styled.p` - color: ${theme.custom.colors.darkGray2}; - ${{ ...theme.typography.body2 }} - margin: 0; - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; - line-height: 1.5; - overflow-wrap: break-word; - - a { - color: ${theme.custom.colors.red}; - text-decoration: underline; - - &:hover { - opacity: 0.8; - } - } - - ${theme.breakpoints.down("sm")} { - ${{ ...theme.typography.body3 }} - -webkit-line-clamp: 2; - margin-top: 0; - color: ${theme.custom.colors.black}; - } -` - -const StoryDate = styled(Typography)` - color: ${theme.custom.colors.silverGrayDark}; - ${{ ...theme.typography.body3 }} - margin-bottom: 16px; - - ${theme.breakpoints.down("sm")} { - margin-bottom: 0; - margin-top: 0; - } -` - -const StyledSection = styled(Section)` - background: ${theme.custom.colors.lightGray1}; - - ul { - list-style: none; - } -` - -const GridContainer = styled.div` - max-width: 800px; - margin: 0 auto; - +const Section = styled.section` + background: ${theme.custom.colors.white}; + padding: 80px 0; ${theme.breakpoints.down("sm")} { - max-width: 100%; + padding: 40px 0; } ` -const MobileContent = styled.div` +const ArticleCardWrapper = styled(Card)` display: flex; - align-items: center; flex-direction: column; - gap: 40px; - margin: 40px 0; - - ${theme.breakpoints.down("sm")} { - margin: 0; - } + height: 100%; ` -const MobileContainer = styled.section` - width: 100%; - margin: 0 -16px; - - h3 { - margin: 0 16px 12px; - } -` - -const AboveMdOnly = styled.div(({ theme }) => ({ - [theme.breakpoints.down("sm")]: { - display: "none", - }, -})) - -const BelowMdOnly = styled.div(({ theme }) => ({ - [theme.breakpoints.up("sm")]: { - display: "none", - }, -})) - const PaginationContainer = styled.div` display: flex; justify-content: center; - margin-top: 24px; - - ${({ theme }) => theme.breakpoints.down("md")} { - margin-top: 16px; - margin-bottom: 24px; - } - - ul li button.Mui-selected { - ${({ theme }) => css({ ...theme.typography.subtitle1 })} - background-color: inherit; - } - - ul li button svg { - background-color: ${({ theme }) => theme.custom.colors.lightGray2}; - border-radius: 4px; - width: 1.5em; - height: 1.5em; - padding: 0.25em; - } -` - -const LoadingContainer = styled.div` - display: flex; - justify-content: center; - align-items: center; - min-height: 400px; - width: 100%; + margin-top: 40px; ` const EmptyState = styled.div` display: flex; flex-direction: column; - justify-content: center; align-items: center; + justify-content: center; min-height: 400px; - width: 100%; gap: 16px; - text-align: center; - padding: 40px 20px; - - ${theme.breakpoints.down("sm")} { - min-height: 300px; - padding: 24px 16px; - } ` -const ArticleBannerStyled = styled(ArticleBanner)<{ page: number }>( - ({ page, theme }) => ({ - padding: "48px 0", - paddingBottom: page === 1 ? "250px" : undefined, - position: "relative", - backgroundSize: "150% !important", - backgroundPosition: "center !important", - - "&::before": { - content: '""', - position: "absolute", - inset: 0, - background: "rgb(0 0 0 / 85%)", - zIndex: 1, - }, - - "& > *": { - position: "relative", - zIndex: 2, - }, - - [theme.breakpoints.down("md")]: { - paddingBottom: page === 1 ? "198px" : undefined, - }, - - [theme.breakpoints.down("sm")]: { - padding: "32px 0", - marginBottom: 0, - }, - }), -) - -const MainStory: React.FC<{ item: NewsFeedItem }> = ({ item }) => { - const [imageError, setImageError] = React.useState(false) - - return ( - - - {item.image?.url && !imageError && ( - - {item.image.alt setImageError(true)} - /> - - )} - - - - - {item.title} - - {item.summary && ( - - )} - - - - - - - ) -} +const ArticleCard: React.FC<{ article: WebsiteContent }> = ({ article }) => { + const articleUrl = article.is_published + ? articleView(article.slug || String(article.id)) + : `${ARTICLES_LISTING}${article.id}/draft` -const RegularStory: React.FC<{ item: NewsFeedItem }> = ({ item }) => { - const [imageError, setImageError] = React.useState(false) + const imageUrl = extractFirstImage(article.content) return ( - - - - - {item.title} - - {item.summary && ( - - )} - - - - - - - - {item.image?.url && !imageError && ( - {item.image.alt setImageError(true)} - /> - )} - - - + + + + {article.title} + + + + + ) } const ArticleListingPage: React.FC = () => { - const [searchParams, setSearchParams] = useSearchParams() - const page = parseInt(searchParams.get("page") ?? "1", 10) + const [page, setPage] = useState(1) + const scrollRef = useRef(null) + const canCreateArticle = useUserHasPermission(Permission.ArticleEditor) - const { data: news, isLoading } = useNewsEventsList({ - feed_type: [NewsEventsListFeedTypeEnum.News], + const { data: articles, isLoading } = useWebsiteContentList({ limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, - sortby: "-news_date", + content_type: "article", }) - const stories = news?.results ?? [] + useEffect(() => { + if (page > 1 && scrollRef.current) { + scrollRef.current.scrollIntoView({ behavior: "smooth", block: "start" }) + } + }, [page]) - // On page 1, first story is featured, rest are grid stories - // On other pages, all stories are grid stories - const mainStory = - page === 1 && stories.length > 0 ? (stories[0] as NewsFeedItem) : null - const gridStories = page === 1 ? stories.slice(1) : stories + const results = articles?.results + const totalPages = articles?.count ? getLastPage(articles.count) : 0 return ( - <> - - - - - {isLoading ? ( - - - - ) : stories.length === 0 ? ( - - No News Available - - There are no news to display at this time. Please check back - later. - - - ) : ( - <> - - - - {page === 1 && mainStory && ( - - - - )} - - {gridStories.map((item) => ( -
  • - -
  • - ))} -
    -
    -
    -
    - - - {/* Main Story Section: Only visible on page 1 */} - {page === 1 && mainStory && ( - - - - )} - - {/* Grid Section: Other articles */} - {gridStories.length > 0 ? ( - - - {gridStories.map((item) => ( - - - - ))} - - - ) : null} - - - )} -
    - - {!isLoading && gridStories.length > 0 && ( - - - { - setSearchParams((current) => { - const copy = new URLSearchParams(current) - if (newPage === 1) { - copy.delete("page") - } else { - copy.set("page", newPage.toString()) - } - return copy - }) - }} - renderItem={(item) => ( - - )} - /> - - +
    + + + Articles + {canCreateArticle ? ( + + New Article + + ) : null} + + + {isLoading ? ( + + ) : results && results.length > 0 ? ( + <> + + {results.map((article) => ( + + + + ))} + + + {totalPages > 1 && ( + + setPage(newPage)} + renderItem={(item) => ( + + )} + /> + + )} + + ) : ( + + No Articles Yet + + Get started by creating your first article. + + {canCreateArticle ? ( + + New Article + + ) : null} + )} - - + +
    ) } diff --git a/frontends/main/src/app-pages/Articles/ArticleNewPage.tsx b/frontends/main/src/app-pages/Articles/ArticleNewPage.tsx deleted file mode 100644 index 8cbdd98489..0000000000 --- a/frontends/main/src/app-pages/Articles/ArticleNewPage.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client" - -import React from "react" -import { useRouter } from "next-nprogress-bar" -import { Permission } from "api/hooks/user" -import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" -import { styled } from "ol-components" -import { ArticleEditor } from "@/page-components/TiptapEditor/ArticleEditor" -import { articlesDraftView, articlesView } from "@/common/urls" -import invariant from "tiny-invariant" - -const PageContainer = styled.div(({ theme }) => ({ - color: theme.custom.colors.darkGray2, - display: "flex", - height: "100%", -})) - -const ArticleNewPage: React.FC = () => { - const router = useRouter() - - return ( - - - { - if (article.is_published) { - invariant(article.slug, "Published article must have a slug") - return router.push(articlesView(article.slug)) - } else { - router.push(articlesDraftView(String(article.id))) - } - }} - /> - - - ) -} - -export { ArticleNewPage } diff --git a/frontends/main/src/app-pages/Articles/ArticleBanner.tsx b/frontends/main/src/app-pages/News/NewsBanner.tsx similarity index 94% rename from frontends/main/src/app-pages/Articles/ArticleBanner.tsx rename to frontends/main/src/app-pages/News/NewsBanner.tsx index 3d13a333b3..cf3acc519f 100644 --- a/frontends/main/src/app-pages/Articles/ArticleBanner.tsx +++ b/frontends/main/src/app-pages/News/NewsBanner.tsx @@ -50,7 +50,7 @@ const BannerDescription = styled(Typography)` } ` -interface ArticleBannerProps { +interface NewsBannerProps { title: string description: string currentBreadcrumb?: string @@ -58,7 +58,7 @@ interface ArticleBannerProps { className?: string } -const ArticleBanner: React.FC = ({ +const NewsBanner: React.FC = ({ title, description, currentBreadcrumb = "MIT News", @@ -87,4 +87,4 @@ const ArticleBanner: React.FC = ({ ) } -export { ArticleBanner } +export { NewsBanner } diff --git a/frontends/main/src/app-pages/Articles/ArticleListingPage.test.tsx b/frontends/main/src/app-pages/News/NewsListingPage.test.tsx similarity index 87% rename from frontends/main/src/app-pages/Articles/ArticleListingPage.test.tsx rename to frontends/main/src/app-pages/News/NewsListingPage.test.tsx index b6f6d3cf4e..ef1140b62e 100644 --- a/frontends/main/src/app-pages/Articles/ArticleListingPage.test.tsx +++ b/frontends/main/src/app-pages/News/NewsListingPage.test.tsx @@ -1,5 +1,5 @@ import React from "react" -import { ArticleListingPage } from "./ArticleListingPage" +import { NewsListingPage } from "./NewsListingPage" import { urls, setMockResponse } from "api/test-utils" import type { NewsFeedItem } from "api/v0" import { newsEvents } from "api/test-utils/factories" @@ -19,7 +19,7 @@ jest.mock("@/common/useFeatureFlagsLoaded") const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled) const mockedUseFeatureFlagsLoaded = jest.mocked(useFeatureFlagsLoaded) -describe("ArticleListingPage", () => { +describe("NewsListingPage", () => { beforeEach(() => { mockedUseFeatureFlagEnabled.mockReturnValue(true) mockedUseFeatureFlagsLoaded.mockReturnValue(true) @@ -36,13 +36,13 @@ describe("ArticleListingPage", () => { test("displays loading spinner on initial load", () => { setupAPI(0) - renderWithProviders() + renderWithProviders() expect(screen.getByRole("progressbar")).toBeInTheDocument() }) test("displays empty state when no articles are available", async () => { setupAPI(0) - renderWithProviders() + renderWithProviders() await screen.findByText("No News Available") @@ -55,7 +55,7 @@ describe("ArticleListingPage", () => { test("displays main news and grid stories on desktop", async () => { const news = setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -73,18 +73,18 @@ describe("ArticleListingPage", () => { ) }) - test("displays article banner with correct title and description", async () => { + test("displays News banner with correct title and description", async () => { setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.getByRole("heading", { name: "News" })).toBeInTheDocument() }) }) - test("displays article images when available", async () => { + test("displays News images when available", async () => { const news = setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -95,38 +95,38 @@ describe("ArticleListingPage", () => { expect(images.length).toBeGreaterThan(0) }) - test("displays article publish dates", async () => { + test("displays News publish dates", async () => { const news = setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() }) // Verify that LocalDate component renders dates for articles - // Check that there are multiple date elements rendered (one per article) + // Check that there are multiple date elements rendered (one per news) const listItems = screen.getAllByRole("listitem") expect(listItems.length).toBeGreaterThan(0) // Verify that dates are present in the document - // The first article should have a publish date - const firstArticle = news.results[0] as NewsFeedItem - const firstArticleDate = firstArticle.news_details?.publish_date - if (firstArticleDate) { + // The first news should have a publish date + const firstNews = news.results[0] as NewsFeedItem + const firstNewsDate = firstNews.news_details?.publish_date + if (firstNewsDate) { // LocalDate component will format the date, so check for parts of the date const dateElements = screen.getAllByText((content, element) => { return ( element?.tagName.toLowerCase() === "time" || - content.includes(new Date(firstArticleDate).getFullYear().toString()) + content.includes(new Date(firstNewsDate).getFullYear().toString()) ) }) expect(dateElements.length).toBeGreaterThan(0) } }) - test("links to article URLs correctly", async () => { + test("links to News URLs correctly", async () => { const news = setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -141,7 +141,7 @@ describe("ArticleListingPage", () => { expect(firstArticleLink).toHaveAttribute("href", news.results[0].url) }) - test("displays article summaries with HTML stripped", async () => { + test("displays News summaries with HTML stripped", async () => { const newsWithHtml = newsEvents.newsItems({ count: 1 }) // Summaries are now cleaned by the backend, so they come without HTML newsWithHtml.results[0].summary = "This is a test summary" @@ -151,7 +151,7 @@ describe("ArticleListingPage", () => { newsWithHtml, ) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -195,7 +195,7 @@ describe("ArticleListingPage", () => { secondPage, ) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -216,7 +216,7 @@ describe("ArticleListingPage", () => { const news = newsEvents.newsItems({ count: 100 }) setMockResponse.get(expect.stringContaining(urls.newsEvents.list()), news) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -233,7 +233,7 @@ describe("ArticleListingPage", () => { news.count = 1500 // Override to simulate 1500 total items setMockResponse.get(expect.stringContaining(urls.newsEvents.list()), news) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -274,7 +274,7 @@ describe("ArticleListingPage", () => { secondPage, ) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -300,7 +300,7 @@ describe("ArticleListingPage", () => { test("hides pagination when no articles", async () => { setupAPI(0) - renderWithProviders() + renderWithProviders() await screen.findByText("No News Available") @@ -330,7 +330,7 @@ describe("ArticleListingPage", () => { secondPage, ) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -344,7 +344,7 @@ describe("ArticleListingPage", () => { test("renders responsive mobile layout", async () => { const news = setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -358,7 +358,7 @@ describe("ArticleListingPage", () => { test("calculates grid stories correctly for page 1", async () => { const news = setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() diff --git a/frontends/main/src/app-pages/News/NewsListingPage.tsx b/frontends/main/src/app-pages/News/NewsListingPage.tsx new file mode 100644 index 0000000000..abd33ec626 --- /dev/null +++ b/frontends/main/src/app-pages/News/NewsListingPage.tsx @@ -0,0 +1,681 @@ +"use client" + +import React from "react" +import Image from "next/image" +import { useSearchParams } from "@mitodl/course-search-utils/next" +import { + Container, + styled, + theme, + Typography, + Grid2, + Pagination, + PaginationItem, + css, + LoadingSpinner, + PlainList, +} from "ol-components" +import Link from "next/link" +import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react" +import { + useNewsEventsList, + NewsEventsListFeedTypeEnum, +} from "api/hooks/newsEvents" +import type { NewsFeedItem } from "api/v0" +import { LocalDate } from "ol-utilities" +import { linkifyText } from "@/common/utils" +import { NewsBanner } from "./NewsBanner" + +const PAGE_SIZE = 20 +const MAX_PAGE = 50 + +export const DEFAULT_BACKGROUND_IMAGE_URL = + "/images/backgrounds/banner_background.webp" + +const getLastPage = (count: number): number => { + const pages = Math.ceil(count / PAGE_SIZE) + return pages > MAX_PAGE ? MAX_PAGE : pages +} + +const Section = styled.section` + background: ${theme.custom.colors.white}; + padding: 80px 0; + ${theme.breakpoints.down("sm")} { + padding: 0; + } +` + +const FeaturedStorySection = styled.div` + max-width: 1000px; + margin: -290px auto 40px; + position: relative; + z-index: 10; + + ${theme.breakpoints.down("md")} { + margin: -250px auto 24px; + } + ${theme.breakpoints.down("sm")} { + margin: 24px auto; + max-width: 100%; + } +` + +const MainStoryCard = styled.div` + display: flex; + border-bottom: 1px solid ${theme.custom.colors.lightGray2}; + background: ${theme.custom.colors.darkGray2}; + border-top: 4px solid #a31f34; + border-radius: 10px; + + &:hover { + h2 { + text-decoration: underline; + } + } + + ${theme.breakpoints.down("sm")} { + flex-direction: column; + gap: 0; + } +` + +const MainStoryImage = styled.div` + width: 50%; + min-height: 400px; + background-color: ${theme.custom.colors.darkGray1}; + border-radius: 10px 0 0 10px; + overflow: hidden; + position: relative; + + ${theme.breakpoints.down("md")} { + min-height: 300px; + } + + ${theme.breakpoints.down("sm")} { + width: 100%; + aspect-ratio: 16 / 9; + min-height: auto; + border-radius: 10px 10px 0 0; + } +` + +const MainStoryContent = styled.div` + width: 50%; + display: flex; + flex-direction: column; + gap: 16px; + padding: 40px; + color: white; + justify-content: space-between; + + ${theme.breakpoints.down("md")} { + padding: 24px; + } + + ${theme.breakpoints.down("sm")} { + width: 100%; + padding: 24px; + } +` +const RegularStoryTitleWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 10px; + + ${theme.breakpoints.down("sm")} { + gap: 8px; + } +` + +const MainStoryContentContainer = styled.div` + display: flex; + flex-direction: column; + gap: 24px; + ${theme.breakpoints.down("md")} { + gap: 16px; + } +` + +const MainStoryTitle = styled.h2` + color: ${theme.custom.colors.white}; + ${{ ...theme.typography.h3 }} + margin: 0; + + a { + color: ${theme.custom.colors.white}; + text-decoration: none; + cursor: pointer; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + + &:hover { + color: ${theme.custom.colors.white}; + } + } + + ${theme.breakpoints.down("md")} { + ${{ ...theme.typography.h5 }} + } + ${theme.breakpoints.down("sm")} { + ${{ ...theme.typography.h5 }} + } +` + +const MainStorySummary = styled.p` + color: ${theme.custom.colors.white}; + ${{ ...theme.typography.body1 }} + margin: 0; + line-height: 22px; + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: break-word; + + a { + color: ${theme.custom.colors.white}; + text-decoration: underline; + + &:hover { + opacity: 0.8; + } + } + + ${theme.breakpoints.down("md")} { + ${{ ...theme.typography.body1 }} + } + ${theme.breakpoints.down("sm")} { + ${{ ...theme.typography.body2 }} + } +` + +const MainStoryDate = styled(Typography)` + color: ${theme.custom.colors.white}; + ${{ ...theme.typography.body3 }} + margin: 0; +` + +// Regular story card for grid +const StoryCard = styled.div` + display: flex; + flex-direction: row; + gap: 24px; + background: white; + border-radius: 8px; + padding: 16px 16px 16px 24px; + overflow: hidden; + border: 1px solid transparent; + + &:hover { + border-radius: 8px; + border: 1px solid ${theme.custom.colors.lightGray2}; + background: ${theme.custom.colors.white}; + box-shadow: 0 8px 20px 0 rgb(120 147 172 / 10%); + + h2 { + color: ${theme.custom.colors.red}; + } + } + + ${theme.breakpoints.down("sm")} { + flex-direction: row; + gap: 12px; + padding: 16px 0; + background: transparent; + border: none; + border-bottom: 1px solid ${theme.custom.colors.lightGray2}; + border-radius: 0; + + &:hover { + border: none; + border-bottom: 1px solid ${theme.custom.colors.lightGray2}; + box-shadow: none; + } + } +` + +const StoryImage = styled.div` + width: 280px; + min-width: 280px; + max-width: 280px; + height: 180px; + flex-shrink: 0; + background-color: ${theme.custom.colors.lightGray1}; + border-radius: 8px; + order: 2; + align-self: flex-end; + overflow: hidden; + position: relative; + + ${theme.breakpoints.down("sm")} { + width: 100px; + min-width: 100px; + max-width: 100px; + height: 80px; + order: 2; + align-self: flex-start; + border-radius: 4px; + } +` + +const StoryContent = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + flex: 1; + order: 1; + min-height: 180px; + min-width: 0; + overflow: hidden; + + ${theme.breakpoints.down("sm")} { + order: 1; + min-height: auto; + min-width: 0; + justify-content: space-between; + gap: 8px; + } +` + +const StoryTitle = styled.h2` + color: ${theme.custom.colors.darkGray2}; + ${{ ...theme.typography.h5 }} + margin: 0; + margin-top: 16px; + + a { + color: ${theme.custom.colors.darkGray2}; + text-decoration: none; + cursor: pointer; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + + &:hover { + color: ${theme.custom.colors.red}; + } + } + + ${theme.breakpoints.down("md")} { + ${{ ...theme.typography.subtitle1 }} + } + + ${theme.breakpoints.down("sm")} { + ${{ ...theme.typography.subtitle2 }} + margin-top: 0; + -webkit-line-clamp: 3; + } +` + +const StorySummary = styled.p` + color: ${theme.custom.colors.darkGray2}; + ${{ ...theme.typography.body2 }} + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.5; + overflow-wrap: break-word; + + a { + color: ${theme.custom.colors.red}; + text-decoration: underline; + + &:hover { + opacity: 0.8; + } + } + + ${theme.breakpoints.down("sm")} { + ${{ ...theme.typography.body3 }} + -webkit-line-clamp: 2; + margin-top: 0; + color: ${theme.custom.colors.black}; + } +` + +const StoryDate = styled(Typography)` + color: ${theme.custom.colors.silverGrayDark}; + ${{ ...theme.typography.body3 }} + margin-bottom: 16px; + + ${theme.breakpoints.down("sm")} { + margin-bottom: 0; + margin-top: 0; + } +` + +const StyledSection = styled(Section)` + background: ${theme.custom.colors.lightGray1}; + + ul { + list-style: none; + } +` + +const GridContainer = styled.div` + max-width: 800px; + margin: 0 auto; + + ${theme.breakpoints.down("sm")} { + max-width: 100%; + } +` + +const MobileContent = styled.div` + display: flex; + align-items: center; + flex-direction: column; + gap: 40px; + margin: 40px 0; + + ${theme.breakpoints.down("sm")} { + margin: 0; + } +` + +const MobileContainer = styled.section` + width: 100%; + margin: 0 -16px; + + h3 { + margin: 0 16px 12px; + } +` + +const AboveMdOnly = styled.div(({ theme }) => ({ + [theme.breakpoints.down("sm")]: { + display: "none", + }, +})) + +const BelowMdOnly = styled.div(({ theme }) => ({ + [theme.breakpoints.up("sm")]: { + display: "none", + }, +})) + +const PaginationContainer = styled.div` + display: flex; + justify-content: center; + margin-top: 24px; + + ${({ theme }) => theme.breakpoints.down("md")} { + margin-top: 16px; + margin-bottom: 24px; + } + + ul li button.Mui-selected { + ${({ theme }) => css({ ...theme.typography.subtitle1 })} + background-color: inherit; + } + + ul li button svg { + background-color: ${({ theme }) => theme.custom.colors.lightGray2}; + border-radius: 4px; + width: 1.5em; + height: 1.5em; + padding: 0.25em; + } +` + +const LoadingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; + width: 100%; +` + +const EmptyState = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 400px; + width: 100%; + gap: 16px; + text-align: center; + padding: 40px 20px; + + ${theme.breakpoints.down("sm")} { + min-height: 300px; + padding: 24px 16px; + } +` +const NewsBannerStyled = styled(NewsBanner)<{ page: number }>( + ({ page, theme }) => ({ + padding: "48px 0", + paddingBottom: page === 1 ? "250px" : undefined, + position: "relative", + backgroundSize: "150% !important", + backgroundPosition: "center !important", + + "&::before": { + content: '""', + position: "absolute", + inset: 0, + background: "rgb(0 0 0 / 85%)", + zIndex: 1, + }, + + "& > *": { + position: "relative", + zIndex: 2, + }, + + [theme.breakpoints.down("md")]: { + paddingBottom: page === 1 ? "198px" : undefined, + }, + + [theme.breakpoints.down("sm")]: { + padding: "32px 0", + marginBottom: 0, + }, + }), +) + +const MainStory: React.FC<{ item: NewsFeedItem }> = ({ item }) => { + const [imageError, setImageError] = React.useState(false) + + return ( + + + {item.image?.url && !imageError && ( + + {item.image.alt setImageError(true)} + /> + + )} + + + + + + {item.title} + + {item.summary && ( + + )} + + + + + + + ) +} + +const RegularStory: React.FC<{ item: NewsFeedItem }> = ({ item }) => { + const [imageError, setImageError] = React.useState(false) + + return ( + + + + + {item.title} + + {item.summary && ( + + )} + + + + + + + + {item.image?.url && !imageError && ( + {item.image.alt setImageError(true)} + /> + )} + + + + ) +} + +const NewsListingPage: React.FC = () => { + const [searchParams, setSearchParams] = useSearchParams() + const page = parseInt(searchParams.get("page") ?? "1", 10) + + const { data: news, isLoading } = useNewsEventsList({ + feed_type: [NewsEventsListFeedTypeEnum.News], + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + sortby: "-news_date", + }) + + const stories = news?.results ?? [] + + // On page 1, first story is featured, rest are grid stories + // On other pages, all stories are grid stories + const mainStory = + page === 1 && stories.length > 0 ? (stories[0] as NewsFeedItem) : null + const gridStories = page === 1 ? stories.slice(1) : stories + + return ( + <> + + + + + {isLoading ? ( + + + + ) : stories.length === 0 ? ( + + No News Available + + There are no news to display at this time. Please check back + later. + + + ) : ( + <> + + + + {page === 1 && mainStory && ( + + + + )} + + {gridStories.map((item) => ( +
  • + +
  • + ))} +
    +
    +
    +
    + + + {/* Main Story Section: Only visible on page 1 */} + {page === 1 && mainStory && ( + + + + )} + + {/* Grid Section: Other news */} + {gridStories.length > 0 ? ( + + + {gridStories.map((item) => ( + + + + ))} + + + ) : null} + + + )} +
    + + {!isLoading && gridStories.length > 0 && ( + + + { + setSearchParams((current) => { + const copy = new URLSearchParams(current) + if (newPage === 1) { + copy.delete("page") + } else { + copy.set("page", newPage.toString()) + } + return copy + }) + }} + renderItem={(item) => ( + + )} + /> + + + )} +
    + + ) +} + +export { NewsListingPage } diff --git a/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx b/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx index 1741b4d2f7..f402da563b 100644 --- a/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx @@ -1280,7 +1280,7 @@ describe("ProgramSummary", () => { expect(priceRow).not.toHaveTextContent("Earn a certificate") }) - test("Shows paid price with certificate type and no cert box when all enrollment modes are paid", () => { + test("Shows paid price with no cert box when all enrollment modes are paid", () => { const product = factories.courses.product({ price: "1499.00" }) const program = factories.programs.program({ enrollment_modes: [paidMode()], @@ -1292,12 +1292,13 @@ describe("ProgramSummary", () => { expect(priceRow).toHaveTextContent( formatPrice(product.price, { avoidCents: true }), ) - expect(priceRow).toHaveTextContent(program.certificate_type) + expect(priceRow).toHaveTextContent("full program") expect(priceRow).not.toHaveTextContent("Free to Learn") expect(priceRow).not.toHaveTextContent("Earn a certificate") + expect(priceRow).not.toHaveTextContent("Audit for free") }) - test("Shows 'Free to Learn' and cert box when enrollment modes include both free and paid", () => { + test("Shows paid price and 'Start for free' callout when enrollment modes include both free and paid", () => { const program = factories.programs.program({ enrollment_modes: bothModes(), }) @@ -1305,11 +1306,14 @@ describe("ProgramSummary", () => { renderWithProviders() const priceRow = screen.getByTestId(TestIds.PriceRow) - expect(priceRow).toHaveTextContent("Free to Learn") - expect(priceRow).toHaveTextContent("Earn a certificate") + expect(priceRow).toHaveTextContent("Audit for free") + expect(priceRow).toHaveTextContent("or upgrade to") + expect(priceRow).toHaveTextContent("certificate") expect(priceRow).toHaveTextContent( formatPrice(program.products[0].price, { avoidCents: true }), ) + expect(priceRow).not.toHaveTextContent("Free to Learn") + expect(priceRow).not.toHaveTextContent("Earn a certificate") }) test.each([ diff --git a/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx b/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx index 7e4e4a594c..df6609e119 100644 --- a/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx @@ -1,7 +1,7 @@ import React, { HTMLAttributes, useState } from "react" import { ActionButton, Alert, styled } from "@mitodl/smoot-design" import { productQueries } from "api/mitxonline-hooks/products" -import { Dialog, Link, Skeleton, Stack, Typography } from "ol-components" +import { Dialog, Link, Skeleton, Stack, theme, Typography } from "ol-components" import type { StackProps } from "ol-components" import { RiCalendarLine, @@ -38,6 +38,14 @@ const UnderlinedLink = styled(ResponsiveLink)({ textDecoration: "underline", }) +const SecondaryUnderlinedLink = styled(UnderlinedLink)(({ theme }) => ({ + ...theme.typography.body3, + color: theme.custom.colors.silverGrayDark, + [theme.breakpoints.down("sm")]: { + ...theme.typography.body4, + }, +})) + const InfoRow = styled.div(({ theme }) => ({ width: "100%", display: "flex", @@ -378,6 +386,159 @@ const GrayText = styled.span(({ theme }) => ({ color: theme.custom.colors.silverGrayDark, })) +const ProgramPaySection = styled.div(({ theme }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: "12px", + width: "346px", + alignSelf: "stretch", + flex: "none", + color: theme.custom.colors.darkGray2, +})) + +const ProgramPayLabel = styled.span(({ theme }) => ({ + ...theme.typography.subtitle3, + color: theme.custom.colors.silverGrayDark, + textTransform: "uppercase", + letterSpacing: "0.04em", +})) + +/** Horizontal row: [current price block] | [vertical divider] | [list price block] */ +const ProgramPriceRowInner = styled.div({ + display: "flex", + flexDirection: "row" as const, + alignItems: "flex-end" as const, + gap: "24px", +}) + +const ProgramCurrentPriceBlock = styled.div({ + display: "flex", + flexDirection: "column" as const, + justifyContent: "flex-end" as const, + alignItems: "flex-start" as const, +}) + +const ProgramPriceAmount = styled.span(({ theme }) => ({ + ...theme.typography.subtitle2, + fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif", + fontWeight: theme.typography.fontWeightBold, + fontSize: "34px", + lineHeight: "40px", + color: theme.custom.colors.darkGray2, +})) + +const ProgramPriceSuffix = styled.span(({ theme }) => ({ + ...theme.typography.body3, + color: theme.custom.colors.silverGrayDark, +})) + +const ProgramVerticalDivider = styled.div(() => ({ + width: "1px", + height: "48px", + backgroundColor: theme.custom.colors.lightGray2, + flexShrink: 0, +})) + +const ProgramListPriceBlock = styled.div({ + display: "flex", + flexDirection: "column" as const, + justifyContent: "flex-end" as const, + alignItems: "flex-start" as const, +}) + +const ProgramListPriceAmount = styled.span({ + ...theme.typography.body3, + fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif", + fontSize: "28px", + lineHeight: "36px", + display: "flex", + alignItems: "flex-end" as const, + textDecoration: "line-through", + color: theme.custom.colors.silverGrayDark, +}) + +const ProgramListPriceSubLabel = styled.span({ + ...theme.typography.body3, + color: theme.custom.colors.silverGrayDark, +}) + +/** Inline row: "Save $X compared to purchasing N courses separately" */ +const ProgramDiscountRow = styled.div({ + display: "flex", + flexDirection: "row" as const, + alignItems: "center" as const, + gap: "4px", + width: "100%", +}) + +const ProgramSavingsText = styled.span({ + ...theme.typography.subtitle3, + fontWeight: theme.typography.fontWeightBold, + color: "#008000", +}) + +const ProgramSavingsDetailText = styled.span({ + ...theme.typography.body3, + color: theme.custom.colors.silverGrayDark, +}) + +const ProgramPriceDivider = styled.div(({ theme }) => ({ + width: "100%", + maxWidth: "346px", + borderTop: `1px solid ${theme.custom.colors.lightGray2}`, + marginBottom: "20px", + flex: "none", + alignSelf: "stretch", +})) + +const ProgramStartForFreeBox = styled.div((_theme) => ({ + display: "flex", + alignItems: "center", + gap: "8px", + padding: "8px 16px", + borderRadius: "8px", + background: + "linear-gradient(0deg, rgba(255, 255, 255, 0.94), rgba(255, 255, 255, 0.94)), #004D1A", +})) + +const ProgramStartForFreeIcon = styled.svg(() => ({ + width: "24px", + height: "24px", + flexShrink: 0, + path: { + fill: "#008000", + }, +})) + +const ProgramStartForFreeTextContainer = styled.span(({ theme }) => ({ + display: "flex", + alignItems: "center", + gap: "4px", + ...theme.typography.body2, +})) + +const ProgramStartForFreeTextStrong = styled.span({ + ...theme.typography.subtitle2, + color: "#008000", +}) + +const ProgramStartForFreeTextRegular = styled.span({ + ...theme.typography.body2, + color: theme.custom.colors.darkGray2, +}) + +const ProgramStartForFreeInfoIcon = styled.span(({ theme }) => ({ + display: "inline-flex", + alignItems: "center", + flexShrink: 0, + color: theme.custom.colors.silverGrayDark, + "& svg": { + width: "20px", + height: "20px", + }, +})) + const CertificateBoxRoot = styled.div(({ theme }) => ({ width: "100%", backgroundColor: theme.custom.colors.lightGray1, @@ -567,7 +728,6 @@ enum TestIds { DurationRow = "duration-row", PriceRow = "price-row", RequirementsRow = "requirements-row", - CertificateTrackRow = "certificate-track-row", } const ArchivedAlert: React.FC = () => { @@ -657,15 +817,16 @@ type ProgramInfoRowProps = { program: V2ProgramDetail } & HTMLAttributes +const getTotalRequiredCourses = (program: V2ProgramDetail) => { + const parsedReqs = parseReqTree(program.req_tree) + return parsedReqs.reduce((sum, req) => sum + req.requiredCount, 0) +} + const RequirementsRow: React.FC = ({ program, ...others }) => { - const parsedReqs = parseReqTree(program.req_tree) - const totalRequired = parsedReqs.reduce( - (sum, req) => sum + req.requiredCount, - 0, - ) + const totalRequired = getTotalRequiredCourses(program) if (totalRequired === 0) return null // Always say "Courses" here. Whether a child program should be labeled @@ -760,28 +921,67 @@ const ProgramPaceRow: React.FC< const PROGRAM_CERT_INFO_HREF = "https://mitxonline.zendesk.com/hc/en-us/articles/28158506908699-What-is-the-Certificate-Track-What-are-Course-and-Program-Certificates" -const ProgramCertificateBox: React.FC<{ program: V2ProgramDetail }> = ({ +type ProgramPriceRowProps = HTMLAttributes & { + program: V2ProgramDetail +} +const ProgramPriceRow: React.FC = ({ program, + ...others }) => { - const price = program.products[0]?.price - if (!price) return null - return ( - - - - - Earn a certificate - - : {formatPrice(price, { avoidCents: true })} - - + const enrollmentType = getEnrollmentType(program.enrollment_modes) + if (enrollmentType === "none") return null + + const currentPrice = program.products[0]?.price + const listPrice = program.page.list_price + + const currentAmount = toNumericPrice(currentPrice) + const listAmount = toNumericPrice(listPrice) + const hasSavings = + currentAmount !== null && listAmount !== null && listAmount > currentAmount + const savingsAmount = hasSavings ? listAmount - currentAmount : null + + const totalRequired = getTotalRequiredCourses(program) + + const paidSection = currentPrice ? ( + + Price + + + + {formatPrice(currentPrice, { avoidCents: true })} + + full program + + {hasSavings && listAmount !== null ? ( + <> + + + + + + + ) : null} + + {hasSavings && savingsAmount !== null ? ( + + + Save {formatPrice(savingsAmount, { avoidCents: true })} + + + compared to purchasing {totalRequired}{" "} + {pluralize("course", totalRequired)} separately + + + ) : null} {program.page.financial_assistance_form_url ? ( - = ({ style={{ minWidth: "fit-content" }} > Financial assistance available - + ) : null} - + {enrollmentType === "both" ? ( + + + + + Audit for free + + + or upgrade to certificate + + + + + + + + ) : null} + + ) : ( + ) -} - -type ProgramPriceRowProps = HTMLAttributes & { - program: V2ProgramDetail -} -const ProgramPriceRow: React.FC = ({ - program, - ...others -}) => { - const enrollmentType = getEnrollmentType(program.enrollment_modes) - if (enrollmentType === "none") return null - - const paidPrice = - enrollmentType === "paid" && program.products[0]?.price ? ( - <> - {formatPrice(program.products[0].price, { avoidCents: true })}{" "} - (includes {program.certificate_type}) - - ) : null return ( - - - - - {enrollmentType === "paid" ? ( - + + {enrollmentType === "paid" || enrollmentType === "both" ? ( + + ) : null} + + {enrollmentType === "paid" || enrollmentType === "both" ? ( + paidSection ) : ( - + <> + + + + + + )} - {enrollmentType === "both" ? ( - - ) : null} - - + + ) } +const toNumericPrice = (value: unknown): number | null => { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "string") { + const parsed = Number.parseFloat(value) + if (Number.isFinite(parsed)) return parsed + } + return null +} + const ProgramSummary: React.FC<{ program: V2ProgramDetail /** diff --git a/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.test.tsx b/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.test.tsx index 46fa4f46aa..e4a913e4bb 100644 --- a/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.test.tsx @@ -34,7 +34,8 @@ const makeUser = factories.user.user describe("ProgramEnrollmentButton", () => { const ENROLLED = "Enrolled" - const ENROLL_FREE = "Enroll for Free" + const ENROLL_FREE = "Enroll in Program" + const ENROLL_PROGRAM = "Enroll in Program" setupLocationMock() @@ -94,7 +95,7 @@ describe("ProgramEnrollmentButton", () => { expect(enrolledLink).toHaveAttribute("href", programView(program.id)) }) - test("Free-only: clicking 'Enroll for Free' enrolls and redirects to the dashboard success URL with title in params", async () => { + test("Free-only: clicking 'Enroll in Program' enrolls and redirects to the dashboard success URL with title in params", async () => { const program = makeProgram({ enrollment_modes: [makeEnrollmentMode({ requires_payment: false })], }) @@ -134,7 +135,7 @@ describe("ProgramEnrollmentButton", () => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument() }) - test("Both: clicking 'Enroll for Free' opens enrollment dialog", async () => { + test("Both: clicking 'Enroll in Program' opens enrollment dialog", async () => { const program = makeProgram({ enrollment_modes: [ makeEnrollmentMode({ requires_payment: false }), @@ -157,7 +158,7 @@ describe("ProgramEnrollmentButton", () => { await screen.findByRole("dialog", { name: program.title }) }) - test("Paid-only: clicking 'Enroll Now' clears basket, adds product, and redirects", async () => { + test("Paid-only: clicking 'Enroll in Program' clears basket, adds product, and redirects", async () => { const assign = jest.mocked(window.location.assign) const product = makeProduct({ price: "500" }) const program = makeProgram({ @@ -177,7 +178,7 @@ describe("ProgramEnrollmentButton", () => { ) const enrollButton = await screen.findByRole("button", { - name: /Enroll Now/, + name: ENROLL_PROGRAM, }) await user.click(enrollButton) @@ -208,12 +209,88 @@ describe("ProgramEnrollmentButton", () => { , ) - const button = await screen.findByRole("button", { name: /Enroll Now/ }) + const button = await screen.findByRole("button", { name: ENROLL_PROGRAM }) await user.click(button) await screen.findByRole("progressbar", { name: "Loading" }) expect(button).toBeDisabled() - expect(button).toHaveTextContent("Enroll Now—$500") + expect(button).toHaveTextContent(ENROLL_PROGRAM) + }) + + test("Hides arrow icon when showArrowIcon is false", async () => { + const program = makeProgram({ + enrollment_modes: [makeEnrollmentMode({ requires_payment: true })], + products: [makeProduct({ price: "500" })], + }) + + setMockResponse.get(mitxUrls.programEnrollments.enrollmentsListV3(), []) + setMockResponse.get( + urls.userMe.get(), + makeUser({ is_authenticated: false }), + ) + + renderWithProviders( + , + ) + + await screen.findByRole("button", { name: ENROLL_PROGRAM }) + expect(screen.queryByTestId("program-enroll-arrow-icon")).toBeNull() + }) + + test("Still shows loading spinner when showArrowIcon is false", async () => { + const product = makeProduct({ price: "500" }) + const program = makeProgram({ + enrollment_modes: [makeEnrollmentMode({ requires_payment: true })], + products: [product], + }) + + setMockResponse.get(mitxUrls.programEnrollments.enrollmentsListV3(), []) + setMockResponse.get(urls.userMe.get(), makeUser({ is_authenticated: true })) + const { promise } = Promise.withResolvers() + setMockResponse.delete(mitxUrls.baskets.clear(), promise) + + renderWithProviders( + , + ) + + const button = await screen.findByRole("button", { name: ENROLL_PROGRAM }) + await user.click(button) + + await screen.findByRole("progressbar", { name: "Loading" }) + expect(screen.queryByTestId("program-enroll-arrow-icon")).toBeNull() + }) + + test("Still shows loading spinner during initial loading when showArrowIcon is false", async () => { + const program = makeProgram({ + enrollment_modes: [makeEnrollmentMode({ requires_payment: false })], + }) + const enrollmentResponse = Promise.withResolvers() + const userResponse = Promise.withResolvers() + + setMockResponse.get( + mitxUrls.programEnrollments.enrollmentsListV3(), + enrollmentResponse.promise, + ) + setMockResponse.get(urls.userMe.get(), userResponse.promise) + + renderWithProviders( + , + ) + + await screen.findByRole("progressbar", { name: "Loading" }) + expect(screen.queryByTestId("program-enroll-arrow-icon")).toBeNull() }) test("Shows error alert when basket operation fails (paid)", async () => { @@ -231,7 +308,7 @@ describe("ProgramEnrollmentButton", () => { , ) - const button = await screen.findByRole("button", { name: /Enroll Now/ }) + const button = await screen.findByRole("button", { name: ENROLL_PROGRAM }) await user.click(button) await screen.findByText( @@ -281,7 +358,7 @@ describe("ProgramEnrollmentButton", () => { ) const button = await screen.findByRole("button", { - name: "Enroll Now—$1,500", + name: ENROLL_PROGRAM, }) expect(button).toBeInTheDocument() expect(button).not.toBeDisabled() @@ -319,7 +396,7 @@ describe("ProgramEnrollmentButton", () => { , ) - const button = await screen.findByRole("button", { name: "Enroll Now" }) + const button = await screen.findByRole("button", { name: ENROLL_PROGRAM }) expect(button).toBeDisabled() }) @@ -398,9 +475,7 @@ describe("ProgramEnrollmentButton", () => { renderWithProviders( , ) - await user.click( - await screen.findByRole("button", { name: "Enroll for Free" }), - ) + await user.click(await screen.findByRole("button", { name: ENROLL_FREE })) expect(mockCapture).toHaveBeenCalledWith( PostHogEvents.CallToActionClicked, @@ -426,9 +501,7 @@ describe("ProgramEnrollmentButton", () => { renderWithProviders( , ) - await user.click( - await screen.findByRole("button", { name: "Enroll for Free" }), - ) + await user.click(await screen.findByRole("button", { name: ENROLL_FREE })) expect(mockCapture).toHaveBeenCalledWith( PostHogEvents.CallToActionClicked, diff --git a/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.tsx b/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.tsx index d3571fe900..86939ef301 100644 --- a/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.tsx @@ -1,12 +1,12 @@ import React from "react" -import { LoadingSpinner, Stack } from "ol-components" +import { LoadingSpinner, Stack, theme } from "ol-components" import { enrollmentQueries, useCreateProgramEnrollment, } from "api/mitxonline-hooks/enrollment" import { useQuery } from "@tanstack/react-query" import { V2ProgramDetail } from "@mitodl/mitxonline-api-axios/v2" -import { RiCheckLine } from "@remixicon/react" +import { RiArrowRightSLine, RiCheckLine } from "@remixicon/react" import { Alert, Button, @@ -22,7 +22,6 @@ import { programView } from "@/common/urls" import { usePostHog } from "posthog-js/react" import { enrollmentAlertSuccessUrl, - formatPrice, getEnrollmentType, } from "@/common/mitxonline" import { useReplaceBasketItem } from "api/mitxonline-hooks/baskets" @@ -36,17 +35,33 @@ const ButtonLinkWithDisabled = styled(ButtonLink)(({ href }) => [ }, ]) +const EnrollButtonIcon = styled.span({ + width: "24px", + height: "24px", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + color: theme.custom.colors.white, + "> svg": { + width: "24px", + height: "24px", + }, +}) + type ProgramEnrollmentButtonProps = { program: V2ProgramDetail variant?: ButtonProps["variant"] className?: string displayAsCourse?: boolean + showArrowIcon?: boolean } const ProgramEnrollmentButton: React.FC = ({ program, variant = "primary", className, displayAsCourse, + showArrowIcon = true, }) => { const [anchor, setAnchor] = React.useState(null) const me = useQuery(userQueries.me()) @@ -63,16 +78,7 @@ const ProgramEnrollmentButton: React.FC = ({ const enrollmentType = getEnrollmentType(program.enrollment_modes) const isPaidWithoutPrice = enrollmentType === "paid" && !program.products[0]?.price - - const getEnrollButtonText = () => { - if (enrollmentType === "paid") { - const price = program.products[0]?.price - return price - ? `Enroll Now—${formatPrice(price, { avoidCents: true })}` - : "Enroll Now" - } - return "Enroll for Free" - } + const enrollButtonLabel = "Enroll in Program" const isLoading = enrollments.isLoading || me.isLoading const isPending = @@ -88,7 +94,7 @@ const ProgramEnrollmentButton: React.FC = ({ posthog.capture(PostHogEvents.CallToActionClicked, { readableId: program.readable_id, resourceType: "program", - label: getEnrollButtonText(), + label: enrollButtonLabel, }) } if (me.data?.is_authenticated) { @@ -144,10 +150,14 @@ const ProgramEnrollmentButton: React.FC = ({ endIcon={ isLoading || isPending ? ( + ) : showArrowIcon ? ( + + ) : undefined } > - {isLoading ? null : getEnrollButtonText()} + {isLoading ? null : enrollButtonLabel} )} {isError && ( diff --git a/frontends/main/src/app-pages/ProductPages/ProgramPage.tsx b/frontends/main/src/app-pages/ProductPages/ProgramPage.tsx index e9c3cfc689..77e2e51fa5 100644 --- a/frontends/main/src/app-pages/ProductPages/ProgramPage.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProgramPage.tsx @@ -295,7 +295,11 @@ const ProgramPage: React.FC = ({ readableId }) => { imageSrc={imageSrc} videoUrl={page.video_url} enrollmentAction={ - + } showStayUpdated={ program.enrollment_modes.length > 0 && diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDetail.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDetail.tsx new file mode 100644 index 0000000000..fea109d342 --- /dev/null +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDetail.tsx @@ -0,0 +1,71 @@ +"use client" + +import React from "react" +import type { WebsiteContent } from "api/v1" +import { useWebsiteContentDetailRetrieve } from "api/hooks/website_content" +import { LoadingSpinner, styled } from "ol-components" +import { NewsEditor } from "@/page-components/TiptapEditor/contentTypes/news/NewsEditor" +import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/article/ArticleEditor" +import { LearningResourceProvider } from "@/page-components/TiptapEditor/extensions/node/LearningResource/LearningResourceDataProvider" +import { notFound } from "next/navigation" + +const DETAIL_EDITORS: Record< + string, + React.ComponentType<{ contentItem: WebsiteContent }> +> = { + article: ({ contentItem }) => ( + + ), + news: ({ contentItem }) => , +} + +const PageContainer = styled.div({ + display: "flex", + height: "100%", +}) + +const Spinner = styled(LoadingSpinner)({ + margin: "auto", + position: "absolute", + top: "40%", + left: "50%", + transform: "translate(-50%, -50%)", +}) + +const WebsiteContentDetail = ({ + contentId, + learningResourceIds = [], +}: { + contentId: string + learningResourceIds?: number[] +}) => { + const { data: contentItem, isLoading } = + useWebsiteContentDetailRetrieve(contentId) + + if (isLoading) { + return ( + + + + ) + } + if (!contentItem) { + return notFound() + } + + const contentType = contentItem.content_type ?? "" + const Editor = DETAIL_EDITORS[contentType] + if (!Editor) { + return notFound() + } + + return ( + + + + + + ) +} + +export { WebsiteContentDetail } diff --git a/frontends/main/src/app-pages/Articles/ArticleDraftListingPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx similarity index 51% rename from frontends/main/src/app-pages/Articles/ArticleDraftListingPage.tsx rename to frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx index 82ed4141f3..5a08eb4841 100644 --- a/frontends/main/src/app-pages/Articles/ArticleDraftListingPage.tsx +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx @@ -13,17 +13,23 @@ import { Typography, } from "ol-components" import { Permission } from "api/hooks/user" -import { useArticleList } from "api/hooks/articles" -import type { WebsiteContent } from "api/v1" +import { useWebsiteContentList } from "api/hooks/website_content" +import type { + WebsiteContent, + WebsiteContentApiWebsiteContentListRequest as WebsiteContentListRequest, +} from "api/v1" import { LocalDate } from "ol-utilities" import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react" -import { ArticleBanner, DEFAULT_BACKGROUND_IMAGE_URL } from "./ArticleBanner" -import { extractFirstImageFromArticle } from "@/common/articleUtils" -import { articlesDraftView, articlesView } from "@/common/urls" +import { extractFirstImage } from "@/common/websiteContentUtils" +import { websiteContentEditView, websiteContentCreateView } from "@/common/urls" import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" +import { ButtonLink } from "@mitodl/smoot-design" const PAGE_SIZE = 20 +export const DEFAULT_BACKGROUND_IMAGE_URL = + "/images/backgrounds/banner_background.webp" + const PageWrapper = styled.div` background: ${theme.custom.colors.white}; min-height: calc(100vh - 200px); @@ -33,7 +39,14 @@ const PageWrapper = styled.div` } ` -const DraftArticleCard = styled(Card)` +const PageHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 40px; +` + +const DraftContentCard = styled(Card)` display: flex; flex-direction: column; height: 100%; @@ -66,48 +79,67 @@ const DraftBadge = styled.span` font-weight: ${theme.typography.fontWeightMedium}; ` -export const DraftArticle: React.FC<{ article: WebsiteContent }> = ({ - article, +const CONTENT_TYPE_LABELS: Record = { + article: "Article", + news: "News", +} + +const DraftItem: React.FC<{ contentItem: WebsiteContent; type: string }> = ({ + contentItem, + type, }) => { - const articleUrl = article.is_published - ? articlesView(article.slug || String(article.id)) - : articlesDraftView(String(article.id)) + const itemUrl = contentItem.is_published + ? `/${type === "article" ? "articles" : type}/${contentItem.slug || contentItem.id}` + : websiteContentEditView(type, contentItem.id) - const imageUrl = extractFirstImageFromArticle(article.content) + const imageUrl = extractFirstImage(contentItem.content) return ( - - { - - } - - {article.title} + + + + {contentItem.title} - - {!article.is_published && ( + + {!contentItem.is_published && ( <> {" • "} Draft )} - + ) } -const ArticleDraftPage: React.FC = () => { +interface WebsiteContentDraftListingPageProps { + /** + * Content type to show drafts for (e.g. 'article', 'news'). + */ + contentType?: string +} + +const WebsiteContentDraftListingPage: React.FC< + WebsiteContentDraftListingPageProps +> = ({ contentType }) => { const [page, setPage] = useState(1) const scrollRef = useRef(null) + const type = contentType || "news" + const label = CONTENT_TYPE_LABELS[type] ?? type - const { data: articles, isLoading: isLoadingArticles } = useArticleList({ + const listParams: WebsiteContentListRequest = { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, - draft: true, // Filter for drafts only on the backend - }) + draft: true, + content_type: type as WebsiteContentListRequest["content_type"], + } + + const { data: contentItems, isLoading: isLoadingContentItems } = + useWebsiteContentList(listParams) useEffect(() => { if (page > 1 && scrollRef.current) { @@ -115,34 +147,43 @@ const ArticleDraftPage: React.FC = () => { } }, [page]) - const draftArticles = articles?.results - const totalPages = articles?.count ? Math.ceil(articles.count / PAGE_SIZE) : 0 + const draftItems = contentItems?.results + const totalPages = contentItems?.count + ? Math.ceil(contentItems.count / PAGE_SIZE) + : 0 - if (isLoadingArticles) { - return + if (isLoadingContentItems) { + return } + return ( - - {isLoadingArticles ? ( + + {label} Drafts + + New {label} + + + + {isLoadingContentItems ? ( - ) : draftArticles && draftArticles.length > 0 ? ( + ) : draftItems && draftItems.length > 0 ? ( <> - {draftArticles.map((article) => ( + {draftItems.map((contentItem) => ( - + ))} @@ -168,10 +209,9 @@ const ArticleDraftPage: React.FC = () => { ) : ( - No Draft Articles + No Draft {label}s - You don't have any draft articles yet. Create a new article to - get started. + You don't have any draft {label.toLowerCase()}s yet. )} @@ -181,4 +221,4 @@ const ArticleDraftPage: React.FC = () => { ) } -export { ArticleDraftPage } +export { WebsiteContentDraftListingPage } diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx new file mode 100644 index 0000000000..5d877a5a5c --- /dev/null +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx @@ -0,0 +1,96 @@ +"use client" + +import React from "react" +import { useRouter } from "next-nprogress-bar" +import { notFound } from "next/navigation" +import { Permission } from "api/hooks/user" +import { useWebsiteContentDetailRetrieve } from "api/hooks/website_content" +import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" +import { styled, LoadingSpinner } from "ol-components" +import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/article/ArticleEditor" +import { NewsEditor } from "@/page-components/TiptapEditor/contentTypes/news/NewsEditor" +import { articleView, websiteContentEditView } from "@/common/urls" +import invariant from "tiny-invariant" +import type { WebsiteContent } from "api/v1" + +const PageContainer = styled.div(({ theme }) => ({ + color: theme.custom.colors.darkGray2, + display: "flex", + height: "100%", +})) + +const Spinner = styled(LoadingSpinner)({ + margin: "auto", + position: "absolute", + top: "40%", + left: "50%", + transform: "translate(-50%, -50%)", +}) + +const PUBLISHED_VIEW_URL: Record string> = { + article: (slug) => articleView(slug), + news: (slug) => `/news/${slug}`, +} + +const EDITORS: Record< + string, + React.ComponentType<{ + onSave?: (savedContent: WebsiteContent) => void + readOnly?: boolean + contentItem?: WebsiteContent + }> +> = { + article: ({ contentItem, ...props }) => ( + + ), + news: ({ contentItem, ...props }) => ( + + ), +} + +interface WebsiteContentEditPageProps { + type: string + idOrSlug: string +} + +const WebsiteContentEditPage = ({ + type, + idOrSlug, +}: WebsiteContentEditPageProps) => { + const { data: article, isLoading } = useWebsiteContentDetailRetrieve(idOrSlug) + const router = useRouter() + + const Editor = EDITORS[type] + const viewUrl = PUBLISHED_VIEW_URL[type] + + if (!Editor || !viewUrl) { + notFound() + } + + if (isLoading) { + return + } + if (!article) { + return notFound() + } + + return ( + + + { + if (saved.is_published) { + invariant(saved.slug, "Published content must have a slug") + return router.push(viewUrl(saved.slug)) + } else { + router.push(websiteContentEditView(type, saved.id)) + } + }} + /> + + + ) +} + +export { WebsiteContentEditPage } diff --git a/frontends/main/src/app-pages/Articles/ArticleNewPage.test.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.test.tsx similarity index 85% rename from frontends/main/src/app-pages/Articles/ArticleNewPage.test.tsx rename to frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.test.tsx index 38f8323e1f..9c1db13ff2 100644 --- a/frontends/main/src/app-pages/Articles/ArticleNewPage.test.tsx +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.test.tsx @@ -6,7 +6,7 @@ import { } from "@/test-utils" import { waitFor } from "@testing-library/react" import { factories, urls } from "api/test-utils" -import { ArticleNewPage } from "./ArticleNewPage" +import { WebsiteContentNewPage } from "./WebsiteContentNewPage" const mockPush = jest.fn() jest.mock("next/navigation", () => ({ @@ -15,7 +15,7 @@ jest.mock("next/navigation", () => ({ }), })) -describe("ArticleNewPage", () => { +describe("WebsiteContentNewPage", () => { test("throws ForbiddenError when user lacks ArticleEditor permission", async () => { const user = factories.user.user({ is_authenticated: true, @@ -28,7 +28,7 @@ describe("ArticleNewPage", () => { renderWithProviders( - + , ) diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.tsx new file mode 100644 index 0000000000..ed648a5b94 --- /dev/null +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.tsx @@ -0,0 +1,73 @@ +"use client" + +import React from "react" +import { useRouter } from "next-nprogress-bar" +import { notFound } from "next/navigation" +import { Permission } from "api/hooks/user" +import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" +import { styled } from "ol-components" +import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/article/ArticleEditor" +import { NewsEditor } from "@/page-components/TiptapEditor/contentTypes/news/NewsEditor" +import { articleView, websiteContentEditView } from "@/common/urls" +import invariant from "tiny-invariant" +import type { WebsiteContent } from "api/v1" + +const PageContainer = styled.div(({ theme }) => ({ + color: theme.custom.colors.darkGray2, + display: "flex", + height: "100%", +})) + +const PUBLISHED_VIEW_URL: Record string> = { + article: (slug) => articleView(slug), + news: (slug) => `/news/${slug}`, +} + +const EDITORS: Record< + string, + React.ComponentType<{ + onSave?: (savedContent: WebsiteContent) => void + readOnly?: boolean + contentItem?: WebsiteContent + }> +> = { + article: ({ contentItem, ...props }) => ( + + ), + news: ({ contentItem: _contentItem, ...props }) => , +} + +interface WebsiteContentNewPageProps { + type: string +} + +const WebsiteContentNewPage: React.FC = ({ + type, +}) => { + const router = useRouter() + const Editor = EDITORS[type] + const viewUrl = PUBLISHED_VIEW_URL[type] + + if (!Editor || !viewUrl) { + notFound() + } + + return ( + + + { + if (article.is_published) { + invariant(article.slug, "Published content must have a slug") + return router.push(viewUrl(article.slug)) + } else { + router.push(websiteContentEditView(type, article.id)) + } + }} + /> + + + ) +} + +export { WebsiteContentNewPage } diff --git a/frontends/main/src/app/(site)/articles/[slugOrId]/draft/page.tsx b/frontends/main/src/app/(site)/articles/[slugOrId]/draft/page.tsx new file mode 100644 index 0000000000..64a79f3363 --- /dev/null +++ b/frontends/main/src/app/(site)/articles/[slugOrId]/draft/page.tsx @@ -0,0 +1,25 @@ +import React from "react" +import { standardizeMetadata } from "@/common/metadata" +import { WebsiteContentDetail } from "@/app-pages/WebsiteContent/WebsiteContentDetail" +import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" +import { Permission } from "api/hooks/user" + +export const generateMetadata = async () => { + return standardizeMetadata({ + title: "Draft Article", + }) +} + +const Page: React.FC> = async ( + props, +) => { + const { slugOrId } = await props.params + + return ( + + + + ) +} + +export default Page diff --git a/frontends/main/src/app/(site)/articles/[slugOrId]/page.tsx b/frontends/main/src/app/(site)/articles/[slugOrId]/page.tsx new file mode 100644 index 0000000000..919d303442 --- /dev/null +++ b/frontends/main/src/app/(site)/articles/[slugOrId]/page.tsx @@ -0,0 +1,78 @@ +import React from "react" +import { HydrationBoundary, dehydrate } from "@tanstack/react-query" +import { websiteContentQueries } from "api/hooks/website_content/queries" +import { WebsiteContentDetail } from "@/app-pages/WebsiteContent/WebsiteContentDetail" +import { getQueryClient } from "@/app/getQueryClient" +import { learningResourceQueries } from "api/hooks/learningResources" +import { extractLearningResourceIds } from "@/page-components/TiptapEditor/extensions/utils" +import { safeGenerateMetadata, standardizeMetadata } from "@/common/metadata" +import { + extractImageMetadata, + extractWebsiteContentDescription, +} from "@/common/website_content" +import { notFound } from "next/navigation" + +export const generateMetadata = async ( + props: PageProps<"/articles/[slugOrId]">, +) => { + const params = await props.params + const { slugOrId } = params + + const queryClient = getQueryClient() + + return safeGenerateMetadata(async () => { + const content = await queryClient.fetchQuery( + websiteContentQueries.websiteContentDetailRetrieve(slugOrId), + ) + if (content.content_type !== "article") { + return notFound() + } + + const description = extractWebsiteContentDescription(content) + const leadImage = extractImageMetadata(content) + + return standardizeMetadata({ + title: content.title, + description, + image: leadImage?.src, + imageAlt: leadImage?.alt, + }) + }) +} + +const Page: React.FC> = async (props) => { + const { slugOrId } = await props.params + + const queryClient = getQueryClient() + + await queryClient.fetchQueryOr404( + websiteContentQueries.websiteContentDetailRetrieve(slugOrId), + ) + + const queryKey = + websiteContentQueries.websiteContentDetailRetrieve(slugOrId).queryKey + const content = queryClient.getQueryData(queryKey) + if (!content || content.content_type !== "article") { + return notFound() + } + + const learningResourceIds = extractLearningResourceIds(content.content) + + if (learningResourceIds.length > 0) { + const bulkQuery = learningResourceQueries.list({ + resource_id: learningResourceIds, + }) + await queryClient.prefetchQuery(bulkQuery) + } + + return ( + + + + ) +} + +export default Page diff --git a/frontends/main/src/app/(site)/articles/page.tsx b/frontends/main/src/app/(site)/articles/page.tsx new file mode 100644 index 0000000000..498bb7c361 --- /dev/null +++ b/frontends/main/src/app/(site)/articles/page.tsx @@ -0,0 +1,15 @@ +import React from "react" +import { Metadata } from "next" +import { standardizeMetadata } from "@/common/metadata" +import { ArticleListingPage } from "@/app-pages/Articles/ArticleListingPage" + +export const metadata: Metadata = standardizeMetadata({ + title: "MIT Learn | Articles", + robots: "noindex, nofollow", +}) + +const Page: React.FC> = () => { + return +} + +export default Page diff --git a/frontends/main/src/app/(site)/news/[slugOrId]/draft/page.tsx b/frontends/main/src/app/(site)/news/[slugOrId]/draft/page.tsx index 1f4dc0b464..5e67a9bb04 100644 --- a/frontends/main/src/app/(site)/news/[slugOrId]/draft/page.tsx +++ b/frontends/main/src/app/(site)/news/[slugOrId]/draft/page.tsx @@ -1,24 +1,24 @@ import React from "react" -import { ArticleDetailPage } from "@/app-pages/Articles/ArticleDetailPage" +import { WebsiteContentDetail } from "@/app-pages/WebsiteContent/WebsiteContentDetail" import { standardizeMetadata } from "@/common/metadata" import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" import { Permission } from "api/hooks/user" export const generateMetadata = async () => { return standardizeMetadata({ - title: "Draft Article", + title: "Draft News", }) } const Page: React.FC> = async (props) => { const { slugOrId } = await props.params - // No prefetching for draft articles - the client-side component + // No prefetching for draft News - the client-side component // will fetch with user authentication return ( - + ) } diff --git a/frontends/main/src/app/(site)/news/[slugOrId]/edit/page.tsx b/frontends/main/src/app/(site)/news/[slugOrId]/edit/page.tsx index 3837282c8e..4064bd021f 100644 --- a/frontends/main/src/app/(site)/news/[slugOrId]/edit/page.tsx +++ b/frontends/main/src/app/(site)/news/[slugOrId]/edit/page.tsx @@ -1,9 +1,8 @@ -import React from "react" -import { ArticleEditPage } from "@/app-pages/Articles/ArticleEditPage" +import { redirect } from "next/navigation" -const Page: React.FC> = async (props) => { +const Page = async (props: { params: Promise<{ slugOrId: string }> }) => { const { slugOrId } = await props.params - - return + redirect(`/website_content/news/${slugOrId}/edit`) } + export default Page diff --git a/frontends/main/src/app/(site)/news/[slugOrId]/page.tsx b/frontends/main/src/app/(site)/news/[slugOrId]/page.tsx index 460f3ea80f..b668bfbaf5 100644 --- a/frontends/main/src/app/(site)/news/[slugOrId]/page.tsx +++ b/frontends/main/src/app/(site)/news/[slugOrId]/page.tsx @@ -1,38 +1,16 @@ import React from "react" import { HydrationBoundary, dehydrate } from "@tanstack/react-query" -import { articleQueries } from "api/hooks/articles/queries" -import { ArticleDetailPage } from "@/app-pages/Articles/ArticleDetailPage" +import { websiteContentQueries } from "api/hooks/website_content/queries" +import { WebsiteContentDetail } from "@/app-pages/WebsiteContent/WebsiteContentDetail" import { getQueryClient } from "@/app/getQueryClient" import { learningResourceQueries } from "api/hooks/learningResources" import { extractLearningResourceIds } from "@/page-components/TiptapEditor/extensions/utils" import { safeGenerateMetadata, standardizeMetadata } from "@/common/metadata" -import type { WebsiteContent } from "api/v1" -import type { JSONContent } from "@tiptap/react" - -// Extracts the banner subheading paragraph at known location -const extractArticleDescription = ( - article: WebsiteContent, -): string | undefined => { - const banner = article.content?.content?.[0] - const subheading = banner?.content?.[1] - const textNode = subheading?.content?.[0] - return textNode?.text -} - -const extractImageMetadata = ( - article: WebsiteContent, -): { src: string; alt: string } | null => { - const imageWithCaption = article.content?.content?.find( - (node: JSONContent) => node.type === "imageWithCaption", - ) - if (!imageWithCaption) { - return null - } - return { - src: imageWithCaption.attrs.src, - alt: imageWithCaption.attrs.caption || imageWithCaption.attrs.alt, - } -} +import { + extractImageMetadata, + extractWebsiteContentDescription, +} from "@/common/website_content" +import { notFound } from "next/navigation" export const generateMetadata = async ( props: PageProps<"/news/[slugOrId]">, @@ -44,15 +22,18 @@ export const generateMetadata = async ( const queryClient = getQueryClient() return safeGenerateMetadata(async () => { - const article = await queryClient.fetchQuery( - articleQueries.articlesDetailRetrieve(slugOrId), + const content = await queryClient.fetchQuery( + websiteContentQueries.websiteContentDetailRetrieve(slugOrId), ) + if (content.content_type !== "news") { + return notFound() + } - const description = extractArticleDescription(article) - const leadImage = extractImageMetadata(article) + const description = extractWebsiteContentDescription(content) + const leadImage = extractImageMetadata(content) return standardizeMetadata({ - title: article.title, + title: content.title, description, image: leadImage?.src, imageAlt: leadImage?.alt, @@ -66,15 +47,17 @@ const Page: React.FC> = async (props) => { const queryClient = getQueryClient() await queryClient.fetchQueryOr404( - articleQueries.articlesDetailRetrieve(slugOrId), + websiteContentQueries.websiteContentDetailRetrieve(slugOrId), ) - const queryKey = articleQueries.articlesDetailRetrieve(slugOrId).queryKey - const cacheData = queryClient.getQueryData(queryKey) + const queryKey = + websiteContentQueries.websiteContentDetailRetrieve(slugOrId).queryKey + const content = queryClient.getQueryData(queryKey) + if (!content || content.content_type !== "news") { + return notFound() + } - const learningResourceIds = cacheData?.content - ? extractLearningResourceIds(cacheData.content) - : [] + const learningResourceIds = extractLearningResourceIds(content.content) if (learningResourceIds.length > 0) { const bulkQuery = learningResourceQueries.list({ @@ -85,8 +68,8 @@ const Page: React.FC> = async (props) => { return ( - diff --git a/frontends/main/src/app/(site)/news/draft/page.tsx b/frontends/main/src/app/(site)/news/draft/page.tsx index 56ad03090b..63cea3be9f 100644 --- a/frontends/main/src/app/(site)/news/draft/page.tsx +++ b/frontends/main/src/app/(site)/news/draft/page.tsx @@ -1,15 +1,7 @@ -import React from "react" -import { Metadata } from "next" -import { standardizeMetadata } from "@/common/metadata" -import { ArticleDraftPage } from "@/app-pages/Articles/ArticleDraftListingPage" +import { redirect } from "next/navigation" -export const metadata: Metadata = standardizeMetadata({ - title: "Articles Draft", - robots: "noindex, nofollow", -}) - -const Page: React.FC> = () => { - return +const Page = () => { + redirect("/website_content/drafts?content_type=news") } export default Page diff --git a/frontends/main/src/app/(site)/news/new/page.tsx b/frontends/main/src/app/(site)/news/new/page.tsx index 7040db5e9d..2309ec5ee8 100644 --- a/frontends/main/src/app/(site)/news/new/page.tsx +++ b/frontends/main/src/app/(site)/news/new/page.tsx @@ -1,15 +1,7 @@ -import React from "react" -import { Metadata } from "next" -import { standardizeMetadata } from "@/common/metadata" -import { ArticleNewPage } from "@/app-pages/Articles/ArticleNewPage" +import { redirect } from "next/navigation" -export const metadata: Metadata = standardizeMetadata({ - title: "MIT Learn| New", - robots: "noindex, nofollow", -}) - -const Page: React.FC> = () => { - return +const Page = () => { + redirect("/website_content/news/new") } export default Page diff --git a/frontends/main/src/app/(site)/news/page.tsx b/frontends/main/src/app/(site)/news/page.tsx index 436c014d4d..942bd20a09 100644 --- a/frontends/main/src/app/(site)/news/page.tsx +++ b/frontends/main/src/app/(site)/news/page.tsx @@ -1,7 +1,7 @@ import React from "react" import { Metadata } from "next" import { standardizeMetadata } from "@/common/metadata" -import { ArticleListingPage } from "@/app-pages/Articles/ArticleListingPage" +import { NewsListingPage } from "@/app-pages/News/NewsListingPage" export const metadata: Metadata = standardizeMetadata({ title: "MIT Learn | News", @@ -9,7 +9,7 @@ export const metadata: Metadata = standardizeMetadata({ }) const Page: React.FC> = () => { - return + return } export default Page diff --git a/frontends/main/src/app/(site)/website_content/[type]/[idOrSlug]/edit/page.tsx b/frontends/main/src/app/(site)/website_content/[type]/[idOrSlug]/edit/page.tsx new file mode 100644 index 0000000000..1df7bc1811 --- /dev/null +++ b/frontends/main/src/app/(site)/website_content/[type]/[idOrSlug]/edit/page.tsx @@ -0,0 +1,13 @@ +import React from "react" +import { WebsiteContentEditPage } from "@/app-pages/WebsiteContent/WebsiteContentEditPage" + +const Page = async ({ + params, +}: { + params: Promise<{ type: string; idOrSlug: string }> +}) => { + const { type, idOrSlug } = await params + return +} + +export default Page diff --git a/frontends/main/src/app/(site)/website_content/[type]/new/page.tsx b/frontends/main/src/app/(site)/website_content/[type]/new/page.tsx new file mode 100644 index 0000000000..9318402af2 --- /dev/null +++ b/frontends/main/src/app/(site)/website_content/[type]/new/page.tsx @@ -0,0 +1,16 @@ +import React from "react" +import { Metadata } from "next" +import { standardizeMetadata } from "@/common/metadata" +import { WebsiteContentNewPage } from "@/app-pages/WebsiteContent/WebsiteContentNewPage" + +export const metadata: Metadata = standardizeMetadata({ + title: "MIT Learn | New", + robots: "noindex, nofollow", +}) + +const Page = async ({ params }: { params: Promise<{ type: string }> }) => { + const { type } = await params + return +} + +export default Page diff --git a/frontends/main/src/app/(site)/website_content/drafts/page.tsx b/frontends/main/src/app/(site)/website_content/drafts/page.tsx new file mode 100644 index 0000000000..5d78090e16 --- /dev/null +++ b/frontends/main/src/app/(site)/website_content/drafts/page.tsx @@ -0,0 +1,20 @@ +import React from "react" +import { Metadata } from "next" +import { standardizeMetadata } from "@/common/metadata" +import { WebsiteContentDraftListingPage } from "@/app-pages/WebsiteContent/WebsiteContentDraftListingPage" + +export const metadata: Metadata = standardizeMetadata({ + title: "MIT Learn | Drafts", + robots: "noindex, nofollow", +}) + +const Page = async ({ + searchParams, +}: { + searchParams: Promise<{ content_type?: string }> +}) => { + const { content_type: contentType } = await searchParams + return +} + +export default Page diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts index f697fd5927..7bc48486a2 100644 --- a/frontends/main/src/common/urls.ts +++ b/frontends/main/src/common/urls.ts @@ -36,17 +36,41 @@ export const learningPathsView = (id: number) => export const PROGRAMLETTER_VIEW = "/program_letter/[id]/view/" export const programLetterView = (id: string) => generatePath(PROGRAMLETTER_VIEW, { id: String(id) }) -export const ARTICLES_LISTING = "/news/" -export const ARTICLES_VIEW = "/news/[id]" -export const ARTICLES_DRAFT_VIEW = "/news/[id]/draft" -export const ARTICLES_EDIT = "/news/[id]/edit" -export const ARTICLES_CREATE = "/news/new" -export const articlesView = (id: string) => +export const NEWS_LISTING = "/news/" +export const NEWS_VIEW = "/news/[id]" +export const NEWS_DRAFT_VIEW = "/news/[id]/draft" +export const NEWS_EDIT = "/news/[id]/edit" +export const NEWS_CREATE = "/news/new" +export const newsView = (id: string) => + generatePath(NEWS_VIEW, { id: String(id) }) +export const newsDraftView = (id: string) => + generatePath(NEWS_DRAFT_VIEW, { id: String(id) }) +export const newsEditView = (id: number) => + generatePath(NEWS_EDIT, { id: String(id) }) + +// Articles (served under /articles) +export const ARTICLES_LISTING = "/articles/" +export const ARTICLES_VIEW = "/articles/[id]" +export const ARTICLES_DRAFT_VIEW = "/articles/[id]/draft" +export const articleView = (id: string) => generatePath(ARTICLES_VIEW, { id: String(id) }) -export const articlesDraftView = (id: string) => +export const articleDraftView = (id: string) => generatePath(ARTICLES_DRAFT_VIEW, { id: String(id) }) -export const articlesEditView = (id: number) => - generatePath(ARTICLES_EDIT, { id: String(id) }) + +// Generic website content editing routes +export const WEBSITE_CONTENT_CREATE = "/website_content/[type]/new" +export const WEBSITE_CONTENT_EDIT = "/website_content/[type]/[idOrSlug]/edit" +export const WEBSITE_CONTENT_DRAFTS = "/website_content/drafts" +export const websiteContentCreateView = (type: string) => + `/website_content/${type}/new` +export const websiteContentEditView = ( + type: string, + idOrSlug: string | number, +) => `/website_content/${type}/${idOrSlug}/edit` +export const websiteContentDraftsView = (contentType?: string) => + contentType + ? `${WEBSITE_CONTENT_DRAFTS}?content_type=${contentType}` + : WEBSITE_CONTENT_DRAFTS export const DEPARTMENTS = "/departments/" export const TOPICS = "/topics/" diff --git a/frontends/main/src/common/articleUtils.ts b/frontends/main/src/common/websiteContentUtils.ts similarity index 77% rename from frontends/main/src/common/articleUtils.ts rename to frontends/main/src/common/websiteContentUtils.ts index c4af58b1e4..493021cf51 100644 --- a/frontends/main/src/common/articleUtils.ts +++ b/frontends/main/src/common/websiteContentUtils.ts @@ -1,10 +1,10 @@ /** * Recursively traverses a ProseMirror JSON content structure to find the first image. * - * @param content - The ProseMirror JSON content object from an article + * @param content - The ProseMirror JSON content object * @returns The URL of the first image found, or null if no image exists */ -export function extractFirstImageFromArticle(content: unknown): string | null { +export function extractFirstImage(content: unknown): string | null { if (!content || typeof content !== "object") return null const node = content as Record @@ -21,7 +21,7 @@ export function extractFirstImageFromArticle(content: unknown): string | null { // Recursively check content array if (Array.isArray(node.content)) { for (const childNode of node.content) { - const imageUrl = extractFirstImageFromArticle(childNode) + const imageUrl = extractFirstImage(childNode) if (imageUrl) { return imageUrl } diff --git a/frontends/main/src/common/website_content.ts b/frontends/main/src/common/website_content.ts new file mode 100644 index 0000000000..028e4de043 --- /dev/null +++ b/frontends/main/src/common/website_content.ts @@ -0,0 +1,32 @@ +import type { JSONContent } from "@tiptap/react" +import type { WebsiteContent } from "api/v1" + +export const extractWebsiteContentDescription = ( + content: WebsiteContent, +): string | undefined => { + const banner = content.content?.content?.[0] + const subheading = banner?.content?.[1] + const textNode = subheading?.content?.[0] + return textNode?.text +} + +export const extractImageMetadata = ( + content: WebsiteContent, +): { src: string; alt: string } | null => { + const imageWithCaption = content.content?.content?.find( + (node: JSONContent) => node.type === "imageWithCaption", + ) + + const attrs = imageWithCaption?.attrs as + | { src?: string; alt?: string; caption?: string } + | undefined + + if (!attrs?.src) { + return null + } + + return { + src: attrs.src, + alt: attrs.caption || attrs.alt || "", + } +} diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleContext.tsx b/frontends/main/src/page-components/TiptapEditor/ArticleContext.tsx deleted file mode 100644 index 2dc29b1b72..0000000000 --- a/frontends/main/src/page-components/TiptapEditor/ArticleContext.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { createContext, useContext } from "react" -import type { WebsiteContent } from "api/v1" - -interface ArticleContextValue { - article?: WebsiteContent -} - -const ArticleContext = createContext({}) - -export const ArticleProvider = ArticleContext.Provider - -export function useArticle() { - return useContext(ArticleContext).article -} diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx b/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx deleted file mode 100644 index ec580b7aa5..0000000000 --- a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx +++ /dev/null @@ -1,395 +0,0 @@ -"use client" - -import React, { ChangeEventHandler, useState, useEffect } from "react" -import styled from "@emotion/styled" -import { EditorContext, JSONContent, useEditor } from "@tiptap/react" -import type { WebsiteContent } from "api/v1" -import { - LoadingSpinner, - Typography, - HEADER_HEIGHT, - HEADER_HEIGHT_MD, -} from "ol-components" - -import { Toolbar } from "./vendor/components/tiptap-ui-primitive/toolbar" -import { Spacer } from "./vendor/components/tiptap-ui-primitive/spacer" - -import { TiptapEditor, MainToolbarContent, TipTapViewer } from "./TiptapEditor" -import { ArticleProvider } from "./ArticleContext" - -import { handleImageUpload } from "./vendor/lib/tiptap-utils" -import { useArticleSchema, newArticleDocument } from "./useArticleSchema" - -import "./vendor/styles/_keyframe-animations.scss" -import "./vendor/styles/_variables.scss" -import "./vendor/components/tiptap-templates/simple/simple-editor.scss" - -import { - useArticleCreate, - useArticlePartialUpdate, - useMediaUpload, -} from "api/hooks/articles" -import { Alert, Button, ButtonLink } from "@mitodl/smoot-design" -import { useUserHasPermission, Permission } from "api/hooks/user" -import dynamic from "next/dynamic" -import { extractLearningResourceIds, contentsMatch } from "./extensions/utils" -import { LearningResourceProvider } from "./extensions/node/LearningResource/LearningResourceDataProvider" - -const LearningResourceDrawer = dynamic( - () => - import("@/page-components/LearningResourceDrawer/LearningResourceDrawer"), - { ssr: false }, -) - -const TOOLBAR_HEIGHT = 43 - -const ViewContainer = styled.div<{ toolbarVisible: boolean }>( - ({ toolbarVisible, theme }) => ({ - width: "100vw", - marginTop: toolbarVisible ? TOOLBAR_HEIGHT : 0, - backgroundColor: theme.custom.colors.white, - }), -) - -const StyledToolbar = styled(Toolbar)(({ theme }) => ({ - "&&": { - position: "fixed", - top: HEADER_HEIGHT, - [theme.breakpoints.down("md")]: { - top: HEADER_HEIGHT_MD, - }, - }, -})) - -const StyledAlert = styled(Alert)({ - margin: "20px auto", - maxWidth: "1000px", - position: "fixed", - top: "108px", - left: "50%", - width: "690px", - transform: "translateX(-50%)", - zIndex: 1, - "p:not(:first-child)": { - margin: "10px 0", - }, -}) - -interface ArticleEditorProps { - value?: object - onSave?: (article: WebsiteContent) => void - readOnly?: boolean - title?: string - setTitle?: ChangeEventHandler - article?: WebsiteContent -} -const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { - const [title, setTitle] = React.useState(article?.title) - const [isPublishing, setIsPublishing] = useState(false) - const [uploadError, setUploadError] = useState(null) - const [resetAttempted, setResetAttempted] = useState(false) - - const { - mutate: createArticle, - isPending: isCreating, - error: createError, - } = useArticleCreate() - const { - mutate: updateArticle, - isPending: isUpdating, - error: updateError, - } = useArticlePartialUpdate() - - const uploadImage = useMediaUpload() - - const isArticleEditor = useUserHasPermission(Permission.ArticleEditor) - - const [content, setContent] = useState( - article?.content || newArticleDocument, - ) - const [touched, setTouched] = useState(false) - - // Extract author_name from the byline node - const extractAuthorName = (content: JSONContent): string | "" => { - const bylineNode = content.content?.find((node) => node.type === "byline") - return bylineNode?.attrs?.authorName || "" - } - - const handleSave = (publish: boolean) => { - if (!title) return - const authorName = extractAuthorName(content) - if (article) { - updateArticle( - { - id: article.id, - title: title.trim(), - content, - is_published: publish, - author_name: authorName, - }, - { - onSuccess: onSave, - }, - ) - } else { - createArticle( - { - title: title.trim(), - content, - is_published: publish, - author_name: authorName, - }, - { - onSuccess: onSave, - }, - ) - } - } - - const uploadHandler = async ( - file: File, - onProgress?: (e: { progress: number }) => void, - abortSignal?: AbortSignal, - ) => { - setUploadError(null) - return handleImageUpload( - file, - async (file: File, progressCb?: (percent: number) => void) => { - try { - uploadImage.setNextProgressCallback(progressCb) - - const response = await uploadImage.mutateAsync({ file }) - - if (!response?.url) throw new Error("Upload failed") - return response.url - } catch (error) { - if (error instanceof Error) { - setUploadError(error.message) - } else { - setUploadError(String(error) || "Upload failed") - } - - throw error - } - }, - onProgress, - abortSignal, - ) - } - - const { extensions, schemaError } = useArticleSchema({ - uploadHandler, - setUploadError, - enabled: isArticleEditor, - content, - }) - - const editor = useEditor({ - immediatelyRender: false, - shouldRerenderOnTransaction: false, - content, - editable: !readOnly, - - onUpdate: ({ editor }) => { - const json = editor.getJSON() - setContent(json) - setTouched(true) - }, - - onCreate: ({ editor }) => { - setTimeout(() => { - editor.commands.setTextSelection(1) - editor.commands.focus() - }, 0) - - editor.commands.updateAttributes("mediaEmbed", { editable: !readOnly }) - editor.commands.updateAttributes("byline", { editable: readOnly }) - }, - - editorProps: { - attributes: { - autocomplete: "off", - autocorrect: "off", - autocapitalize: "off", - "aria-label": "Main content area, start typing to enter text.", - class: "simple-editor", - }, - }, - extensions, - }) - - useEffect(() => { - if (!article || !editor) return - - if (article.content) { - const currentContent = editor.getJSON() - if (!contentsMatch(article.content, currentContent)) { - setContent(article.content) - setTouched(true) - editor.commands.setContent(article.content) - } - } - - if (article.title !== undefined) { - setTitle(article.title) - } - }, [article, editor]) - - useEffect(() => { - if (!editor) return - const title = editor.$node("heading", { level: 1 })?.textContent || "" - setTitle(title) - }, [editor, content]) - - useEffect(() => { - if (!editor) return - editor - .chain() - .command(({ tr, state }) => { - state.doc.descendants((node, pos) => { - if ( - node.type.name === "mediaEmbed" || - node.type.name === "imageWithCaption" || - node.type.name === "byline" || - node.type.name === "learningResource" - ) { - tr.setNodeMarkup(pos, undefined, { - ...node.attrs, - editable: !readOnly, - }) - } - }) - return true - }) - .run() - }, [editor, readOnly]) - - if (!editor) return null - - const isPending = isCreating || isUpdating - const error = createError || updateError || uploadError || schemaError - - const resourceIds = extractLearningResourceIds(content) - - return ( - - - - - {isArticleEditor ? ( - readOnly ? ( - - - - Drafts - - - Edit - - - ) : ( - - - {!article?.is_published ? ( - - ) : null} - - - - ) - ) : null} - {error ? ( - - - {error instanceof Error ? error.message : error} - - {schemaError && !readOnly ? ( - <> - - Reset to attempt to align the article to the content - template. - - {resetAttempted ? ( - - Reset attempt failed. - - ) : null} - - - ) : null} - - ) : null} - {readOnly ? ( - <> - - - - ) : ( - - )} - - - - - ) -} - -export { ArticleEditor } diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleViewer.test.tsx b/frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx similarity index 85% rename from frontends/main/src/page-components/TiptapEditor/ArticleViewer.test.tsx rename to frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx index 8ee3150d3b..abf5b69a07 100644 --- a/frontends/main/src/page-components/TiptapEditor/ArticleViewer.test.tsx +++ b/frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx @@ -1,17 +1,18 @@ import React from "react" import { screen, renderWithProviders, setMockResponse } from "@/test-utils" import { factories, urls } from "api/test-utils" -import { ArticleEditor } from "./ArticleEditor" +import { NewsEditor } from "./contentTypes/news/NewsEditor" +import { ArticleEditor } from "./contentTypes/article/ArticleEditor" -describe("ArticleViewer", () => { - test("renders article content", async () => { +describe("NewsViewer", () => { + test("renders content", async () => { const user = factories.user.user({ is_authenticated: true, is_article_editor: true, }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const newsItem = factories.websiteContent.websiteContent({ content: { type: "doc", content: [ @@ -64,7 +65,7 @@ describe("ArticleViewer", () => { }, }) - renderWithProviders() + renderWithProviders() await screen.findByRole("heading", { name: "Test Title", level: 1 }) await screen.findByText("Test subheading") @@ -78,14 +79,47 @@ describe("ArticleViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) const authorName = `${user.first_name} ${user.last_name}` - const article = factories.articles.article({ + const newsItem = factories.websiteContent.websiteContent({ user, author_name: authorName, }) + renderWithProviders() + + await screen.findByText(`By ${authorName}`) + }) + + test("article read-only renders the byline with a share button", async () => { + const user = factories.user.user({ + is_authenticated: true, + is_article_editor: true, + }) + setMockResponse.get(urls.userMe.get(), user) + const authorName = `${user.first_name} ${user.last_name}` + const article = factories.websiteContent.websiteContent({ + author_name: authorName, + content: { + type: "doc", + content: [ + { + type: "banner", + content: [ + { type: "heading", attrs: { level: 1 }, content: [] }, + { type: "paragraph", content: [] }, + ], + }, + { type: "byline" }, + { type: "paragraph", content: [] }, + ], + }, + }) + renderWithProviders() await screen.findByText(`By ${authorName}`) + expect( + screen.getByRole("button", { name: /share this article/i }), + ).toBeInTheDocument() }) test("renders headings levels 1-6", async () => { @@ -95,7 +129,7 @@ describe("ArticleViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const newsItem = factories.websiteContent.websiteContent({ content: { type: "doc", content: [ @@ -202,7 +236,7 @@ describe("ArticleViewer", () => { }, }) - renderWithProviders() + renderWithProviders() await screen.findByRole("heading", { level: 1, name: "Heading Level 1" }) await screen.findByRole("heading", { level: 2, name: "Heading Level 2" }) @@ -219,7 +253,7 @@ describe("ArticleViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const newsItem = factories.websiteContent.websiteContent({ content: { type: "doc", content: [ @@ -294,7 +328,7 @@ describe("ArticleViewer", () => { }, }) - renderWithProviders() + renderWithProviders() const firstUnordered = await screen.findByText("First unordered item") const secondUnordered = await screen.findByText("Second unordered item") @@ -321,7 +355,7 @@ describe("ArticleViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const newsItem = factories.websiteContent.websiteContent({ content: { type: "doc", content: [ @@ -398,7 +432,7 @@ describe("ArticleViewer", () => { }, }) - renderWithProviders() + renderWithProviders() const boldText = await screen.findByText("bold text") expect(boldText).toBeInTheDocument() @@ -428,7 +462,7 @@ describe("ArticleViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const newsItem = factories.websiteContent.websiteContent({ content: { type: "doc", content: [ @@ -486,7 +520,7 @@ describe("ArticleViewer", () => { }, }) - renderWithProviders() + renderWithProviders() const link = await screen.findByRole("link", { name: "example.com" }) expect(link).toBeInTheDocument() @@ -500,8 +534,8 @@ describe("ArticleViewer", () => { is_article_editor: true, }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article() - renderWithProviders() + const newsItem = factories.websiteContent.websiteContent() + renderWithProviders() await screen.findByRole("link", { name: "Edit" }) }) diff --git a/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx b/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx index e86152ed18..47b7613a3e 100644 --- a/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx @@ -49,7 +49,7 @@ import "./vendor/components/tiptap-templates/simple/simple-editor.scss" import "./TiptapEditor.styles.scss" import { BannerViewer } from "./extensions/node/Banner/BannerNode" -import { ArticleByLineInfoBarViewer } from "./extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarViewer" +import { ByLineInfoBarViewer } from "./extensions/node/ByLineInfoBar/ByLineInfoBarViewer" import { ImageWithCaptionViewer } from "./extensions/node/Image/ImageWithCaption" import { DividerViewer } from "./extensions/node/Divider/DividerNode" import { LearningResourceButton } from "./extensions/ui/LearningResource/LearningResourceButton" @@ -61,7 +61,7 @@ const Container = styled.div<{ }>(({ theme, readOnly }) => ({ maxWidth: "890px", minHeight: "calc(100vh - 350px)", - backgroundColor: theme.custom.colors.white, + backgroundColor: "transparent", borderRadius: "10px", margin: "0 auto", @@ -71,6 +71,7 @@ const Container = styled.div<{ padding: "0 16px", }, }, + ...(readOnly ? { backgroundColor: "transparent", @@ -272,17 +273,12 @@ interface TiptapEditorProps { editor: Editor readOnly?: boolean fullWidth?: boolean - className?: string } -const TiptapEditor = ({ editor, className }: TiptapEditorProps) => { +const TiptapEditor = ({ editor }: TiptapEditorProps) => { return ( - - + + ) } @@ -290,9 +286,11 @@ const TiptapEditor = ({ editor, className }: TiptapEditorProps) => { const TipTapViewer = ({ content, extensions, + bannerViewer = BannerViewer, }: { content: JSONContent extensions: Array + bannerViewer?: typeof BannerViewer }) => { return ( @@ -310,8 +308,8 @@ const TipTapViewer = ({ * See https://tiptap.dev/docs/editor/api/utilities/static-renderer#react-nodeviews */ nodeMapping: { - banner: BannerViewer, - byline: ArticleByLineInfoBarViewer, + banner: bannerViewer, + byline: ByLineInfoBarViewer, divider: DividerViewer, imageWithCaption: ImageWithCaptionViewer, learningResource: LearningResourceCardViewer, diff --git a/frontends/main/src/page-components/TiptapEditor/WebsiteContentContext.tsx b/frontends/main/src/page-components/TiptapEditor/WebsiteContentContext.tsx new file mode 100644 index 0000000000..9c71fd4eb4 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/WebsiteContentContext.tsx @@ -0,0 +1,14 @@ +import { createContext, useContext } from "react" +import type { WebsiteContent } from "api/v1" + +interface WebsiteContentContextValue { + contentItem?: WebsiteContent +} + +const WebsiteContentContext = createContext({}) + +export const WebsiteContentProvider = WebsiteContentContext.Provider + +export function useWebsiteContent() { + return useContext(WebsiteContentContext).contentItem +} diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.happydom.test.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.happydom.test.tsx new file mode 100644 index 0000000000..d216f99b41 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.happydom.test.tsx @@ -0,0 +1,56 @@ +/** + * @jest-environment @happy-dom/jest-environment + * + * Using the Happy DOM environment as the editor accesses DOM APIs and uses + * contenteditable elements not supported by JSDOM, the default Jest environment. + */ +import React from "react" +import { screen } from "@testing-library/react" +import { setMockResponse, factories, urls } from "api/test-utils" +import type { JSONContent } from "@tiptap/react" +import { ArticleEditor } from "./ArticleEditor" +import { renderWithProviders } from "@/test-utils" + +const content: JSONContent = { + type: "doc", + content: [ + { + type: "banner", + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [{ type: "text", text: "Article Title" }], + }, + { type: "paragraph", content: [] }, + ], + }, + { type: "byline" }, + { type: "paragraph", content: [] }, + ], +} + +const renderArticleEditor = () => { + const user = factories.user.user({ + is_authenticated: true, + is_article_editor: true, + }) + setMockResponse.get(urls.userMe.get(), user) + const article = factories.websiteContent.websiteContent({ content }) + renderWithProviders(, { user }) +} + +describe("ArticleEditor", () => { + test("mounts the live editor with an editable banner heading", async () => { + renderArticleEditor() + + await screen.findByTestId("editor") + await screen.findByRole("heading", { level: 1, name: "Article Title" }) + }) + + test("renders the article breadcrumb bar in edit mode", async () => { + renderArticleEditor() + + await screen.findByText("Articles") + }) +}) diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx new file mode 100644 index 0000000000..c7ced70591 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx @@ -0,0 +1,91 @@ +"use client" + +import React from "react" +import styled from "@emotion/styled" +import { WebsiteContentContentTypeEnum, type WebsiteContent } from "api/v1" +import { + useWebsiteContentCreate, + useWebsiteContentPartialUpdate, + useMediaUpload, +} from "api/hooks/website_content" +import { WebsiteContentEditor } from "../../core/WebsiteContentEditor" +import { + createArticleExtensions, + newArticleDocument, +} from "./articleExtensions" +import { ArticleBannerViewer } from "../../extensions/node/Banner/ArticleBannerNode" + +/** + * Article-specific byline look: merged into the white banner (no bar chrome) with + * a "·" separator. Styling lives here (not in the byline node) by targeting the + * node's published hook classes, so the node stays content-type-agnostic. + */ +const StyledWebsiteContentEditor = styled(WebsiteContentEditor)( + ({ theme }) => ({ + // Article sits on a gray page; the banner + byline render as white islands. + backgroundColor: theme.custom.colors.lightGray1, + ".byline-info-bar": { + boxShadow: "none", + border: "none", + marginBottom: "40px", + paddingTop: 0, + }, + ".byline-info-bar__separator::before": { + content: '"·"', + }, + }), +) + +// Article-specific: extract author name from the byline node +const extractArticleExtraFields = (content: { + content?: Array<{ type?: string; attrs?: Record }> +}): Record => { + const bylineNode = content.content?.find((node) => node.type === "byline") + return { + author_name: bylineNode?.attrs?.authorName || "", + content_type: "article", + } +} + +interface ArticleEditorProps { + onSave?: (article: WebsiteContent) => void + readOnly?: boolean + article?: WebsiteContent +} + +/** + * Editor shell configured for the article content type (served under /articles). + * Owns its own save mutations so WebsiteContentEditor stays API-agnostic. + * + * Currently uses the same websiteContent API as the news editor. If /articles + * later needs a dedicated endpoint, swap in the new hooks here: + * + * const createMutation = useArticleCreate() // future hook + * const updateMutation = useArticlePartialUpdate() + * + * WebsiteContentEditor does not need to change at all. + */ +const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { + // Swap these hooks when a dedicated article API exists. + const createMutation = useWebsiteContentCreate() + const updateMutation = useWebsiteContentPartialUpdate() + const uploadImage = useMediaUpload() + + return ( + + ) +} + +export { ArticleEditor } +export type { ArticleEditorProps } diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/articleExtensions.ts b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/articleExtensions.ts new file mode 100644 index 0000000000..829468b6b5 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/articleExtensions.ts @@ -0,0 +1,49 @@ +import type { Extension, Node, Mark } from "@tiptap/core" +import Document from "@tiptap/extension-document" +import { ArticleBannerNode } from "../../extensions/node/Banner/ArticleBannerNode" +import { ByLineInfoBarNode } from "../../extensions/node/ByLineInfoBar/ByLineInfoBarNode" +import { createBaseExtensions } from "../../extensions/baseExtensions" +import type { CreateExtensionsFn } from "../../core/WebsiteContentEditor" + +export const ArticleDocument = Document.extend({ + content: "banner byline block+", +}) + +export const newArticleDocument = { + type: "doc", + content: [ + { + type: "banner", + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [], + }, + { + type: "paragraph", + content: [], + }, + ], + }, + { + type: "byline", + attrs: { authorName: null }, + }, + { type: "paragraph", content: [] }, + ], +} + +/** + * Factory function that builds the full extensions list for the article content type. + * Pass to WebsiteContentEditor as `createExtensions`. + */ +export const createArticleExtensions: CreateExtensionsFn = ( + uploadHandler, + setUploadError, +): (Extension | Node | Mark)[] => [ + ArticleDocument, + ...createBaseExtensions(uploadHandler, setUploadError), + ArticleBannerNode, + ByLineInfoBarNode, +] diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.happydom.test.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx similarity index 97% rename from frontends/main/src/page-components/TiptapEditor/ArticleEditor.happydom.test.tsx rename to frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx index 95f0ef7026..0730eec432 100644 --- a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.happydom.test.tsx +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx @@ -8,7 +8,7 @@ import React from "react" import { screen, waitFor, fireEvent } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { setMockResponse, factories, urls, makeRequest } from "api/test-utils" -import { ArticleEditor } from "./ArticleEditor" +import { NewsEditor } from "./NewsEditor" import type { JSONContent } from "@tiptap/react" import { renderWithProviders } from "@/test-utils" @@ -19,7 +19,7 @@ jest.mock("posthog-js/react", () => ({ const mockOnSave = jest.fn() -describe("ArticleEditor - Content Editing and Saving", () => { +describe("NewsEditor - Content Editing and Saving", () => { beforeEach(() => { mockOnSave.mockClear() jest.clearAllMocks() @@ -36,21 +36,23 @@ describe("ArticleEditor - Content Editing and Saving", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const newsItem = factories.websiteContent.websiteContent({ id: articleId, title, content, is_published: false, }) - setMockResponse.get(urls.websiteContent.details(articleId), article) + setMockResponse.get(urls.websiteContent.details(articleId), newsItem) renderWithProviders( - , - { user }, + , + { + user, + }, ) await screen.findByTestId("editor") - return article + return newsItem } describe("Editing title in banner heading", () => { @@ -377,7 +379,7 @@ describe("ArticleEditor - Content Editing and Saving", () => { }) describe("Save as Draft functionality", () => { - test("can save article as draft", async () => { + test("can save news as draft", async () => { const initialContent: JSONContent = { type: "doc", content: [ @@ -405,23 +407,23 @@ describe("ArticleEditor - Content Editing and Saving", () => { ], } - const article = await setupEditor(initialContent, 208, "Title") + const newsItem = await setupEditor(initialContent, 208, "Title") const paragraph = screen.getByText("Content") await userEvent.click(paragraph) await userEvent.keyboard("{Control>}a{/Control}") await userEvent.type(paragraph, "Updated content") - const updatedArticle = { - ...article, + const updatedNewsItem = { + ...newsItem, content: expect.objectContaining({ type: "doc", }), is_published: false, } setMockResponse.patch( - urls.websiteContent.details(article.id), - updatedArticle, + urls.websiteContent.details(newsItem.id), + updatedNewsItem, ) const saveDraftButton = await screen.findByRole("button", { @@ -434,7 +436,7 @@ describe("ArticleEditor - Content Editing and Saving", () => { expect(makeRequest).toHaveBeenCalledWith( "patch", - urls.websiteContent.details(article.id), + urls.websiteContent.details(newsItem.id), expect.objectContaining({ is_published: false, author_name: "", @@ -506,22 +508,22 @@ describe("ArticleEditor - Content Editing and Saving", () => { }) }) - describe("Creating new articles", () => { - test("submits article successfully", async () => { + describe("Creating news", () => { + test("submits news successfully", async () => { const user = factories.user.user({ is_authenticated: true, is_article_editor: true, }) setMockResponse.get(urls.userMe.get(), user) - const createdArticle = factories.articles.article({ + const createdNewsItem = factories.websiteContent.websiteContent({ id: 101, title: "My Article", is_published: true, }) - setMockResponse.post(urls.websiteContent.list(), createdArticle) + setMockResponse.post(urls.websiteContent.list(), createdNewsItem) - renderWithProviders(, { user }) + renderWithProviders(, { user }) await screen.findByTestId("editor") @@ -599,7 +601,7 @@ describe("ArticleEditor - Content Editing and Saving", () => { expect(savedData).toBeDefined() expect(savedData).toMatchObject({ - id: createdArticle.id, + id: createdNewsItem.id, title: "My Article", is_published: true, }) @@ -607,7 +609,7 @@ describe("ArticleEditor - Content Editing and Saving", () => { }) }) -describe("ArticleEditor - Document Rendering", () => { +describe("NewsEditor - Document Rendering", () => { let consoleErrorSpy: ReturnType beforeEach(() => { @@ -639,20 +641,20 @@ describe("ArticleEditor - Document Rendering", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const newsItem = factories.websiteContent.websiteContent({ id: 1, title: "Test Article", content, }) - setMockResponse.get(urls.websiteContent.details(articleId), article) + setMockResponse.get(urls.websiteContent.details(articleId), newsItem) renderWithProviders( - , + , { user }, ) await screen.findByTestId("editor") - return article + return newsItem } test("renders editor when user has ArticleEditor permission", async () => { @@ -662,7 +664,7 @@ describe("ArticleEditor - Document Rendering", () => { }) setMockResponse.get(urls.userMe.get(), user) - renderWithProviders(, { user }) + renderWithProviders(, { user }) await screen.findByTestId("editor") }) diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx new file mode 100644 index 0000000000..8b215712f2 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx @@ -0,0 +1,58 @@ +"use client" + +import React from "react" +import { WebsiteContentContentTypeEnum, type WebsiteContent } from "api/v1" +import { + useWebsiteContentCreate, + useWebsiteContentPartialUpdate, + useMediaUpload, +} from "api/hooks/website_content" +import { WebsiteContentEditor } from "../../core/WebsiteContentEditor" +import { createNewsExtensions, newNewsDocument } from "./newsExtensions" + +// News-specific: extract the author name from the byline node in the document +const extractNewsExtraFields = (content: { + content?: Array<{ type?: string; attrs?: Record }> +}): Record => { + const bylineNode = content.content?.find((node) => node.type === "byline") + return { + author_name: bylineNode?.attrs?.authorName || "", + content_type: "news", + } +} + +interface NewsEditorProps { + onSave?: (savedContent: WebsiteContent) => void + readOnly?: boolean + newsItem?: WebsiteContent +} + +/** + * Editor shell configured for the news content type (served under /news). + * Owns its own save mutations (websiteContent API) and passes them to + * WebsiteContentEditor — keeping the generic shell decoupled from any specific API. + */ +const NewsEditor = ({ onSave, readOnly, newsItem }: NewsEditorProps) => { + // News content type uses the websiteContent API. + // A different content type would call different hooks here. + const createMutation = useWebsiteContentCreate() + const updateMutation = useWebsiteContentPartialUpdate() + const uploadImage = useMediaUpload() + + return ( + + ) +} + +export { NewsEditor } +export type { NewsEditorProps } diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/newsExtensions.ts b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/newsExtensions.ts new file mode 100644 index 0000000000..05a650b379 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/newsExtensions.ts @@ -0,0 +1,49 @@ +import type { Extension, Node, Mark } from "@tiptap/core" +import Document from "@tiptap/extension-document" +import { BannerNode } from "../../extensions/node/Banner/BannerNode" +import { ByLineInfoBarNode } from "../../extensions/node/ByLineInfoBar/ByLineInfoBarNode" +import { createBaseExtensions } from "../../extensions/baseExtensions" +import type { CreateExtensionsFn } from "../../core/WebsiteContentEditor" + +export const NewsDocument = Document.extend({ + content: "banner byline block+", +}) + +export const newNewsDocument = { + type: "doc", + content: [ + { + type: "banner", + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [], + }, + { + type: "paragraph", + content: [], + }, + ], + }, + { + type: "byline", + attrs: { authorName: null }, + }, + { type: "paragraph", content: [] }, + ], +} + +/** + * Factory function that builds the full extensions list for the news content type. + * Pass to WebsiteContentEditor as `createExtensions`. + */ +export const createNewsExtensions: CreateExtensionsFn = ( + uploadHandler, + setUploadError, +): (Extension | Node | Mark)[] => [ + NewsDocument, + ...createBaseExtensions(uploadHandler, setUploadError), + BannerNode, + ByLineInfoBarNode, +] diff --git a/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx b/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx new file mode 100644 index 0000000000..a6fc8a5ad8 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx @@ -0,0 +1,499 @@ +"use client" + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import styled from "@emotion/styled" +import { EditorContext, JSONContent, useEditor } from "@tiptap/react" +import type { Extension, Node, Mark } from "@tiptap/core" +import { getSchema } from "@tiptap/core" +import type { WebsiteContent, WebsiteContentContentTypeEnum } from "api/v1" +import { + LoadingSpinner, + Typography, + HEADER_HEIGHT, + HEADER_HEIGHT_MD, +} from "ol-components" +import { Alert, Button, ButtonLink } from "@mitodl/smoot-design" +import { useUserHasPermission, Permission } from "api/hooks/user" +import dynamic from "next/dynamic" + +import { Toolbar } from "../vendor/components/tiptap-ui-primitive/toolbar" +import { TiptapEditor, MainToolbarContent, TipTapViewer } from "../TiptapEditor" +import { BannerViewer } from "../extensions/node/Banner/BannerNode" +import { Spacer } from "../vendor/components/tiptap-ui-primitive/spacer" +import { handleImageUpload } from "../vendor/lib/tiptap-utils" +import { useSchema } from "../useSchema" +import { WebsiteContentProvider } from "../WebsiteContentContext" +import { extractLearningResourceIds, contentsMatch } from "../extensions/utils" +import { LearningResourceProvider } from "../extensions/node/LearningResource/LearningResourceDataProvider" +import { websiteContentDraftsView, websiteContentEditView } from "@/common/urls" + +const LearningResourceDrawer = dynamic( + () => + import("@/page-components/LearningResourceDrawer/LearningResourceDrawer"), + { ssr: false }, +) + +const TOOLBAR_HEIGHT = 43 + +const ViewContainer = styled.div<{ + toolbarVisible: boolean +}>(({ toolbarVisible, theme }) => ({ + width: "100vw", + marginTop: toolbarVisible ? TOOLBAR_HEIGHT : 0, + backgroundColor: theme.custom.colors.white, +})) + +const StyledToolbar = styled(Toolbar)(({ theme }) => ({ + "&&": { + position: "fixed", + top: HEADER_HEIGHT, + [theme.breakpoints.down("md")]: { + top: HEADER_HEIGHT_MD, + }, + }, +})) + +const StyledAlert = styled(Alert)({ + margin: "20px auto", + maxWidth: "1000px", + position: "fixed", + top: "108px", + left: "50%", + width: "690px", + transform: "translateX(-50%)", + zIndex: 1, + "p:not(:first-child)": { + margin: "10px 0", + }, +}) + +export type UploadHandler = ( + file: File, + onProgress?: (e: { progress: number }) => void, + abortSignal?: AbortSignal, +) => Promise + +/** + * The minimal interface expected from a media upload mutation. + * Matches the shape returned by `useMediaUpload` from `api/hooks/website_content`, + * but callers may supply any compatible implementation. + */ +export interface MediaUpload { + mutateAsync: (data: { file: File }) => Promise<{ url?: string }> + setNextProgressCallback: ( + callback: ((percent: number) => void) | undefined, + ) => void +} + +/** + * The data shape sent to the create/update API. + * `[key: string]: unknown` allows per-type extra fields (e.g. author_name). + */ +export interface SavePayload { + title: string + content: JSONContent + is_published: boolean + [key: string]: unknown +} + +/** + * Per-type save mutations. Each content type owns its own API hooks and passes + * the resulting mutation objects here, so WebsiteContentEditor never imports a + * specific API hook directly. + * + * Example — news type uses websiteContent API: + * const create = useWebsiteContentCreate() + * const update = useWebsiteContentPartialUpdate() + * + * + * A future content type could use a different API hook: + * const create = useSpecializedContentCreate() // future hook + * const update = useSpecializedContentPartialUpdate() + * + */ +export interface SaveMutations { + create: { + mutate: ( + data: SavePayload, + options: { onSuccess?: (result: WebsiteContent) => void }, + ) => void + isPending: boolean + error: Error | null | unknown + } + update: { + mutate: ( + data: SavePayload & { id: number }, + options: { onSuccess?: (result: WebsiteContent) => void }, + ) => void + isPending: boolean + error: Error | null | unknown + } +} + +/** + * A factory function that builds the Tiptap extensions for a given content type. + * Receives upload utilities so extensions that handle image upload can be configured. + */ +export type CreateExtensionsFn = ( + uploadHandler: UploadHandler, + setUploadError: (error: string | null) => void, +) => (Extension | Node | Mark)[] + +export interface WebsiteContentEditorProps { + /** + * Factory that builds the full extensions list for this content type. + * Must be a stable reference (module-level function or useCallback). + */ + createExtensions: CreateExtensionsFn + /** Initial document structure when no content item is provided. */ + initialDoc: JSONContent + /** Content type for route generation (Drafts/Edit links in read-only toolbar). */ + contentType: WebsiteContentContentTypeEnum + /** + * Optional CSS class applied to the editor root container (covers both edit + * and read-only). Used by content-type wrappers via `styled(WebsiteContentEditor)` + * to theme nodes through their hook classes. + */ + className?: string + /** + * Extract additional fields to include in the save payload. + * E.g., for news: `(content) => ({ author_name: extractAuthorName(content) })` + */ + extractExtraFields?: (content: JSONContent) => Record + /** + * Mutations for create and update. Provided by the content-type wrapper so + * WebsiteContentEditor stays decoupled from any specific API endpoint. + */ + saveMutations: SaveMutations + /** + * Upload mutation provided by the content-type wrapper. + * Pass the return value of `useMediaUpload()` (or a compatible implementation) + * so WebsiteContentEditor stays decoupled from any specific upload endpoint. + */ + uploadImage: MediaUpload + onSave?: (contentItem: WebsiteContent) => void + readOnly?: boolean + contentItem?: WebsiteContent + bannerViewer?: typeof BannerViewer +} + +const WebsiteContentEditor = ({ + createExtensions, + contentType, + initialDoc, + className, + extractExtraFields, + saveMutations, + uploadImage, + onSave, + readOnly, + contentItem, + bannerViewer, +}: WebsiteContentEditorProps) => { + const [isPublishing, setIsPublishing] = useState(false) + const [uploadError, setUploadError] = useState(null) + const [resetAttempted, setResetAttempted] = useState(false) + const [content, setContent] = useState( + contentItem?.content || initialDoc, + ) + const [title, setTitle] = useState(contentItem?.title) + const [touched, setTouched] = useState(false) + + const { create: createMutation, update: updateMutation } = saveMutations + const isPending = createMutation.isPending || updateMutation.isPending + const saveError = createMutation.error || updateMutation.error + + // Keep a ref so the stable uploadHandler callback always calls the latest mutation. + const uploadImageRef = useRef(uploadImage) + uploadImageRef.current = uploadImage + + const isArticleEditor = useUserHasPermission(Permission.ArticleEditor) + + const uploadHandler = useCallback( + async (file, onProgress, abortSignal) => { + setUploadError(null) + return handleImageUpload( + file, + async (f, progressCb) => { + try { + uploadImageRef.current.setNextProgressCallback(progressCb) + const response = await uploadImageRef.current.mutateAsync({ + file: f, + }) + if (!response?.url) throw new Error("Upload failed") + return response.url + } catch (error) { + const msg = + error instanceof Error + ? error.message + : String(error) || "Upload failed" + setUploadError(msg) + throw error + } + }, + onProgress, + abortSignal, + ) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ) + + const extensions = useMemo( + () => createExtensions(uploadHandler, setUploadError), + [createExtensions, uploadHandler], + ) + + const schema = useMemo(() => getSchema(extensions), [extensions]) + + const schemaError = useSchema({ + schema, + content, + enabled: isArticleEditor, + }) + + const handleSave = (publish: boolean) => { + if (!title) return + const extraFields = extractExtraFields?.(content) ?? {} + if (contentItem) { + updateMutation.mutate( + { + id: contentItem.id, + title: title.trim(), + content, + is_published: publish, + ...extraFields, + }, + { onSuccess: onSave }, + ) + } else { + createMutation.mutate( + { + title: title.trim(), + content, + is_published: publish, + ...extraFields, + }, + { onSuccess: onSave }, + ) + } + } + + const editor = useEditor({ + immediatelyRender: false, + shouldRerenderOnTransaction: false, + content, + editable: !readOnly, + + onUpdate: ({ editor }) => { + const json = editor.getJSON() + setContent(json) + setTouched(true) + }, + + onCreate: ({ editor }) => { + setTimeout(() => { + editor.commands.setTextSelection(1) + editor.commands.focus() + }, 0) + + editor.commands.updateAttributes("mediaEmbed", { editable: !readOnly }) + editor.commands.updateAttributes("byline", { editable: readOnly }) + }, + + editorProps: { + attributes: { + autocomplete: "off", + autocorrect: "off", + autocapitalize: "off", + "aria-label": "Main content area, start typing to enter text.", + class: "simple-editor", + }, + }, + extensions, + }) + + // Sync incoming content changes (e.g., after a refetch) + useEffect(() => { + if (!contentItem || !editor) return + + if (contentItem.content) { + const currentContent = editor.getJSON() + if (!contentsMatch(contentItem.content, currentContent)) { + setContent(contentItem.content) + setTouched(true) + editor.commands.setContent(contentItem.content) + } + } + + if (contentItem.title !== undefined) { + setTitle(contentItem.title) + } + }, [contentItem, editor]) + + // Keep title in sync with the h1 heading inside the editor + useEffect(() => { + if (!editor) return + const headingTitle = + editor.$node("heading", { level: 1 })?.textContent || "" + setTitle(headingTitle) + }, [editor, content]) + + // Propagate readOnly changes to interactive node attrs + useEffect(() => { + if (!editor) return + editor + .chain() + .command(({ tr, state }) => { + state.doc.descendants((node, pos) => { + if ( + node.type.name === "mediaEmbed" || + node.type.name === "imageWithCaption" || + node.type.name === "byline" || + node.type.name === "learningResource" + ) { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + editable: !readOnly, + }) + } + }) + return true + }) + .run() + }, [editor, readOnly]) + + if (!editor) return null + + const error = saveError || uploadError || schemaError + const errorMessage = + error instanceof Error ? error.message : (error as string | null) + const resourceIds = extractLearningResourceIds(content) + const editIdOrSlug = contentItem?.is_published + ? (contentItem?.slug ?? contentItem.id) + : contentItem?.id + const readOnlyToolbarSlot = ( + <> + + + Drafts + + {editIdOrSlug !== undefined ? ( + + Edit + + ) : null} + + ) + + return ( + + + + + {isArticleEditor ? ( + readOnly ? ( + {readOnlyToolbarSlot} + ) : ( + + + {!contentItem?.is_published ? ( + + ) : null} + + + ) + ) : null} + + {error ? ( + + + {errorMessage} + + {schemaError && !readOnly ? ( + <> + + Reset to attempt to align the content to the template. + + {resetAttempted ? ( + + Reset attempt failed. + + ) : null} + + + ) : null} + + ) : null} + + {readOnly ? ( + <> + + + + ) : ( + + )} + + + + + ) +} + +export { WebsiteContentEditor } diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/baseExtensions.ts b/frontends/main/src/page-components/TiptapEditor/extensions/baseExtensions.ts new file mode 100644 index 0000000000..3cf7de88a4 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/extensions/baseExtensions.ts @@ -0,0 +1,121 @@ +/** + * Tier-1 base extensions: common to all website content types. + * Constructible from only (uploadHandler, setUploadError) — no type-specific args. + * + * Each content type's createExtensions factory collapses to: + * [TypeDocument, ...createBaseExtensions(deps), TypeBannerNode, ByLineInfoBarNode] + */ +import type { Extension, Node, Mark } from "@tiptap/core" +import { Placeholder, Selection } from "@tiptap/extensions" +import { StarterKit } from "@tiptap/starter-kit" +import { TaskItem, TaskList } from "@tiptap/extension-list" +import { Heading } from "@tiptap/extension-heading" +import { Image } from "@tiptap/extension-image" +import { TextAlign } from "@tiptap/extension-text-align" +import { Typography as TiptapTypography } from "@tiptap/extension-typography" +import { Subscript } from "@tiptap/extension-subscript" +import { Superscript } from "@tiptap/extension-superscript" +import type { Node as ProseMirrorNode } from "@tiptap/pm/model" +import { HorizontalRule } from "../vendor/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension" +import { ImageNode } from "./node/Image/ImageNode" +import { ImageWithCaptionNode } from "./node/Image/ImageWithCaptionNode" +import { DividerNode } from "./node/Divider/DividerNode" +import { LearningResourceNode } from "./node/LearningResource/LearningResourceNode" +import { LearningResourceInputNode } from "./node/LearningResource/LearningResourceInputNode" +import { LearningResourceURLHandler } from "./node/LearningResource/LearningResourcePaste" +import { MediaEmbedURLHandler } from "./node/MediaEmbed/MediaEmbedURLHandler" +import { MediaEmbedNode } from "./node/MediaEmbed/MediaEmbedNode" +import { MediaEmbedInputNode } from "./node/MediaEmbed/MediaEmbedInputNode" +import type { ExtendedNodeConfig } from "./node/types" +import { MAX_FILE_SIZE } from "../vendor/lib/tiptap-utils" +import type { UploadHandler } from "../core/WebsiteContentEditor" + +export const createBaseExtensions = ( + uploadHandler: UploadHandler, + setUploadError: (error: string | null) => void, +): (Extension | Node | Mark)[] => [ + StarterKit.configure({ + document: false, + horizontalRule: false, + heading: false, + link: { + openOnClick: false, + enableClickSelection: true, + }, + trailingNode: { + node: "paragraph", + }, + }), + Heading.configure({ + levels: [1, 2, 3, 4, 5, 6], + }), + Placeholder.configure({ + showOnlyCurrent: false, + includeChildren: true, + placeholder: ({ node, editor }): string => { + let parentNode: typeof node | null = null + + editor.state.doc.descendants((n: ProseMirrorNode) => { + n.forEach((childNode: ProseMirrorNode) => { + if (childNode === node) { + parentNode = n + } + }) + if (parentNode) { + return false + } + return undefined + }) + + if (parentNode) { + const parentExtension = editor.extensionManager.extensions.find( + (ext) => ext.name === parentNode!.type.name, + ) + + if ( + parentExtension && + "config" in parentExtension && + parentExtension.config && + typeof (parentExtension.config as ExtendedNodeConfig) + .getPlaceholders === "function" + ) { + const placeholder = ( + parentExtension.config as ExtendedNodeConfig + ).getPlaceholders(node) + if (placeholder) { + return placeholder + } + } + } + + if (node.type.name === "heading") { + return "Add a heading" + } + return "Add some text" + }, + }), + HorizontalRule, + LearningResourceURLHandler, + LearningResourceNode, + LearningResourceInputNode, + TextAlign.configure({ types: ["heading", "paragraph"] }), + TaskList, + TaskItem.configure({ nested: true }), + TiptapTypography, + Superscript, + Subscript, + Selection, + Image, + MediaEmbedNode, + MediaEmbedInputNode, + DividerNode, + ImageWithCaptionNode, + MediaEmbedURLHandler, + ImageNode.configure({ + accept: "image/*", + maxSize: MAX_FILE_SIZE, + limit: 3, + upload: uploadHandler, + onError: (error) => setUploadError(error.message), + }), +] diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx new file mode 100644 index 0000000000..d5282d2fc0 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx @@ -0,0 +1,192 @@ +import React from "react" +import { + ReactNodeViewRenderer, + Node, + mergeAttributes, + NodeViewWrapper, + NodeViewContent, + ReactNodeViewContentProvider, +} from "@tiptap/react" +import type { Node as ProseMirrorNode } from "@tiptap/pm/model" +import { Container, Breadcrumbs } from "ol-components" +import styled from "@emotion/styled" +import type { ExtendedNodeConfig } from "../types" +import { getTitle } from "../lib" + +const FullWidthContainer = styled.div({ + position: "relative", + left: "50%", + right: "50%", + marginLeft: "-50vw", + marginRight: "-50vw", + width: "100vw", +}) + +const InnerContainer = styled(Container)({ + "&&": { + maxWidth: "890px", + }, +}) + +const StyledNodeViewWrapper = styled(NodeViewWrapper)({ + "&&": { + position: "relative", + left: "50%", + right: "50%", + marginLeft: "-50vw", + marginRight: "-50vw", + width: "100vw", + }, +}) + +const BreadcrumContainer = styled(Container)(({ theme }) => ({ + maxWidth: "1080px !important", + padding: "0 !important", + [theme.breakpoints.down("lg")]: { + padding: "0 16px !important", + }, + [theme.breakpoints.down("md")]: { + padding: "0 16px !important", + }, + [theme.breakpoints.down("sm")]: { + padding: "0 16px !important", + }, +})) + +const BreadcrumbBar = styled.div(({ theme }) => ({ + position: "relative", + left: "50%", + right: "50%", + marginLeft: "-50vw", + marginRight: "-50vw", + width: "100vw", + padding: "18px 0 2px 0", + backgroundColor: theme.custom.colors.white, + borderBottom: `1px solid ${theme.custom.colors.red}`, + textDecoration: "none", + "&& .breadcrum span span a": { + textDecoration: "none !important", + }, + "&& .breadcrum span span a span": { + textDecoration: "none !important", + }, + [theme.breakpoints.down("sm")]: { + padding: "12px 0 0px 0", + }, +})) + +const ArticleBannerSection = styled.div(({ theme }) => ({ + padding: "64px 0", + backgroundColor: theme.custom.colors.white, + [theme.breakpoints.down("sm")]: { + padding: "32px 0", + }, +})) + +const StyledNodeViewContent = styled(NodeViewContent)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + "&&&&& h1": { + marginTop: 0, + marginBottom: "0px", + color: theme.custom.colors.darkGray2, + [theme.breakpoints.down("sm")]: { + ...theme.typography.h3, + }, + }, + "&&&&& p": { + position: "relative", + marginBottom: 0, + marginTop: "16px", + color: theme.custom.colors.darkGray2, + [theme.breakpoints.down("sm")]: { + ...theme.typography.body2, + marginTop: 0, + }, + }, + ".is-empty:not(.with-slash)[data-placeholder]:has(> .ProseMirror-trailingBreak:only-child)::before": + { + color: theme.custom.colors.silverGrayLight, + opacity: 0.4, + }, + '[contenteditable="true"] &': { + caretColor: theme.custom.colors.red, + }, +})) + +const ArticleBannerViewer = ({ + children, + node, +}: { + children?: React.ReactNode + node?: ProseMirrorNode +}) => { + return ( + + + + ) +} + +const ArticleBannerWrapper = (props?: { node?: ProseMirrorNode }) => { + return ( + + + + + + + + + + + + + + + ) +} + +const articleBannerNodeConfig: ExtendedNodeConfig = { + name: "banner", + + selectable: false, + + // Enforce that the node must contain exactly a title (heading) and subheading (paragraph) + content: "heading paragraph", + + isolating: false, + + parseHTML() { + return [{ tag: "banner" }] + }, + + renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { + return ["banner", mergeAttributes(HTMLAttributes), 0] + }, + + addNodeView() { + return ReactNodeViewRenderer(ArticleBannerWrapper) + }, + + getPlaceholders: (childNode: ProseMirrorNode) => { + if (childNode.type.name === "heading") { + return "Add a title" + } + if (childNode.type.name === "paragraph") { + return "Add a subheading" + } + return null + }, +} + +const ArticleBannerNode = Node.create(articleBannerNodeConfig) + +export { ArticleBannerNode, ArticleBannerViewer } diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBar.tsx similarity index 86% rename from frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx rename to frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBar.tsx index 92d32c516e..84c2b6f6a9 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBar.tsx @@ -6,7 +6,7 @@ import { Container } from "ol-components" import { RiShareFill } from "@remixicon/react" import { ActionButton, TextField } from "@mitodl/smoot-design" import type { JSONContent } from "@tiptap/core" -import { useArticle } from "../../../ArticleContext" +import { useWebsiteContent } from "../../../WebsiteContentContext" import { calculateReadTime } from "../../utils" import SharePopover from "@/components/SharePopover/SharePopover" @@ -56,6 +56,12 @@ const InfoText = styled.span(({ theme }) => ({ color: theme.custom.colors.silverGrayDark, })) +// Separator between read time and date. Default "-"; content-type owners may +// override the glyph by targeting the "byline-info-bar__separator" hook class. +const Separator = styled(InfoText)({ + "&::before": { content: '"-"' }, +}) + const AuthorInput = styled(TextField)(({ theme }) => ({ "& .MuiInputBase-root": { ...theme.typography.body2, @@ -85,7 +91,7 @@ const AuthorInput = styled(TextField)(({ theme }) => ({ width: "300px", })) -interface ArticleByLineInfoBarContentProps { +interface ByLineInfoBarContentProps { publishedDate: string | null content: JSONContent | null | undefined isEditable?: boolean @@ -93,17 +99,17 @@ interface ArticleByLineInfoBarContentProps { onAuthorNameChange?: (name: string) => void } -export const ArticleByLineInfoBarContent = ({ +export const ByLineInfoBarContent = ({ publishedDate, content, isEditable = false, authorName, onAuthorNameChange, -}: ArticleByLineInfoBarContentProps) => { +}: ByLineInfoBarContentProps) => { const [shareOpen, setShareOpen] = useState(false) const shareButtonRef = useRef(null) - const article = useArticle() + const article = useWebsiteContent() const readTime = calculateReadTime(content) @@ -111,13 +117,13 @@ export const ArticleByLineInfoBarContent = ({ const displayAuthorName = authorName || "" return ( - + setShareOpen(false)} - pageUrl={`${NEXT_PUBLIC_ORIGIN}/news/${article?.slug}`} + pageUrl={`${NEXT_PUBLIC_ORIGIN}/${article?.content_type === "article" ? "articles" : "news"}/${article?.slug}`} /> {(displayAuthorName || isEditable) && ( @@ -135,7 +141,9 @@ export const ArticleByLineInfoBarContent = ({ By {displayAuthorName} )} {readTime ? {readTime} min read : null} - {readTime && publishedDate ? - : null} + {readTime && publishedDate ? ( + + ) : null} {publishedDate ? new Date(publishedDate).toLocaleDateString("en-US", { @@ -165,12 +173,12 @@ export const ArticleByLineInfoBarContent = ({ ) } -const ArticleByLineInfoBar = ({ +const ByLineInfoBar = ({ editor, node, updateAttributes, }: ReactNodeViewProps) => { - const article = useArticle() + const article = useWebsiteContent() const publishedDate = article?.is_published ? article?.created_on : null @@ -200,7 +208,7 @@ const ArticleByLineInfoBar = ({ return ( - { - const article = useArticle() +const ByLineInfoBarViewer = () => { + const article = useWebsiteContent() const publishedDate = article?.is_published ? article?.created_on : null const content = article?.content const authorName = article?.author_name ?? null return ( - { ) } -export { ArticleByLineInfoBarViewer } +export { ByLineInfoBarViewer } diff --git a/frontends/main/src/page-components/TiptapEditor/index.ts b/frontends/main/src/page-components/TiptapEditor/index.ts index 60304b2ea2..36d8e0a99d 100644 --- a/frontends/main/src/page-components/TiptapEditor/index.ts +++ b/frontends/main/src/page-components/TiptapEditor/index.ts @@ -1 +1,6 @@ -export { ArticleEditor } from "./ArticleEditor" +export { NewsEditor } from "./contentTypes/news/NewsEditor" +export { WebsiteContentEditor } from "./core/WebsiteContentEditor" +export type { + WebsiteContentEditorProps, + CreateExtensionsFn, +} from "./core/WebsiteContentEditor" diff --git a/frontends/main/src/page-components/TiptapEditor/useArticleSchema.test.tsx b/frontends/main/src/page-components/TiptapEditor/useArticleSchema.test.tsx deleted file mode 100644 index 5837cab756..0000000000 --- a/frontends/main/src/page-components/TiptapEditor/useArticleSchema.test.tsx +++ /dev/null @@ -1,172 +0,0 @@ -/** - * @jest-environment @happy-dom/jest-environment - */ -import React from "react" -import { render, screen, waitFor } from "@testing-library/react" -import { useArticleSchema } from "./useArticleSchema" -import type { JSONContent } from "@tiptap/react" - -// Mock console methods to avoid noise in test output -const originalError = console.error -let consoleErrorSpy: ReturnType - -beforeEach(() => { - // Suppress expected validation error messages in console.error - consoleErrorSpy = jest - .spyOn(console, "error") - .mockImplementation((message) => { - // Suppress expected validation errors - if ( - typeof message === "string" && - message.includes("Document schema check failed") - ) { - return - } - originalError(message) - }) -}) - -afterEach(() => { - consoleErrorSpy.mockRestore() -}) - -const TestComponent = ({ - content, - enabled, -}: { - content: JSONContent - enabled: boolean -}) => { - const mockUploadHandler = jest - .fn() - .mockResolvedValue("http://example.com/image.jpg") - const mockSetUploadError = jest.fn() - - const { schemaError } = useArticleSchema({ - uploadHandler: mockUploadHandler, - setUploadError: mockSetUploadError, - enabled, - content, - }) - - return ( -
    - {schemaError &&
    {schemaError}
    } - {!schemaError &&
    No error
    } -
    - ) -} - -describe("useArticleSchema", () => { - describe("schema validation", () => { - test("show schema error when document is not valid ProseMirror content", async () => { - const content: JSONContent = { - some: "random", - } - render() - - await screen.findByText( - "Document schema check failed: Invalid content for node doc: content specification not satisfied", - ) - }) - - test("show schema error when document is not valid ProseMirror JSON", async () => { - const content: JSONContent = { - type: "doc", - content: [ - { - type: "invalid", - }, - ], - } - render() - - await screen.findByText( - 'Document schema check failed: Invalid content for node doc: node type "invalid" not found in schema', - ) - }) - - test("shows schema error when document does not confirm to content expression (missing banner and byline)", async () => { - // Content missing required banner and byline - const content: JSONContent = { - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "Content paragraph", - }, - ], - }, - ], - } - - render() - - await screen.findByText( - "Document schema check failed: Invalid content for node doc: paragraph is not allowed in this position", - ) - }) - - test("shows no error when content is valid", async () => { - const content: JSONContent = { - type: "doc", - content: [ - { - type: "banner", - content: [ - { - type: "heading", - attrs: { level: 1 }, - content: [{ type: "text", text: "Title" }], - }, - { - type: "paragraph", - content: [], - }, - ], - }, - { - type: "byline", - }, - { - type: "paragraph", - content: [{ type: "text", text: "Content paragraph" }], - }, - ], - } - - render() - - await waitFor(() => { - expect(screen.getByTestId("no-error")).toBeInTheDocument() - }) - }) - - test("shows no error when enabled is false", async () => { - // Invalid content, but validation should be skipped - const content: JSONContent = { - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "Content paragraph", - }, - ], - }, - ], - } - - render() - - await waitFor(() => { - expect(screen.getByTestId("no-error")).toBeInTheDocument() - }) - }) - }) -}) diff --git a/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts b/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts deleted file mode 100644 index 5e192b7265..0000000000 --- a/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts +++ /dev/null @@ -1,185 +0,0 @@ -"use client" - -import { useMemo } from "react" -import type { Node as ProseMirrorNode } from "@tiptap/pm/model" -import { getSchema } from "@tiptap/core" -import { useSchema } from "./useSchema" -import Document from "@tiptap/extension-document" -import { Placeholder, Selection } from "@tiptap/extensions" -import { StarterKit } from "@tiptap/starter-kit" -import { TaskItem, TaskList } from "@tiptap/extension-list" -import { Heading } from "@tiptap/extension-heading" -import { Image } from "@tiptap/extension-image" -import { TextAlign } from "@tiptap/extension-text-align" -import { Typography as TiptapTypography } from "@tiptap/extension-typography" -import { Subscript } from "@tiptap/extension-subscript" -import { Superscript } from "@tiptap/extension-superscript" -import type { JSONContent } from "@tiptap/react" -import { HorizontalRule } from "./vendor/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension" -import { ImageNode } from "./extensions/node/Image/ImageNode" -import { ImageWithCaptionNode } from "./extensions/node/Image/ImageWithCaptionNode" -import { DividerNode } from "./extensions/node/Divider/DividerNode" -import { ArticleByLineInfoBarNode } from "./extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarNode" -import { LearningResourceNode } from "./extensions/node/LearningResource/LearningResourceNode" -import { LearningResourceInputNode } from "./extensions/node/LearningResource/LearningResourceInputNode" -import { LearningResourceURLHandler } from "./extensions/node/LearningResource/LearningResourcePaste" -import { MediaEmbedURLHandler } from "./extensions/node/MediaEmbed/MediaEmbedURLHandler" -import { MediaEmbedNode } from "./extensions/node/MediaEmbed/MediaEmbedNode" -import { MediaEmbedInputNode } from "./extensions/node/MediaEmbed/MediaEmbedInputNode" -import { BannerNode } from "./extensions/node/Banner/BannerNode" -import type { ExtendedNodeConfig } from "./extensions/node/types" -import { MAX_FILE_SIZE } from "./vendor/lib/tiptap-utils" - -const ArticleDocument = Document.extend({ - content: "banner byline block+", -}) - -interface UseArticleSchemaOptions { - uploadHandler: ( - file: File, - onProgress?: (e: { progress: number }) => void, - abortSignal?: AbortSignal, - ) => Promise - setUploadError: (error: string | null) => void - enabled: boolean - content: JSONContent -} - -export const newArticleDocument = { - type: "doc", - content: [ - { - type: "banner", - content: [ - { - type: "heading", - attrs: { level: 1 }, - content: [], - }, - { - type: "paragraph", - content: [], - }, - ], - }, - { - type: "byline", - attrs: { authorName: null }, - }, - { type: "paragraph", content: [] }, - ], -} - -export const useArticleSchema = ({ - uploadHandler, - setUploadError, - enabled, - content, -}: UseArticleSchemaOptions) => { - const extensions = useMemo( - () => [ - ArticleDocument, - StarterKit.configure({ - document: false, // Disable default document to use our ArticleDocument - horizontalRule: false, - heading: false, - link: { - openOnClick: false, - enableClickSelection: true, - }, - trailingNode: { - node: "paragraph", - }, - }), - Heading.configure({ - levels: [1, 2, 3, 4, 5, 6], - }), - Placeholder.configure({ - showOnlyCurrent: false, - includeChildren: true, - placeholder: ({ node, editor }): string => { - let parentNode: typeof node | null = null - - editor.state.doc.descendants((n: ProseMirrorNode) => { - n.forEach((childNode: ProseMirrorNode) => { - if (childNode === node) { - parentNode = n - } - }) - if (parentNode) { - return false - } - return undefined - }) - - if (parentNode) { - const parentExtension = editor.extensionManager.extensions.find( - (ext) => ext.name === parentNode!.type.name, - ) - - if ( - parentExtension && - "config" in parentExtension && - parentExtension.config && - typeof (parentExtension.config as ExtendedNodeConfig) - .getPlaceholders === "function" - ) { - const placeholder = ( - parentExtension.config as ExtendedNodeConfig - ).getPlaceholders(node) - if (placeholder) { - return placeholder - } - } - } - - if (node.type.name === "heading") { - return "Add a heading" - } - return "Add some text" - }, - }), - HorizontalRule, - LearningResourceURLHandler, - LearningResourceNode, - LearningResourceInputNode, - TextAlign.configure({ types: ["heading", "paragraph"] }), - TaskList, - TaskItem.configure({ nested: true }), - TiptapTypography, - Superscript, - Subscript, - Selection, - Image, - MediaEmbedNode, - MediaEmbedInputNode, - DividerNode, - ArticleByLineInfoBarNode, - ImageWithCaptionNode, - MediaEmbedURLHandler, - ImageNode.configure({ - accept: "image/*", - maxSize: MAX_FILE_SIZE, - limit: 3, - upload: uploadHandler, - onError: (error) => setUploadError(error.message), - }), - BannerNode, - ], - [uploadHandler, setUploadError], - ) - - const schema = useMemo(() => getSchema(extensions), [extensions]) - - const schemaError = useSchema({ - schema, - content, - enabled, - }) - - return { - extensions, - schema, - schemaError, - } -} diff --git a/learning_resources/management/commands/backpopulate_mit_edx_data.py b/learning_resources/management/commands/backpopulate_mit_edx_data.py index e04bd756de..2250558a25 100644 --- a/learning_resources/management/commands/backpopulate_mit_edx_data.py +++ b/learning_resources/management/commands/backpopulate_mit_edx_data.py @@ -51,7 +51,9 @@ def handle(self, *args, **options): # noqa: ARG002 resource_delete_actions(learning_resource) else: task = get_mit_edx_data.delay( - options["api_course_datafile"], options["api_program_datafile"] + options["api_course_datafile"], + options["api_program_datafile"], + _cooldown_force=True, ) self.stdout.write(f"Started task {task} to get MIT edX course data") self.stdout.write("Waiting on task...") diff --git a/learning_resources/management/commands/backpopulate_mitxonline_data.py b/learning_resources/management/commands/backpopulate_mitxonline_data.py index b95144e87b..ed4750dec6 100644 --- a/learning_resources/management/commands/backpopulate_mitxonline_data.py +++ b/learning_resources/management/commands/backpopulate_mitxonline_data.py @@ -38,7 +38,7 @@ def handle(self, *args, **options): # noqa: ARG002 ): resource_delete_actions(learning_resource) else: - task = get_mitxonline_data.delay() + task = get_mitxonline_data.delay(_cooldown_force=True) self.stdout.write(f"Started task {task} to get MITx Online course data") self.stdout.write("Waiting on task...") start = now_in_utc() diff --git a/learning_resources/management/commands/backpopulate_oll_data.py b/learning_resources/management/commands/backpopulate_oll_data.py index 0c0cdba7eb..de2a7ae081 100644 --- a/learning_resources/management/commands/backpopulate_oll_data.py +++ b/learning_resources/management/commands/backpopulate_oll_data.py @@ -45,7 +45,10 @@ def handle(self, *args, **options): # noqa: ARG002 ): resource_delete_actions(learning_resource) else: - task = get_oll_data.delay(sheets_id=options["sheets_id"]) + task = get_oll_data.delay( + sheets_id=options["sheets_id"], + _cooldown_force=True, + ) self.stdout.write(f"Started task {task} to get oll course data") self.stdout.write("Waiting on task...") start = now_in_utc() diff --git a/learning_resources/management/commands/backpopulate_xpro_data.py b/learning_resources/management/commands/backpopulate_xpro_data.py index 9ac7846bd4..849bf0eb09 100644 --- a/learning_resources/management/commands/backpopulate_xpro_data.py +++ b/learning_resources/management/commands/backpopulate_xpro_data.py @@ -39,7 +39,7 @@ def handle(self, *args, **options): # noqa: ARG002 ): resource_delete_actions(learning_resource) else: - task = get_xpro_data.delay() + task = get_xpro_data.delay(_cooldown_force=True) self.stdout.write(f"Started task {task} to get xpro course data") self.stdout.write("Waiting on task...") start = now_in_utc() diff --git a/learning_resources/tasks.py b/learning_resources/tasks.py index cdd9779f44..8d42d0088b 100644 --- a/learning_resources/tasks.py +++ b/learning_resources/tasks.py @@ -57,6 +57,7 @@ from learning_resources_search.exceptions import RetryError from main.celery import app from main.constants import ISOFORMAT +from main.decorators import cooldown_task from main.utils import chunks, clear_views_cache, now_in_utc log = logging.getLogger(__name__) @@ -117,10 +118,16 @@ def get_micromasters_data(): @app.task +@cooldown_task( + wait_time=3600, + key_func=lambda *, api_course_datafile=None, api_program_datafile=None: ( + f"course={api_course_datafile}:program={api_program_datafile}" + ), +) def get_mit_edx_data( api_course_datafile: str | None = None, api_program_datafile: str | None = None, -) -> int: +) -> int | None: """Task to sync MIT edX data with the database Args: @@ -130,7 +137,8 @@ def get_mit_edx_data( Otherwise, the API is queried directly. Returns: - int: The number of results that were fetched + int | None: The number of results fetched, or None if the call was + skipped due to rate limiting. """ courses = pipelines.mit_edx_courses_etl(api_course_datafile) programs = pipelines.mit_edx_programs_etl(api_program_datafile) @@ -139,7 +147,8 @@ def get_mit_edx_data( @app.task -def get_mitxonline_data() -> int: +@cooldown_task(wait_time=900) +def get_mitxonline_data() -> int | None: """Execute the MITX Online ETL pipeline""" courses = pipelines.mitxonline_courses_etl() programs = pipelines.mitxonline_programs_etl() @@ -148,7 +157,11 @@ def get_mitxonline_data() -> int: @app.task -def get_oll_data(sheets_id=None): +@cooldown_task( + wait_time=900, + key_func=lambda *, sheets_id=None: f"sheets_id={sheets_id}", +) +def get_oll_data(sheets_id=None) -> int | None: """Execute the OLL ETL pipeline. Args: @@ -176,7 +189,8 @@ def get_sloan_data(): @app.task -def get_xpro_data(): +@cooldown_task(wait_time=900) +def get_xpro_data() -> int | None: """Execute the xPro ETL pipeline""" courses = pipelines.xpro_courses_etl() programs = pipelines.xpro_programs_etl() diff --git a/load_testing/README.md b/load_testing/README.md index 48f993b2d1..eca8f0c80a 100644 --- a/load_testing/README.md +++ b/load_testing/README.md @@ -3,7 +3,7 @@ ### Usage (Docker) ```shell -./scripts/k6.sh -e BACKEND_BASE_URL=#### -e FRONTEND_BASE_URL=#### +./scripts/k6.sh run /app/learn.smoke.ts -e BACKEND_BASE_URL=#### -e FRONTEND_BASE_URL=#### ``` ### Usage (local k6) @@ -11,5 +11,25 @@ - Install [k6](https://grafana.com/docs/k6/latest/set-up/install-k6/) ```shell -k6 run learn.ts -e BACKEND_BASE_URL=#### -e FRONTEND_BASE_URL=#### +k6 run learn.smoke.ts -e BACKEND_BASE_URL=#### -e FRONTEND_BASE_URL=#### ``` + +### Available tests + +**Note: the numbers for average-load/stress should be updated over time** + +- `learn.smoke.ts` - for lightweight smoke testing of deployments (4 users) +- `learn.average-load.ts` - simulates an average amount of user load (100 users) +- `learn.stress.ts` - simulates a stressful amount of user load (200 users) + +### Environment Variables + +**Note: for base urls, if you access the services via a port, include the port number** + +| Name | Decription | Example value | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| `BACKEND_BASE_URL` | The base url to the backend API service. | `https://api.learn.odl.local` | +| `FRONTEND_BASE_URL` | The base url to the frontend service. | `https://learn.odl.local` | +| `SSO_BASE_URL` | The base url to the keycloak service. | `https://keycloak.odl.local` | +| `IGNORE_HTTPS_ERRORS` | Ignore https certificate errors. Only recommemded for local test certificates. | `true` | +| `USERS_JSON_FILE` | Data file for users auth info. Expected to be in the format `[{"email": "", "password": ""}, ...]`. Put this is the `data/` subdirectory as files in there are gitignored. | `data/users.json` | diff --git a/load_testing/auth.ts b/load_testing/auth.ts index 46b66ffc54..98bb24c787 100644 --- a/load_testing/auth.ts +++ b/load_testing/auth.ts @@ -1,13 +1,45 @@ import { SharedArray } from "k6/data" -export function getAccessToken(): String { +export type AuthCredential = { + email: string + password: string +} + +export function getAccessToken(): string | null { return __ENV.AUTH_ACCESS_TOKEN } -export const users = new SharedArray("users", function () { - if (!__ENV.USERS_JSON_FILE) { - return [] +export function hasAccessToken(): boolean { + return !!getAccessToken() +} + +function _validate_credentials(credentials) { + if (!Array.isArray(credentials)) { + throw Error("Expected an array of credentials") + } + + for (let index = 0; index < credentials.length; index++) { + const credential = credentials[index] + if (!Object.hasOwn(credential, "email")) { + throw Error(`User entry is missing 'email' at index ${index}`) + } + if (!Object.hasOwn(credential, "password")) { + throw Error(`User entry is missing 'password' at index ${index}`) + } } +} + +export const credentials: AuthCredential[] = new SharedArray( + "credentials", + function () { + if (!__ENV.USERS_JSON_FILE) { + return [] + } + + const parsed = JSON.parse(open(__ENV.USERS_JSON_FILE)) + + _validate_credentials(parsed) - return JSON.parse(open(__ENV.USERS_JSON_FILE)) -}) + return parsed + }, +) diff --git a/load_testing/backend/test.ts b/load_testing/backend/test.ts index 105ce4c166..73a2f973c0 100644 --- a/load_testing/backend/test.ts +++ b/load_testing/backend/test.ts @@ -8,6 +8,7 @@ import { import { createV0Client, createV1Client } from "./client/client.ts" import { NewsEventsListParams } from "./client/v0/api.schemas.ts" import { FeaturedListParams } from "./client/v1/api.schemas.ts" +import { hasAccessToken } from "../auth.ts" export function testBackend() { group("api", function () { @@ -15,23 +16,31 @@ export function testBackend() { exec.vu.metrics.tags.apiVersion = "v0" const client = createV0Client() - let res = client.videoShortsList({ limit: 50 }) + group("video shorts", () => { + const res = client.videoShortsList({ limit: 50 }) - check(res, { - "is status 200": (r) => r.response.status === 200, - "has results": (r) => r.response.json("results").length > 0, + check(res, { + "is status 200": (r) => r.response.status === 200, + "has results": (r) => r.response.json("results").length > 0, + }) }) - res = client.usersMeRetrieve() - check(res, { - "is status 200": (r) => r.response.status === 200, + group("users/me", () => { + const res = client.usersMeRetrieve() + check(res, { + "is status 200": (r) => r.response.status === 200, + "expected auth state": (r) => + r.response.json("is_authenticated") === hasAccessToken(), + }) }) - res = client.testimonialsList({ position: 1 }) + group("testimonials", () => { + const res = client.testimonialsList({ position: 1 }) - check(res, { - "is status 200": (r) => r.response.status === 200, - "has results": (r) => r.response.json("results").length > 0, + check(res, { + "is status 200": (r) => r.response.status === 200, + "has results": (r) => r.response.json("results").length > 0, + }) }) group("news", function () { diff --git a/load_testing/config.ts b/load_testing/config.ts index d000f4bcf5..5ac5497220 100644 --- a/load_testing/config.ts +++ b/load_testing/config.ts @@ -1,10 +1,20 @@ import { NewBrowserContextOptions } from "k6/browser" -export const BACKEND_BASE_URL: string = __ENV.BACKEND_BASE_URL -export const FRONTEND_BASE_URL: string = __ENV.FRONTEND_BASE_URL +export const BACKEND_BASE_URL: string = __ENV.BACKEND_BASE_URL?.replace( + /\/$/, + "", +) +export const FRONTEND_BASE_URL: string = __ENV.FRONTEND_BASE_URL?.replace( + /\/$/, + "", +) +export const SSO_BASE_URL: string = ( + __ENV.SSO_BASE_URL ?? "http://localhost" +).replace(/\/$/, "") + +export const IGNORE_HTTPS_ERRORS: boolean = + (__ENV.IGNORE_HTTPS_ERRORS || "false").toLowerCase() == "true" export const BROWSER_CONTEXT_OPTIONS: NewBrowserContextOptions = { - ignoreHTTPSErrors: Object.hasOwn(__ENV, "BROWSER_IGNORE_HTTPS_ERRORS") - ? __ENV.BROWSER_IGNORE_HTTPS_ERRORS - : false, + ignoreHTTPSErrors: IGNORE_HTTPS_ERRORS, } diff --git a/load_testing/data/.keep b/load_testing/data/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/load_testing/frontend/test.ts b/load_testing/frontend/test.ts index 9d6aa2e5bd..52b4d03dbf 100644 --- a/load_testing/frontend/test.ts +++ b/load_testing/frontend/test.ts @@ -1,24 +1,48 @@ import { check } from "k6" import { browser, Page } from "k6/browser" -import { randomIntBetween } from "https://jslib.k6.io/k6-utils/1.2.0/index.js" -import { BROWSER_CONTEXT_OPTIONS, FRONTEND_BASE_URL } from "../config.ts" +import { + randomIntBetween, + randomItem, +} from "https://jslib.k6.io/k6-utils/1.2.0/index.js" +import { + BROWSER_CONTEXT_OPTIONS, + FRONTEND_BASE_URL, + SSO_BASE_URL, +} from "../config.ts" +import { AuthCredential, credentials } from "../auth.ts" +import { escapeRegex } from "../utils.ts" -async function home(page: Page) { +type Context = { + loggedIn: boolean + credential: AuthCredential | null +} + +async function home(page: Page, context: Context) { await page.goto(FRONTEND_BASE_URL) const carousel = await page.getByTestId("resource-carousel") - const articlesCount = await carousel.locator("article").count() + const articles = carousel.locator("article") + + try { + await articles.first().waitFor({ timeout: 10_000 }) + } catch { + // carousel never populated; let the assertion below report it + } + + const articlesCount = await articles.count() + await check(carousel, { - "has 12 items": () => articlesCount === 12, + "home page carousel has 12 items": () => articlesCount === 12, }) - await carousel - .locator("article") - .nth(randomIntBetween(0, articlesCount - 1)) - .click() + if (articlesCount === 0) { + return + } + + await articles.nth(randomIntBetween(0, articlesCount - 1)).click() } -async function search(page: Page) { +async function search(page: Page, context: Context) { await page.goto(`${FRONTEND_BASE_URL}/search?sortby=-views`) await page.goto(`${FRONTEND_BASE_URL}/search?resource_type_group=program`) await page.goto( @@ -26,27 +50,131 @@ async function search(page: Page) { ) } -async function topics(page: Page) { +async function topics(page: Page, context: Context) { await page.goto(`${FRONTEND_BASE_URL}/topics`) } -async function departments(page: Page) { +async function departments(page: Page, context: Context) { await page.goto(`${FRONTEND_BASE_URL}/departments`) } -async function units(page: Page) { +async function units(page: Page, context: Context) { await page.goto(`${FRONTEND_BASE_URL}/units`) } +async function login(page: Page, context: Context) { + const credential: AuthCredential | undefined = randomItem(credentials) + + if (credential == null) { + console.log("Login > skipping because no credentials provided") + return + } + + if (!SSO_BASE_URL) { + throw Error("SSO_BASE_URL must be set to run the login flow") + } + + if (page.url() === "about:blank") { + await page.goto(FRONTEND_BASE_URL) + } + + console.log(`Login > using '${credential.email}'`) + + const loginButton = page.getByTestId("login-button-desktop") + await loginButton.first().click() + + let loggedIn = false + + try { + await loginKeycloak(page, credential, context) + await page.locator('[aria-label="User Menu"]').waitFor({ timeout: 10_000 }) + loggedIn = true + console.log("Login > authenticated; user menu visible") + } catch (err) { + console.log( + `Login > failed for '${credential.email}' at URL '${page.url()}': ${err}`, + ) + } + + check(null, { + "login succeeded": () => loggedIn, + }) +} + +const ESCAPED_SSO_URL = escapeRegex(SSO_BASE_URL) + +const KEYCLOAK_USERNAME_URL_RE = new RegExp( + `${ESCAPED_SSO_URL}\/realms\/[a-z-]+\/protocol\/openid-connect\/auth.*`, +) +const KEYCLOAK_PASSWORD_URL_RE = new RegExp( + `${ESCAPED_SSO_URL}\/realms\/[a-z-]+\/login-actions\/authenticate.*`, +) + +async function loginKeycloak( + page: Page, + credential: AuthCredential, + context: Context, +) { + await page.waitForURL(KEYCLOAK_USERNAME_URL_RE, { + waitUntil: "load", + }) + + console.log("Login > Keycloak > on login email page") + + const credentialnameInput = await page.locator("input[name=username]") + console.log("Login > Keycloak > found username input") + await credentialnameInput.focus() + await credentialnameInput.fill(credential.email) + console.log("Login > Keycloak > entered email address") + + await page.locator("button[type=submit]").click() + console.log("Login > Keycloak > submitting email address") + + await page.waitForURL(KEYCLOAK_PASSWORD_URL_RE) + console.log("Login > Keycloak > on login password page") + + const passwordInput = await page.locator("input[name=password]") + console.log("Login > Keycloak > found password input") + await passwordInput.focus() + await passwordInput.fill(credential.password, { + force: true, + }) + await page.locator("button[type=submit]").click() + console.log("Login > Keycloak > submitting password") + await page.waitForNavigation() + + context.loggedIn = true + context.credential = credential +} + +async function dashboard(page: Page, context: Context) { + if (!context.loggedIn) { + return + } + await page.goto(`${FRONTEND_BASE_URL}/dashboard`) +} + export async function testFrontend() { const page = await browser.newPage(BROWSER_CONTEXT_OPTIONS) + const context: Context = { + loggedIn: false, + credential: null, + } try { - await home(page) - await search(page) - await topics(page) - await departments(page) - await units(page) + // always home page first + await home(page, context) + await search(page, context) + await topics(page, context) + await departments(page, context) + await units(page, context) + await login(page, context) + await dashboard(page, context) + await search(page, context) + await topics(page, context) + await departments(page, context) + await units(page, context) + await home(page, context) } finally { await page.close() } diff --git a/load_testing/learn.average-load.ts b/load_testing/learn.average-load.ts new file mode 100644 index 0000000000..e903c60a70 --- /dev/null +++ b/load_testing/learn.average-load.ts @@ -0,0 +1,46 @@ +import { IGNORE_HTTPS_ERRORS } from "./config.ts" + +export { testBackend } from "./backend/test.ts" +export { testFrontend } from "./frontend/test.ts" + +const MAX_VUS = 100 +const BROWSER_VU_SHARE = 0.5 +const BACKEND_VU_SHARE = 1 - BROWSER_VU_SHARE + +const BROWSER_VUS = Math.floor(MAX_VUS * BROWSER_VU_SHARE) +const BACKEND_VUS = Math.floor(MAX_VUS * BACKEND_VU_SHARE) + +export const options = { + scenarios: { + browser: { + exec: "testFrontend", + executor: "ramping-vus", + options: { + browser: { + type: "chromium", + }, + }, + stages: [ + { duration: "5m", target: BROWSER_VUS }, + { duration: "30m", target: BROWSER_VUS }, + { duration: "5m", target: 0 }, + ], + }, + backend: { + exec: "testBackend", + executor: "ramping-vus", + stages: [ + { duration: "5m", target: BACKEND_VUS }, + { duration: "30m", target: BACKEND_VUS }, + { duration: "5m", target: 0 }, + ], + }, + }, + thresholds: { + // the rate of successful checks should be higher than 90% + checks: ["rate>0.9"], + http_req_failed: ["rate<0.01"], + browser_http_req_failed: ["rate<0.05"], + }, + insecureSkipTLSVerify: IGNORE_HTTPS_ERRORS, +} diff --git a/load_testing/learn.ts b/load_testing/learn.smoke.ts similarity index 54% rename from load_testing/learn.ts rename to load_testing/learn.smoke.ts index bc8f78a81e..af3971da9b 100644 --- a/load_testing/learn.ts +++ b/load_testing/learn.smoke.ts @@ -1,13 +1,19 @@ +import { IGNORE_HTTPS_ERRORS } from "./config.ts" + export { testBackend } from "./backend/test.ts" export { testFrontend } from "./frontend/test.ts" +const MAX_VUS = 4 +const BROWSER_VU_SHARE = 0.5 +const BACKEND_VU_SHARE = 1 - BROWSER_VU_SHARE + export const options = { scenarios: { browser: { exec: "testFrontend", executor: "constant-vus", - vus: 10, - duration: "30s", + vus: Math.floor(MAX_VUS * BROWSER_VU_SHARE), + duration: "1m", options: { browser: { type: "chromium", @@ -17,12 +23,15 @@ export const options = { backend: { exec: "testBackend", executor: "constant-vus", - vus: 10, - duration: "30s", + vus: Math.floor(MAX_VUS * BACKEND_VU_SHARE), + duration: "1m", }, }, thresholds: { // the rate of successful checks should be higher than 90% checks: ["rate>0.9"], + http_req_failed: ["rate<0.01"], + browser_http_req_failed: ["rate<0.05"], }, + insecureSkipTLSVerify: IGNORE_HTTPS_ERRORS, } diff --git a/load_testing/learn.stress.ts b/load_testing/learn.stress.ts new file mode 100644 index 0000000000..32a57ffb0c --- /dev/null +++ b/load_testing/learn.stress.ts @@ -0,0 +1,46 @@ +import { IGNORE_HTTPS_ERRORS } from "./config.ts" + +export { testBackend } from "./backend/test.ts" +export { testFrontend } from "./frontend/test.ts" + +const MAX_VUS = 200 +const BROWSER_VU_SHARE = 0.5 +const BACKEND_VU_SHARE = 1 - BROWSER_VU_SHARE + +const BROWSER_VUS = Math.floor(MAX_VUS * BROWSER_VU_SHARE) +const BACKEND_VUS = Math.floor(MAX_VUS * BACKEND_VU_SHARE) + +export const options = { + scenarios: { + browser: { + exec: "testFrontend", + executor: "ramping-vus", + options: { + browser: { + type: "chromium", + }, + }, + stages: [ + { duration: "5m", target: BROWSER_VUS }, + { duration: "30m", target: BROWSER_VUS }, + { duration: "5m", target: 0 }, + ], + }, + backend: { + exec: "testBackend", + executor: "ramping-vus", + stages: [ + { duration: "5m", target: BACKEND_VUS }, + { duration: "30m", target: BACKEND_VUS }, + { duration: "5m", target: 0 }, + ], + }, + }, + thresholds: { + // the rate of successful checks should be higher than 90% + checks: ["rate>0.9"], + http_req_failed: ["rate<0.01"], + browser_http_req_failed: ["rate<0.05"], + }, + insecureSkipTLSVerify: IGNORE_HTTPS_ERRORS, +} diff --git a/load_testing/utils.ts b/load_testing/utils.ts new file mode 100644 index 0000000000..199065a5db --- /dev/null +++ b/load_testing/utils.ts @@ -0,0 +1,3 @@ +export function escapeRegex(str: string): string { + return str.replace(/[\.\+\*\$\?\^\(\)\{\}\\\[\]\|]/g, "\\$&") +} diff --git a/main/constants.py b/main/constants.py index 78cae26b4e..469d5024d3 100644 --- a/main/constants.py +++ b/main/constants.py @@ -40,6 +40,7 @@ "ul", } ALLOWED_HTML_ATTRIBUTES = {} +TASK_REJECTED = "task-rejected" class PostHogEvents(Enum): diff --git a/main/decorators.py b/main/decorators.py new file mode 100644 index 0000000000..652cc47eba --- /dev/null +++ b/main/decorators.py @@ -0,0 +1,102 @@ +"""main decorators""" + +import inspect +import logging +from collections.abc import Callable +from functools import wraps + +from celery import current_task, states +from celery.exceptions import Reject +from django.core.cache import caches + +from main.constants import TASK_REJECTED + +log = logging.getLogger(__name__) + +KEY_PREFIX = "cooldown" + + +def cooldown_task( + wait_time: int, + key: str | None = None, + key_func: Callable[..., str] | None = None, +): + """ + Drop calls made within `wait_time` seconds of the previous invocation. + + The lock is acquired before the wrapped function runs and is not released + on exception — failures count against the cooldown to prevent retry + storms against upstream APIs. Uses an atomic ``cache.add`` so it is safe + across Celery workers. + + Place this *below* ``@app.task`` so the cooldown runs on the worker, not + on the enqueuing process. + + When a call is skipped from inside a Celery worker, the task's state is + explicitly set to ``REJECTED`` in the result backend and + ``Reject(requeue=False)`` is raised (should be ignored by sentry). + + To bypass the cooldown for a specific invocation (e.g., operator-forced + recovery), pass ``_cooldown_force=True`` as a kwarg through ``delay()`` + or ``apply_async``. The wrapper consumes it before calling the wrapped + function and refreshes the lock so subsequent calls are still gated. + This is race-free relative to clearing the lock from outside, which has + a window between clear and enqueue where another worker can reacquire. + + The wrapper also exposes ``clear_cooldown(*args, **kwargs)`` which + deletes the lock key. Useful for operational debugging from a shell; + prefer ``_cooldown_force=True`` from the enqueuing path. + + Args: + wait_time: Lock duration in seconds. + key: Optional static cache key. Defaults to the wrapped function's + fully-qualified name. + key_func: Optional callable receiving the wrapped function's bound + arguments as keyword args; returns a string suffix appended to + the base key. Opt-in; use to scope the cooldown per + argument-set. + """ + + def decorator(func): + base_key = f"{KEY_PREFIX}:{key or f'{func.__module__}.{func.__qualname__}'}" + sig = inspect.signature(func) if key_func else None + + def _key_for(*args, **kwargs): + if key_func is None: + return base_key + bound = sig.bind(*args, **kwargs) + bound.apply_defaults() + return f"{base_key}:{key_func(**bound.arguments)}" + + @wraps(func) + def wrapper(*args, **kwargs): + force = kwargs.pop("_cooldown_force", False) + lock_key = _key_for(*args, **kwargs) + if force: + log.info("Force-overriding cooldown for %s", lock_key) + caches["redis"].set(lock_key, "1", timeout=wait_time) + elif not caches["redis"].add(lock_key, "1", timeout=wait_time): + log.info("Skipping %s: cooldown active (%ss)", lock_key, wait_time) + if current_task and not current_task.request.called_directly: + current_task.update_state( + state=states.REJECTED, + meta={"reason": "cooldown", "key": lock_key}, + ) + try: + current_task.send_event(TASK_REJECTED, requeue=False) + except Exception: # noqa: BLE001 + log.warning( + "Failed to emit task-rejected event for %s", lock_key + ) + reason = "cooldown active" + raise Reject(reason, requeue=False) + return None + return func(*args, **kwargs) + + def clear_cooldown(*args, **kwargs): + caches["redis"].delete(_key_for(*args, **kwargs)) + + wrapper.clear_cooldown = clear_cooldown + return wrapper + + return decorator diff --git a/main/decorators_test.py b/main/decorators_test.py new file mode 100644 index 0000000000..a047b6fd01 --- /dev/null +++ b/main/decorators_test.py @@ -0,0 +1,186 @@ +"""Tests for main.decorators""" + +import pytest +from celery.exceptions import Reject + +from main.constants import TASK_REJECTED +from main.decorators import cooldown_task + + +@pytest.fixture +def mock_redis(mocker): + """Patch the redis cache used by cooldown_task and return its mock.""" + mock = mocker.Mock() + mocker.patch("main.decorators.caches", {"redis": mock}) + return mock + + +def test_cooldown_task_runs_first_call_and_drops_subsequent(mock_redis, mocker): + """First call runs; subsequent calls within the window are dropped.""" + mock_redis.add.side_effect = [True, False, False] + inner = mocker.Mock(return_value="result") + + @cooldown_task(wait_time=3600) + def my_task(*args, **kwargs): + return inner(*args, **kwargs) + + assert my_task("a", b=1) == "result" + assert my_task() is None + assert my_task() is None + inner.assert_called_once_with("a", b=1) + for call in mock_redis.add.call_args_list: + assert call.kwargs["timeout"] == 3600 + + +def test_cooldown_task_uses_custom_key(mock_redis): + """When `key` is provided, the lock key uses it instead of the func name.""" + mock_redis.add.return_value = True + + @cooldown_task(wait_time=60, key="my-custom-key") + def my_task(): + return 1 + + my_task() + assert mock_redis.add.call_args[0][0] == "cooldown:my-custom-key" + + +def test_cooldown_task_default_key_uses_func_name(mock_redis): + """Default key is derived from the wrapped function's qualified name.""" + mock_redis.add.return_value = True + + @cooldown_task(wait_time=60) + def some_task(): + return 1 + + some_task() + key = mock_redis.add.call_args[0][0] + assert key.startswith("cooldown:") + assert "some_task" in key + + +def test_cooldown_task_key_func_scopes_per_argument(mock_redis): + """`key_func` produces a distinct lock per argument set.""" + mock_redis.add.return_value = True + + @cooldown_task( + wait_time=60, + key_func=lambda *, sheets_id=None: f"sheet:{sheets_id}", + ) + def my_task(sheets_id=None): + return 1 + + my_task(sheets_id="A") + my_task(sheets_id="B") + keys = [call.args[0] for call in mock_redis.add.call_args_list] + assert keys[0].endswith(":sheet:A") + assert keys[1].endswith(":sheet:B") + assert keys[0] != keys[1] + + +def test_cooldown_task_lock_held_across_exception(mock_redis): + """Failures count against the cooldown — lock is not released on exception.""" + mock_redis.add.side_effect = [True, False] + + @cooldown_task(wait_time=60) + def my_task(): + msg = "boom" + raise RuntimeError(msg) + + with pytest.raises(RuntimeError): + my_task() + assert my_task() is None + + +def test_cooldown_task_clear_cooldown_deletes_key(mock_redis): + """`clear_cooldown` deletes the lock key.""" + + @cooldown_task(wait_time=60) + def my_task(): + return 1 + + my_task.clear_cooldown() + assert mock_redis.delete.called + key = mock_redis.delete.call_args[0][0] + assert key.startswith("cooldown:") + assert "my_task" in key + + +def test_cooldown_task_clear_cooldown_respects_key_func(mock_redis): + """`clear_cooldown` uses `key_func` so per-argument locks can be cleared.""" + + @cooldown_task( + wait_time=60, + key_func=lambda *, sheets_id=None: f"sheet:{sheets_id}", + ) + def my_task(sheets_id=None): + return 1 + + my_task.clear_cooldown(sheets_id="A") + assert mock_redis.delete.call_args[0][0].endswith(":sheet:A") + + +def test_cooldown_task_force_bypasses_active_cooldown(mock_redis, mocker): + """`_cooldown_force=True` runs the wrapped func even when the lock is held.""" + inner = mocker.Mock(return_value="ran") + + @cooldown_task(wait_time=60) + def my_task(**kwargs): + return inner(**kwargs) + + result = my_task(_cooldown_force=True) + assert result == "ran" + inner.assert_called_once_with() + mock_redis.add.assert_not_called() + assert mock_redis.set.called + assert mock_redis.set.call_args.kwargs["timeout"] == 60 + + +def test_cooldown_task_force_refreshes_cooldown_for_subsequent_calls(mock_redis): + """After a forced run, an immediate normal call is still gated.""" + mock_redis.add.return_value = False + + @cooldown_task(wait_time=60) + def my_task(**kwargs): + return 1 + + my_task(_cooldown_force=True) + assert my_task() is None + mock_redis.add.assert_called_once() + + +def test_cooldown_task_raises_reject_inside_celery_worker(mock_redis, mocker): + """ + When a skip happens inside a real Celery task run, write REJECTED to the + result backend and raise Reject so the run is observable as REJECTED + rather than PENDING/SUCCESS. + """ + mock_redis.add.return_value = False + mock_task = mocker.patch("main.decorators.current_task") + mock_task.request.called_directly = False + + @cooldown_task(wait_time=60) + def my_task(): + return 1 + + with pytest.raises(Reject) as exc_info: + my_task() + assert exc_info.value.requeue is False + mock_task.update_state.assert_called_once() + assert mock_task.update_state.call_args.kwargs["state"] == "REJECTED" + assert mock_task.update_state.call_args.kwargs["meta"]["reason"] == "cooldown" + mock_task.send_event.assert_called_once_with(TASK_REJECTED, requeue=False) + + +def test_cooldown_task_returns_none_when_called_directly(mock_redis, mocker): + """Direct (non-worker) skipped calls return None — no Reject, no state write.""" + mock_redis.add.return_value = False + mock_task = mocker.patch("main.decorators.current_task") + mock_task.request.called_directly = True + + @cooldown_task(wait_time=60) + def my_task(): + return 1 + + assert my_task() is None + mock_task.update_state.assert_not_called() + mock_task.send_event.assert_not_called() diff --git a/main/settings.py b/main/settings.py index 31d4051c80..71112162eb 100644 --- a/main/settings.py +++ b/main/settings.py @@ -35,7 +35,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.68.5" +VERSION = "0.68.7" log = logging.getLogger() diff --git a/news_events/etl/articles_news.py b/news_events/etl/articles_news.py index 19a7c9a131..219947cb31 100644 --- a/news_events/etl/articles_news.py +++ b/news_events/etl/articles_news.py @@ -43,9 +43,11 @@ def sync_single_website_content_news_to_news(article: WebsiteContent): """ if not article.is_published: return + if article.content_type != WebsiteContentType.news.name: return article_data = extract_single_website_content(article) + item_data = transform_single_article(article_data) if not item_data: return diff --git a/news_events/tasks.py b/news_events/tasks.py index 7e285aad7c..1168fc7493 100644 --- a/news_events/tasks.py +++ b/news_events/tasks.py @@ -50,6 +50,7 @@ def get_mitpe_events(): @app.task def get_website_content_news(): """Run the website content news ETL pipeline""" + pipelines.articles_news_etl() clear_views_cache() @@ -86,7 +87,9 @@ def sync_website_content_to_news(self, content_id: int): try: content = WebsiteContent.objects.get(id=content_id, is_published=True) + sync_single_website_content_news_to_news(content) + clear_views_cache() logger.info( "Successfully synced content %s to news feed", diff --git a/pyproject.toml b/pyproject.toml index f5ad307972..1da4398078 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,7 +108,7 @@ dependencies = [ "wrapt>=1.14.1,<2", "youtube-transcript-api>=1.0.0,<1.1", "pypdfium2>=5.0.0,<6", - "pyarrow>=21.0.0,<22", + "pyarrow>=23.0.1,<24", "django-zeal>=2.0.4,<3", "pdf2image>=1.17.0,<2", "opendataloader-pdf>=1.3.0,<2", diff --git a/scripts/k6.sh b/scripts/k6.sh index 80c01807b6..5095e0b288 100755 --- a/scripts/k6.sh +++ b/scripts/k6.sh @@ -11,4 +11,4 @@ docker run --rm -ti \ -v $ROOT_DIR/load_testing:/app \ --add-host learn.odl.local:host-gateway \ grafana/k6:master-with-browser \ - run /app/learn.ts "$@" + run "$@" diff --git a/uv.lock b/uv.lock index 4758aa42d8..cd86b19c02 100644 --- a/uv.lock +++ b/uv.lock @@ -2706,7 +2706,7 @@ requires-dist = [ { name = "posthog", specifier = ">=5.0.0,<6" }, { name = "psycopg", specifier = ">=3.2.4,<4" }, { name = "psycopg2", specifier = ">=2.9.6,<3" }, - { name = "pyarrow", specifier = ">=21.0.0,<22" }, + { name = "pyarrow", specifier = ">=23.0.1,<24" }, { name = "pycountry", specifier = ">=24.6.1,<25" }, { name = "pygithub", specifier = ">=2.0.0,<3" }, { name = "pymupdf", specifier = ">=1.26.6,<2" }, @@ -3720,17 +3720,17 @@ wheels = [ [[package]] name = "pyarrow" -version = "21.0.0" +version = "23.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ef/c2/ea068b8f00905c06329a3dfcd40d0fcc2b7d0f2e355bdb25b65e0a0e4cd4/pyarrow-21.0.0.tar.gz", hash = "sha256:5051f2dccf0e283ff56335760cbc8622cf52264d67e359d5569541ac11b6d5bc", size = 1133487, upload-time = "2025-07-18T00:57:31.761Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/d4/d4f817b21aacc30195cf6a46ba041dd1be827efa4a623cc8bf39a1c2a0c0/pyarrow-21.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:3a302f0e0963db37e0a24a70c56cf91a4faa0bca51c23812279ca2e23481fccd", size = 31160305, upload-time = "2025-07-18T00:55:35.373Z" }, - { url = "https://files.pythonhosted.org/packages/a2/9c/dcd38ce6e4b4d9a19e1d36914cb8e2b1da4e6003dd075474c4cfcdfe0601/pyarrow-21.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:b6b27cf01e243871390474a211a7922bfbe3bda21e39bc9160daf0da3fe48876", size = 32684264, upload-time = "2025-07-18T00:55:39.303Z" }, - { url = "https://files.pythonhosted.org/packages/4f/74/2a2d9f8d7a59b639523454bec12dba35ae3d0a07d8ab529dc0809f74b23c/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:e72a8ec6b868e258a2cd2672d91f2860ad532d590ce94cdf7d5e7ec674ccf03d", size = 41108099, upload-time = "2025-07-18T00:55:42.889Z" }, - { url = "https://files.pythonhosted.org/packages/ad/90/2660332eeb31303c13b653ea566a9918484b6e4d6b9d2d46879a33ab0622/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b7ae0bbdc8c6674259b25bef5d2a1d6af5d39d7200c819cf99e07f7dfef1c51e", size = 42829529, upload-time = "2025-07-18T00:55:47.069Z" }, - { url = "https://files.pythonhosted.org/packages/33/27/1a93a25c92717f6aa0fca06eb4700860577d016cd3ae51aad0e0488ac899/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:58c30a1729f82d201627c173d91bd431db88ea74dcaa3885855bc6203e433b82", size = 43367883, upload-time = "2025-07-18T00:55:53.069Z" }, - { url = "https://files.pythonhosted.org/packages/05/d9/4d09d919f35d599bc05c6950095e358c3e15148ead26292dfca1fb659b0c/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:072116f65604b822a7f22945a7a6e581cfa28e3454fdcc6939d4ff6090126623", size = 45133802, upload-time = "2025-07-18T00:55:57.714Z" }, - { url = "https://files.pythonhosted.org/packages/71/30/f3795b6e192c3ab881325ffe172e526499eb3780e306a15103a2764916a2/pyarrow-21.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf56ec8b0a5c8c9d7021d6fd754e688104f9ebebf1bf4449613c9531f5346a18", size = 26203175, upload-time = "2025-07-18T00:56:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f4b0dbfa124c0bb161f8b5ebb40f1a680b70279aa0c9901d44a2b5a20806039f", size = 34214575, upload-time = "2026-02-16T10:09:56.225Z" }, + { url = "https://files.pythonhosted.org/packages/e1/da/3f941e3734ac8088ea588b53e860baeddac8323ea40ce22e3d0baa865cc9/pyarrow-23.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:7707d2b6673f7de054e2e83d59f9e805939038eebe1763fe811ee8fa5c0cd1a7", size = 35832540, upload-time = "2026-02-16T10:10:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/88/7c/3d841c366620e906d54430817531b877ba646310296df42ef697308c2705/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86ff03fb9f1a320266e0de855dee4b17da6794c595d207f89bba40d16b5c78b9", size = 44470940, upload-time = "2026-02-16T10:10:10.704Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05", size = 47586063, upload-time = "2026-02-16T10:10:17.95Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/b7d2ebcff47a514f47f9da1e74b7949138c58cfeb108cdd4ee62f43f0cf3/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bf5842f960cddd2ef757d486041d57c96483efc295a8c4a0e20e704cbbf39c67", size = 48173045, upload-time = "2026-02-16T10:10:25.363Z" }, + { url = "https://files.pythonhosted.org/packages/43/b2/b40961262213beaba6acfc88698eb773dfce32ecdf34d19291db94c2bd73/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564baf97c858ecc03ec01a41062e8f4698abc3e6e2acd79c01c2e97880a19730", size = 50621741, upload-time = "2026-02-16T10:10:33.477Z" }, + { url = "https://files.pythonhosted.org/packages/f6/70/1fdda42d65b28b078e93d75d371b2185a61da89dda4def8ba6ba41ebdeb4/pyarrow-23.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:07deae7783782ac7250989a7b2ecde9b3c343a643f82e8a4df03d93b633006f0", size = 27620678, upload-time = "2026-02-16T10:10:39.31Z" }, ] [[package]] diff --git a/website_content/api.py b/website_content/api.py index fa39d244d1..0fdfe4f0a9 100644 --- a/website_content/api.py +++ b/website_content/api.py @@ -12,6 +12,7 @@ log = logging.getLogger(__name__) + _CONTENT_TYPE_LISTING_URL = { WebsiteContentType.news.name: "/news", WebsiteContentType.article.name: "/articles", @@ -53,6 +54,7 @@ def purge_content_on_save(content): fastly_purge_website_content_list.delay( _CONTENT_TYPE_LISTING_URL.get(content.content_type, "/news") ) + else: log.debug( "WebsiteContent %s is not published or has no slug, skipping CDN purge.", diff --git a/website_content/migrations/0003_add_editors_group.py b/website_content/migrations/0003_add_editors_group.py index 1d49627b8d..44366a99ec 100644 --- a/website_content/migrations/0003_add_editors_group.py +++ b/website_content/migrations/0003_add_editors_group.py @@ -1,4 +1,5 @@ """ + Ensure the website_content_editors Group exists in the database. If the legacy article_editors Group exists, copy its members into the new group diff --git a/website_content/views.py b/website_content/views.py index 8fa4123cb9..fb8bce5e86 100644 --- a/website_content/views.py +++ b/website_content/views.py @@ -55,12 +55,12 @@ class WebsiteContentViewSet(viewsets.ModelViewSet): queryset = WebsiteContent.objects.all() permission_classes = [CanViewWebsiteContent, CanEditWebsiteContent] http_method_names = VALID_HTTP_METHODS + filter_backends = [DjangoFilterBackend] filterset_class = WebsiteContentFilter def get_queryset(self): qs = WebsiteContent.objects.all() - if not (is_admin_user(self.request) or is_website_content_editor(self.request)): qs = qs.filter(is_published=True) diff --git a/yarn.lock b/yarn.lock index d2ac5fe0cb..3aa5f65cd2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3332,13 +3332,13 @@ __metadata: languageName: node linkType: hard -"@mitodl/mitxonline-api-axios@npm:2026.5.1": - version: 2026.5.1 - resolution: "@mitodl/mitxonline-api-axios@npm:2026.5.1" +"@mitodl/mitxonline-api-axios@npm:2026.5.14": + version: 2026.5.14 + resolution: "@mitodl/mitxonline-api-axios@npm:2026.5.14" dependencies: "@types/node": "npm:^20.11.19" axios: "npm:^1.6.5" - checksum: 10/6eb179298221fc2801ce4e3c0589de0378c68b7a32503101de675a85f6e3569c78ec6cb4b1bc5aa85094637532a34d10c110a4a9cdfa96c525dc4362e0236d5c + checksum: 10/24780dbfd2c9cf50c9b3ec877b4cad18bd30ca01202445215fdaadbf627bdf961d04354e7329cd05e2d5d58ad02d539972769d4848e682eaaf5aca57fdbca63f languageName: node linkType: hard @@ -8939,7 +8939,7 @@ __metadata: resolution: "api@workspace:frontends/api" dependencies: "@faker-js/faker": "npm:^10.0.0" - "@mitodl/mitxonline-api-axios": "npm:2026.5.1" + "@mitodl/mitxonline-api-axios": "npm:2026.5.14" "@tanstack/react-query": "npm:^5.66.0" "@testing-library/react": "npm:^16.3.0" axios: "npm:^1.12.2" @@ -16184,7 +16184,7 @@ __metadata: "@floating-ui/react": "npm:^0.27.16" "@happy-dom/jest-environment": "npm:^20.1.0" "@mitodl/course-search-utils": "npm:^3.5.2" - "@mitodl/mitxonline-api-axios": "npm:2026.5.1" + "@mitodl/mitxonline-api-axios": "npm:2026.5.14" "@mitodl/smoot-design": "npm:^6.27.0" "@mui/material": "npm:^6.4.5" "@mui/material-nextjs": "npm:^6.4.3"