diff --git a/RELEASE.rst b/RELEASE.rst
index fa6311fd24..21d19197af 100644
--- a/RELEASE.rst
+++ b/RELEASE.rst
@@ -1,6 +1,18 @@
Release Notes
=============
+Version 0.81.0
+--------------
+
+- Smooth FAQ accordion open/close animation (#3997)
+- feat(website-content): save drafts automatically, drop the draft button (#3978)
+- Posthog checkout_completed event (#3982)
+- fix: scope unsubscribe lookup to the requesting user's own subscriptions (#3984)
+- Sanitize MITPE news and events summary/content like sibling sources (#3977)
+- feat(website-content): unpublish published items from the listing card (#3963)
+- Re-ingest content files of republished runs, fix inconsistent contentfile (de)indexing (#3976)
+- add back schedule and fix admin criteria text field (#3989)
+
Version 0.80.18
---------------
diff --git a/frontends/api/src/hooks/website_content/index.test.ts b/frontends/api/src/hooks/website_content/index.test.ts
index a27397a719..dd79aeba58 100644
--- a/frontends/api/src/hooks/website_content/index.test.ts
+++ b/frontends/api/src/hooks/website_content/index.test.ts
@@ -102,6 +102,19 @@ describe("Website Content CRUD", () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: websiteContentKeys.detail(article.id),
})
+ expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: websiteContentKeys.websiteContentDetailRetrieve(
+ article.slug || String(article.id),
+ ),
+ })
+ /**
+ * The listings render the fields a patch can change -- title, summary and
+ * whether the item is published at all. Without this, a card unpublished
+ * from the listing stays on screen until something else refetches.
+ */
+ expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
+ queryKey: websiteContentKeys.listRoot(),
+ })
})
test("useWebsiteContentDestroy calls correct API", async () => {
diff --git a/frontends/api/src/hooks/website_content/index.ts b/frontends/api/src/hooks/website_content/index.ts
index 354d0cf197..ac3a3bdbb5 100644
--- a/frontends/api/src/hooks/website_content/index.ts
+++ b/frontends/api/src/hooks/website_content/index.ts
@@ -136,6 +136,14 @@ const useWebsiteContentPartialUpdate = ({ meta }: MutationHookOptions = {}) => {
client.invalidateQueries({
queryKey: websiteContentKeys.websiteContentDetailRetrieve(identifier),
})
+ /**
+ * The listings render the fields a patch can change -- title, summary
+ * and whether the item is published at all -- so they go stale too. The
+ * destroy hook already invalidates them for the same reason.
+ */
+ client.invalidateQueries({
+ queryKey: websiteContentKeys.listRoot(),
+ })
},
})
}
diff --git a/frontends/main/src/app-pages/Articles/ArticleListingPage.test.tsx b/frontends/main/src/app-pages/Articles/ArticleListingPage.test.tsx
new file mode 100644
index 0000000000..cff02dfbd7
--- /dev/null
+++ b/frontends/main/src/app-pages/Articles/ArticleListingPage.test.tsx
@@ -0,0 +1,130 @@
+import React from "react"
+import { screen, waitFor } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { setMockResponse, factories, urls, makeRequest } from "api/test-utils"
+import { renderWithProviders } from "@/test-utils"
+import { ArticleListingPage } from "./ArticleListingPage"
+
+const setup = ({
+ isArticleEditor = true,
+ isPublished = true,
+}: { isArticleEditor?: boolean; isPublished?: boolean } = {}) => {
+ const user = factories.user.user({
+ is_authenticated: true,
+ is_article_editor: isArticleEditor,
+ })
+ setMockResponse.get(urls.userMe.get(), user)
+
+ const article = factories.websiteContent.websiteContent({
+ id: 701,
+ title: "Breaking the old model of education",
+ content_type: "article",
+ is_published: isPublished,
+ })
+ setMockResponse.get(
+ urls.websiteContent.list({ limit: 10, offset: 0, content_type: "article" }),
+ { count: 1, next: null, previous: null, results: [article] },
+ )
+
+ renderWithProviders(, { user })
+ return { article }
+}
+
+const menuName = (title: string) => `More options for ${title}`
+
+/**
+ * The page renders its mobile and desktop layouts together and hides one with
+ * CSS, so every card is in the DOM twice. These queries take the first match
+ * rather than asserting a single one.
+ */
+const findMenuButtons = (title: string) =>
+ screen.findAllByRole("button", { name: menuName(title) })
+const queryMenuButtons = (title: string) =>
+ screen.queryAllByRole("button", { name: menuName(title) })
+
+describe("ArticleListingPage article actions", () => {
+ test("an editor can unpublish a published article from the card", async () => {
+ const { article } = setup()
+ setMockResponse.patch(urls.websiteContent.details(article.id), {
+ ...article,
+ is_published: false,
+ })
+
+ await userEvent.click((await findMenuButtons(article.title))[0])
+ await userEvent.click(
+ await screen.findByRole("menuitem", { name: "Unpublish" }),
+ )
+
+ // The menu only asks; the dialog owns the confirmation.
+ await screen.findByRole("heading", { name: "Unpublish article" })
+ await userEvent.click(
+ await screen.findByRole("button", { name: "Yes, Unpublish article" }),
+ )
+
+ await waitFor(() => {
+ expect(makeRequest).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: "patch",
+ url: urls.websiteContent.details(article.id),
+ body: { is_published: false },
+ }),
+ )
+ })
+ })
+
+ test("cancelling the dialog unpublishes nothing", async () => {
+ const { article } = setup()
+
+ await userEvent.click((await findMenuButtons(article.title))[0])
+ await userEvent.click(
+ await screen.findByRole("menuitem", { name: "Unpublish" }),
+ )
+ await userEvent.click(await screen.findByRole("button", { name: "Cancel" }))
+
+ expect(makeRequest).not.toHaveBeenCalledWith(
+ expect.objectContaining({ method: "patch" }),
+ )
+ })
+
+ test("the menu is hidden from users who cannot edit articles", async () => {
+ const { article } = setup({ isArticleEditor: false })
+
+ await screen.findAllByText(article.title)
+
+ expect(queryMenuButtons(article.title)).toHaveLength(0)
+ })
+
+ test("a draft has no menu, since unpublishing is the only action", async () => {
+ const { article } = setup({ isPublished: false })
+
+ await screen.findAllByText(article.title)
+
+ expect(queryMenuButtons(article.title)).toHaveLength(0)
+ })
+})
+
+describe("ArticleListingPage links", () => {
+ const hrefs = (title: string) =>
+ screen
+ .getAllByRole("link", { name: title })
+ .map((a) => a.getAttribute("href"))
+
+ test("a draft links to the editor, since it has no public page", async () => {
+ const { article } = setup({ isPublished: false })
+
+ await screen.findAllByText(article.title)
+
+ /* Every link on the card, so the title and the image cannot diverge. */
+ const unique = [...new Set(hrefs(article.title))]
+ expect(unique).toEqual([`/website_content/article/${article.id}/edit`])
+ })
+
+ test("a published article links to its public page", async () => {
+ const { article } = setup({ isPublished: true })
+
+ await screen.findAllByText(article.title)
+
+ const unique = [...new Set(hrefs(article.title))]
+ expect(unique).toEqual([`/articles/${article.slug ?? article.id}`])
+ })
+})
diff --git a/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx b/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx
index 1d47d54feb..a0aa1fad58 100644
--- a/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx
+++ b/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx
@@ -19,13 +19,19 @@ import {
} from "ol-components"
import Link from "next/link"
import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react"
-import type { WebsiteContent } from "api/v1"
+import { WebsiteContentContentTypeEnum, type WebsiteContent } from "api/v1"
import { LocalDate } from "ol-utilities"
import { useWebsiteContentList } from "api/hooks/website_content"
import { extractArticleContent } from "@/common/websiteContentUtils"
-import { articleView, websiteContentCreateView } from "@/common/urls"
+import { CONTENT_TYPE_LABELS } from "@/common/website_content"
+import {
+ articleView,
+ websiteContentCreateView,
+ websiteContentEditView,
+} from "@/common/urls"
import { Permission, useUserHasPermission } from "api/hooks/user"
import { ButtonLink } from "@mitodl/smoot-design"
+import { WebsiteContentActionsMenu } from "@/page-components/WebsiteContentActionsMenu/WebsiteContentActionsMenu"
const PAGE_SIZE = 10
const MAX_PAGE = 50
@@ -57,6 +63,7 @@ const RegularStoryTitleWrapper = styled.div`
const StoryCard = styled.div`
display: flex;
flex-direction: row;
+ position: relative;
gap: 24px;
background: white;
border-radius: 8px;
@@ -92,6 +99,25 @@ const StoryCard = styled.div`
}
`
+/**
+ * Holds the three-dot menu over the card's top-right corner, where the design
+ * puts it: level with the top of the image, inside the card's 16px padding.
+ *
+ * A sibling of the image's link rather than a child of it, so clicking the
+ * menu cannot navigate to the article.
+ */
+const StoryActions = styled.div`
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ z-index: 1;
+
+ ${theme.breakpoints.down("sm")} {
+ top: 16px;
+ right: 0;
+ }
+`
+
const StoryImage = styled.div`
width: 280px;
min-width: 280px;
@@ -372,14 +398,33 @@ const BreadcrumContainer = styled(Container)(({ theme }) => ({
const RegularStory: React.FC<{ item: WebsiteContent }> = ({ item }) => {
const articleContent = extractArticleContent(item)
const [imageError, setImageError] = React.useState(false)
+ /**
+ * An unpublished article has no public page to land on, so the card points
+ * at the editor instead. Only an editor ever sees one here: the listing
+ * endpoint filters unpublished items out for everyone else.
+ *
+ * By id rather than slug, which a draft may not have yet.
+ */
+ const href = item.is_published
+ ? articleView(item.slug ?? String(item.id))
+ : websiteContentEditView(WebsiteContentContentTypeEnum.Article, item.id)
return (
+ {/* A draft has nothing to unpublish; the menu hides itself from
+ users who cannot edit articles. */}
+ {item.is_published ? (
+
+
+
+ ) : null}
-
- {item.title}
-
+ {item.title}
{articleContent.paragraph && (
= ({ item }) => {
{articleContent?.image?.src && !imageError && (
-
+
({
trackCheckoutCompleted: jest.fn(),
}))
+jest.mock("posthog-js/react")
+const mockedPostHogCapture = jest.fn()
+jest.mocked(usePostHog).mockReturnValue({
+ capture: mockedPostHogCapture,
+} as unknown as PostHog)
const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
describe("EnrollmentRedirectAlert", () => {
beforeEach(() => {
jest.clearAllMocks()
+ process.env.NEXT_PUBLIC_POSTHOG_API_KEY = "test-key"
+ })
+
+ afterEach(() => {
+ delete process.env.NEXT_PUBLIC_POSTHOG_API_KEY
})
test("shows invalid-enrollment-code error alert and clears params", async () => {
@@ -256,7 +269,11 @@ describe("EnrollmentRedirectAlert", () => {
test("tracks checkout-completed once with order id, course name, and value from receipt", async () => {
const receipt = mitxonline.factories.orders.order({
- lines: [mitxonline.factories.orders.transactionLine()],
+ lines: [
+ mitxonline.factories.orders.transactionLine({
+ content_type: "courserun",
+ }),
+ ],
total_price_paid: "199.99",
})
@@ -274,6 +291,17 @@ describe("EnrollmentRedirectAlert", () => {
courseName: receipt.lines[0].content_title,
value: 199.99,
})
+ expect(mockedPostHogCapture).toHaveBeenCalledTimes(1)
+ expect(mockedPostHogCapture).toHaveBeenCalledWith(
+ PostHogEvents.CheckoutCompleted,
+ {
+ orderId: 17,
+ courseName: receipt.lines[0].content_title,
+ value: 199.99,
+ readableId: receipt.lines[0].readable_id,
+ contentType: "courserun",
+ },
+ )
})
test("tracks checkout-completed with a null value when the receipt fails to load", async () => {
@@ -293,6 +321,33 @@ describe("EnrollmentRedirectAlert", () => {
courseName: undefined,
value: null,
})
+ expect(mockedPostHogCapture).toHaveBeenCalledWith(
+ PostHogEvents.CheckoutCompleted,
+ {
+ orderId: 18,
+ courseName: undefined,
+ value: null,
+ readableId: undefined,
+ contentType: undefined,
+ },
+ )
+ })
+
+ test("does not capture PostHog checkout_completed when PostHog is not configured", async () => {
+ delete process.env.NEXT_PUBLIC_POSTHOG_API_KEY
+ setMockResponse.get(
+ mitxonline.urls.orders.receipt(19),
+ mitxonline.factories.orders.order(),
+ )
+
+ renderWithProviders(, {
+ url: "/dashboard?order_status=fulfilled&order_id=19",
+ })
+
+ await screen.findByRole("alert")
+
+ expect(trackCheckoutCompleted).toHaveBeenCalledTimes(1)
+ expect(mockedPostHogCapture).not.toHaveBeenCalled()
})
test("does not track checkout-completed for non-paid alerts", async () => {
@@ -303,6 +358,7 @@ describe("EnrollmentRedirectAlert", () => {
await screen.findByRole("alert")
expect(trackCheckoutCompleted).not.toHaveBeenCalled()
+ expect(mockedPostHogCapture).not.toHaveBeenCalled()
})
test.each([
diff --git a/frontends/main/src/app-pages/DashboardPage/EnrollmentRedirectAlert.tsx b/frontends/main/src/app-pages/DashboardPage/EnrollmentRedirectAlert.tsx
index d76b55db3c..ec193347b5 100644
--- a/frontends/main/src/app-pages/DashboardPage/EnrollmentRedirectAlert.tsx
+++ b/frontends/main/src/app-pages/DashboardPage/EnrollmentRedirectAlert.tsx
@@ -3,12 +3,14 @@
import { env } from "@/env"
import React from "react"
import { useQuery } from "@tanstack/react-query"
+import { usePostHog } from "posthog-js/react"
import { Alert } from "@mitodl/smoot-design"
import { Link, Skeleton, styled } from "ol-components"
import { orderQueries } from "api/mitxonline-hooks/orders"
import { mitxUserQueries } from "api/mitxonline-hooks/user"
import { DASHBOARD_MY_LEARNING } from "@/common/urls"
import { trackCheckoutCompleted } from "@/common/analytics/gtm"
+import { PostHogEvents } from "@/common/constants"
import {
ENROLLMENT_STATUS_PARAM,
ENROLLMENT_ERROR_TYPE_PARAM,
@@ -174,6 +176,7 @@ const parseAlertRequest = (
const EnrollmentRedirectAlert: React.FC = () => {
const request = useConsumeSearchParamsOnce(parseAlertRequest)
const supportEmail = env("NEXT_PUBLIC_MITOL_SUPPORT_EMAIL") || ""
+ const posthog = usePostHog()
const mitxOnlineUserQuery = useQuery({
...mitxUserQueries.me(),
@@ -195,12 +198,25 @@ const EnrollmentRedirectAlert: React.FC = () => {
? Number(paidReceipt.data.total_price_paid)
: NaN
+ const courseName = paidReceipt.data?.lines[0]?.content_title
+ const value = Number.isNaN(parsedValue) ? null : parsedValue
+
trackCheckoutCompleted({
orderId: request.orderId,
- courseName: paidReceipt.data?.lines[0]?.content_title,
- value: Number.isNaN(parsedValue) ? null : parsedValue,
+ courseName,
+ value,
})
- }, [request, paidReceipt.isPending, paidReceipt.data])
+ if (env("NEXT_PUBLIC_POSTHOG_API_KEY")) {
+ const line = paidReceipt.data?.lines[0]
+ posthog.capture(PostHogEvents.CheckoutCompleted, {
+ orderId: request.orderId,
+ courseName,
+ value,
+ readableId: line?.readable_id,
+ contentType: line?.content_type,
+ })
+ }
+ }, [request, paidReceipt.isPending, paidReceipt.data, posthog])
if (request?.kind === "error") {
const errorMessage =
diff --git a/frontends/main/src/app-pages/News/NewsListingPage.test.tsx b/frontends/main/src/app-pages/News/NewsListingPage.test.tsx
index 400b7ff894..c4f7b3b0aa 100644
--- a/frontends/main/src/app-pages/News/NewsListingPage.test.tsx
+++ b/frontends/main/src/app-pages/News/NewsListingPage.test.tsx
@@ -1,6 +1,6 @@
import React from "react"
import { NewsListingPage } from "./NewsListingPage"
-import { urls, setMockResponse } from "api/test-utils"
+import { urls, setMockResponse, makeRequest } from "api/test-utils"
import type { NewsFeedItem } from "api/v0"
import { newsEvents } from "api/test-utils/factories"
import {
@@ -401,3 +401,109 @@ describe("NewsListingPage", () => {
expect(mainStoryInstances.length).toBeGreaterThan(0)
})
})
+
+/**
+ * The listing renders its mobile and desktop layouts together and hides one
+ * with CSS, so each card is in the DOM twice; these take the first match.
+ *
+ * Index 0 of the feed is the featured MainStory and the rest are
+ * RegularStories -- two different cards, so `index` picks which one a test
+ * puts the synced story in.
+ */
+describe("NewsListingPage story actions", () => {
+ const CONTENT_ID = 512
+
+ /* A sibling describe, so it does not inherit the suite's own beforeEach. */
+ beforeEach(() => {
+ mockedUseFeatureFlagEnabled.mockReturnValue(true)
+ mockedUseFeatureFlagsLoaded.mockReturnValue(true)
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ const setupWithSyncedStory = ({
+ isContentEditor = true,
+ fromWebsiteContent = true,
+ index = 1,
+ } = {}) => {
+ setMockResponse.get(urls.userMe.get(), {
+ is_authenticated: isContentEditor,
+ is_article_editor: isContentEditor,
+ })
+ const news = newsEvents.newsItems({ count: 3 })
+ const story = news.results[index] as NewsFeedItem
+ if (fromWebsiteContent) {
+ /* The guid `WebsiteContentNewsPlugin` writes for synced content. */
+ story.guid = `article-${CONTENT_ID}`
+ }
+ setMockResponse.get(expect.stringContaining(urls.newsEvents.list()), news)
+ renderWithProviders()
+ return story
+ }
+
+ const menuButtons = (title: string) =>
+ screen.queryAllByRole("button", { name: `More options for ${title}` })
+
+ /* Opens the story's menu and confirms the dialog it puts up. */
+ const unpublishStory = async (title: string) => {
+ setMockResponse.patch(urls.websiteContent.details(CONTENT_ID), {
+ id: CONTENT_ID,
+ is_published: false,
+ })
+
+ await waitFor(() => expect(menuButtons(title).length).toBeGreaterThan(0))
+ await user.click(menuButtons(title)[0])
+ await user.click(await screen.findByRole("menuitem", { name: "Unpublish" }))
+
+ await screen.findByRole("heading", { name: "Unpublish news" })
+ await user.click(
+ await screen.findByRole("button", { name: "Yes, Unpublish news" }),
+ )
+ }
+
+ const expectUnpublished = () =>
+ waitFor(() => {
+ expect(makeRequest).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: "patch",
+ url: urls.websiteContent.details(CONTENT_ID),
+ body: { is_published: false },
+ }),
+ )
+ })
+
+ test("an editor can unpublish a story synced from website content", async () => {
+ const story = setupWithSyncedStory()
+
+ await unpublishStory(story.title)
+
+ await expectUnpublished()
+ })
+
+ test("the featured story carries the same menu", async () => {
+ const story = setupWithSyncedStory({ index: 0 })
+
+ await unpublishStory(story.title)
+
+ await expectUnpublished()
+ })
+
+ test("an externally ingested story has no menu", async () => {
+ const story = setupWithSyncedStory({ fromWebsiteContent: false })
+
+ await screen.findAllByText(story.title)
+
+ /* No WebsiteContent behind it, so there is nothing to unpublish. */
+ expect(menuButtons(story.title)).toHaveLength(0)
+ })
+
+ test("the menu is hidden from users who cannot edit content", async () => {
+ const story = setupWithSyncedStory({ isContentEditor: false })
+
+ await screen.findAllByText(story.title)
+
+ expect(menuButtons(story.title)).toHaveLength(0)
+ })
+})
diff --git a/frontends/main/src/app-pages/News/NewsListingPage.tsx b/frontends/main/src/app-pages/News/NewsListingPage.tsx
index d59a59c12f..5518f1d788 100644
--- a/frontends/main/src/app-pages/News/NewsListingPage.tsx
+++ b/frontends/main/src/app-pages/News/NewsListingPage.tsx
@@ -25,6 +25,11 @@ import {
import type { NewsFeedItem } from "api/v0"
import { LocalDate } from "ol-utilities"
import { linkifyText } from "@/common/utils"
+import {
+ CONTENT_TYPE_LABELS,
+ websiteContentIdFromFeedGuid,
+} from "@/common/website_content"
+import { WebsiteContentActionsMenu } from "@/page-components/WebsiteContentActionsMenu/WebsiteContentActionsMenu"
import { NewsBanner } from "./NewsBanner"
const PAGE_SIZE = 20
@@ -63,6 +68,7 @@ const FeaturedStorySection = styled.div`
const MainStoryCard = styled.div`
display: flex;
+ position: relative;
border-bottom: 1px solid ${theme.custom.colors.lightGray2};
background: ${theme.custom.colors.darkGray2};
border-top: 4px solid #a31f34;
@@ -202,6 +208,7 @@ const MainStoryDate = styled(Typography)`
const StoryCard = styled.div`
display: flex;
flex-direction: row;
+ position: relative;
gap: 24px;
background: white;
border-radius: 8px;
@@ -237,6 +244,31 @@ const StoryCard = styled.div`
}
`
+/**
+ * Holds the three-dot menu over a card's top-right corner, where the design
+ * puts it: level with the top of the image, 16px inside the card.
+ *
+ * A sibling of the card's links rather than a child of one, so clicking the
+ * menu cannot navigate to the story.
+ */
+const MainStoryActions = styled.div`
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ z-index: 1;
+`
+
+/**
+ * The same slot on the regular card, which goes full-bleed on small screens:
+ * it drops its horizontal padding there, so the menu follows the content out
+ * to the card edge rather than staying 16px inside it.
+ */
+const RegularStoryActions = styled(MainStoryActions)`
+ ${theme.breakpoints.down("sm")} {
+ right: 0;
+ }
+`
+
const StoryImage = styled.div`
width: 280px;
min-width: 280px;
@@ -480,11 +512,44 @@ const NewsBannerStyled = styled(NewsBanner)<{ page: number }>(
}),
)
+/**
+ * A story's three-dot menu, or nothing where it does not apply.
+ *
+ * Both card layouts show the same menu under the same conditions, so the
+ * conditions live here and each card supplies its own positioned `slot`.
+ * Externally ingested stories have no WebsiteContent behind them, so there is
+ * nothing to unpublish. Presence in the feed already means the item is
+ * published -- unpublishing deletes the feed entry -- so unlike the article
+ * listing there is no published check to make. The menu itself hides from
+ * users who cannot edit content.
+ */
+const StoryActionsMenu: React.FC<{
+ item: NewsFeedItem
+ slot: React.ComponentType<{ children: React.ReactNode }>
+}> = ({ item, slot: Slot }) => {
+ const contentId = websiteContentIdFromFeedGuid(item.guid)
+
+ if (contentId === null) {
+ return null
+ }
+
+ return (
+
+
+
+ )
+}
+
const MainStory: React.FC<{ item: NewsFeedItem }> = ({ item }) => {
const [imageError, setImageError] = React.useState(false)
return (
+
{item.image?.url && !imageError && (
@@ -523,6 +588,7 @@ const RegularStory: React.FC<{ item: NewsFeedItem }> = ({ item }) => {
return (
+
@@ -558,6 +624,7 @@ const RegularStory: React.FC<{ item: NewsFeedItem }> = ({ item }) => {
const NewsListingPage: React.FC = () => {
const searchParams = useAppSearchParams()
const setSearchParams = useSetSearchParams()
+ /* News is edited behind the same permission as articles. */
const page = parseInt(searchParams.get("page") ?? "1", 10)
const { data: news, isLoading } = useNewsEventsList({
diff --git a/frontends/main/src/app-pages/ProductPages/FaqsSection.tsx b/frontends/main/src/app-pages/ProductPages/FaqsSection.tsx
index 1c68f967bb..7db4cd8ca3 100644
--- a/frontends/main/src/app-pages/ProductPages/FaqsSection.tsx
+++ b/frontends/main/src/app-pages/ProductPages/FaqsSection.tsx
@@ -97,6 +97,7 @@ const FaqRow: React.FC<{ index: number; faq: FAQItem }> = ({ index, faq }) => {
expanded={expanded}
onChange={() => setExpanded(!expanded)}
disableGutters
+ slotProps={{ transition: { timeout: 250 } }}
>