Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
Release Notes
=============

Version 0.71.3
--------------

- Fix error pages after swap to runtime env vars (#3448)
- add scm as synonym to search (#3449)
- remove duplicate resource task (#3436)
- fix: add link to the creation page of article and news (#3430)
- Remove video shorts app (Phase 2) (#3385)

Version 0.71.1 (Released June 08, 2026)
--------------

Expand Down
40 changes: 23 additions & 17 deletions frontends/main/src/app-pages/Articles/ArticleListingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import type { WebsiteContent } from "api/v1"
import { LocalDate } from "ol-utilities"
import { useWebsiteContentList } from "api/hooks/website_content"
import { extractArticleContent } from "@/common/websiteContentUtils"
import { articleView } from "@/common/urls"
import { articleView, websiteContentCreateView } from "@/common/urls"
import { Permission, useUserHasPermission } from "api/hooks/user"
import { ButtonLink } from "@mitodl/smoot-design"

const PAGE_SIZE = 10
const MAX_PAGE = 50
Expand Down Expand Up @@ -223,6 +225,10 @@ const BannerGridContainer = styled.div`
max-width: 842px;
margin: 0 auto;
padding: 64px 0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;

${theme.breakpoints.down("sm")} {
max-width: 100%;
Expand Down Expand Up @@ -299,6 +305,10 @@ const BannerSection = styled.div`
background: ${theme.custom.colors.white};
border-bottom: 1px solid ${theme.custom.colors.lightGray2};
`
const NewArticleLink = styled(ButtonLink)`
display: flex;
justify-content: end;
`

const EmptyState = styled.div`
display: flex;
Expand All @@ -319,7 +329,6 @@ const EmptyState = styled.div`

const BannerTitle = styled(Typography)`
color: ${theme.custom.colors.black};
margin-top: 8px;
${theme.breakpoints.down("md")} {
${{ ...theme.typography.h2 }}
}
Expand All @@ -329,16 +338,6 @@ const BannerTitle = styled(Typography)`
}
` as typeof Typography

const BannerDescription = styled(Typography)`
color: ${theme.custom.colors.black};
margin-top: 16px;
font-size: 20px;
line-height: 32px;
${theme.breakpoints.down("sm")} {
${{ ...theme.typography.body2 }}
}
`

const BreadcrumbBar = styled.div(({ theme }) => ({
width: "100%",
padding: "18px 0 2px 0",
Expand Down Expand Up @@ -418,6 +417,8 @@ const ArticleListingPage: React.FC = () => {
const parsedPage = Number.parseInt(searchParams.get("page") ?? "1", 10)
const page = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1

const isArticleEditor = useUserHasPermission(Permission.ArticleEditor)

const { data: articles, isLoading } = useWebsiteContentList({
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
Expand Down Expand Up @@ -454,11 +455,16 @@ const ArticleListingPage: React.FC = () => {
<BannerTitle component="h1" variant="h1">
Articles
</BannerTitle>
<BannerDescription variant="body1">
Dive into articles covering emerging technologies, global
challenges, and creative thinking. Stay connected with the latest
insights and discoveries from MIT.
</BannerDescription>
{isArticleEditor && (
<NewArticleLink
variant="primary"
href={websiteContentCreateView("article")}
>
<Typography variant="body1" color="white">
New Article
</Typography>
</NewArticleLink>
)}
</BannerGridContainer>
</Container>
</BannerSection>
Expand Down
36 changes: 31 additions & 5 deletions frontends/main/src/app-pages/News/NewsBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import {
Breadcrumbs,
BannerBackground,
} from "ol-components"
import { ButtonLink } from "@mitodl/smoot-design"
import { Permission, useUserHasPermission } from "api/hooks/user"
import { websiteContentCreateView } from "@/common/urls"

export const DEFAULT_BACKGROUND_IMAGE_URL =
"/images/backgrounds/backgroung_steps.jpg"
Expand All @@ -30,7 +33,17 @@ const BannerSection = styled(BannerBackground)`
z-index: 2;
}
`

const NewArticleLink = styled(ButtonLink)`
display: flex;
justify-content: end;
`
const InfoContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 16px;
`
const BannerTitle = styled(Typography)`
color: ${theme.custom.colors.white};
margin-top: 8px;
Expand Down Expand Up @@ -65,6 +78,7 @@ const NewsBanner: React.FC<NewsBannerProps> = ({
backgroundUrl = DEFAULT_BACKGROUND_IMAGE_URL,
className,
}) => {
const isArticleEditor = useUserHasPermission(Permission.ArticleEditor)
return (
<BannerSection
className={className}
Expand All @@ -78,10 +92,22 @@ const NewsBanner: React.FC<NewsBannerProps> = ({
ancestors={[{ href: "/", label: "Home" }]}
current={currentBreadcrumb}
/>
<BannerTitle component="h1" variant="h1">
{title}
</BannerTitle>
<BannerDescription variant="body1">{description}</BannerDescription>
<InfoContainer>
<div>
<BannerTitle component="h1" variant="h1">
{title}
</BannerTitle>
<BannerDescription variant="body1">{description}</BannerDescription>
</div>
{isArticleEditor && (
<NewArticleLink
variant="tertiary"
href={websiteContentCreateView("news")}
>
<Typography variant="body1">Add news</Typography>
</NewArticleLink>
)}
</InfoContainer>
</Container>
</BannerSection>
)
Expand Down
29 changes: 29 additions & 0 deletions frontends/main/src/app-pages/News/NewsListingPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe("NewsListingPage", () => {
beforeEach(() => {
mockedUseFeatureFlagEnabled.mockReturnValue(true)
mockedUseFeatureFlagsLoaded.mockReturnValue(true)
setMockResponse.get(urls.userMe.get(), { is_authenticated: false })
})

afterEach(() => {
Expand Down Expand Up @@ -82,6 +83,34 @@ describe("NewsListingPage", () => {
})
})

test("shows 'Add news' button in banner when user is an article editor", async () => {
setMockResponse.get(urls.userMe.get(), {
is_authenticated: true,
is_article_editor: true,
})
setupAPI(21)
renderWithProviders(<NewsListingPage />)

await waitFor(() => {
expect(
screen.getByRole("link", { name: /add news/i }),
).toBeInTheDocument()
})
})

test("hides 'Add news' button in banner when user is not an article editor", async () => {
setupAPI(21)
renderWithProviders(<NewsListingPage />)

await waitFor(() => {
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument()
})

expect(
screen.queryByRole("link", { name: /add news/i }),
).not.toBeInTheDocument()
})

test("displays News images when available", async () => {
const news = setupAPI(21)
renderWithProviders(<NewsListingPage />)
Expand Down
7 changes: 2 additions & 5 deletions frontends/main/src/app/components/PublicEnvScript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,16 @@
*/
import React from "react"
import { connection } from "next/server"
import { publicEnvObject } from "@/env"

export async function PublicEnvScript() {
// `connection()` opts this route out of static prerendering so that
// process.env is read fresh on every request (not baked at build time).
await connection()

const publicEnv = Object.fromEntries(
Object.entries(process.env).filter(([k]) => k.startsWith("NEXT_PUBLIC_")),
)

// Escape `<` to prevent a value like `</script><script>...` from breaking
// out of the script tag.
const json = JSON.stringify(publicEnv).replace(/</g, "\\u003c")
const json = JSON.stringify(publicEnvObject()).replace(/</g, "\\u003c")

return (
<script dangerouslySetInnerHTML={{ __html: `window.__ENV=${json};` }} />
Expand Down
3 changes: 3 additions & 0 deletions frontends/main/src/app/global-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
* - does NOT use root layout (since error occurred there!)
* - therefore, must define its own HTML tags and providers
* Must define its own HTML tag
* - root layout metadata still emits here, so the x-public-env <meta> and env()
* usually work (also how Sentry inits) — but prefer env() over requiredEnv()
* in this subtree; there's no boundary below to catch a throw.
* - NOT used in development mode
*
* https://nextjs.org/docs/app/building-your-application/routing/error-handling#handling-errors-in-root-layouts
Expand Down
18 changes: 15 additions & 3 deletions frontends/main/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
import React from "react"
import Providers from "./providers"
import { PublicEnvScript } from "./components/PublicEnvScript"
import { env } from "@/env"
import { env, publicEnvObject } from "@/env"

import "./GlobalStyles"
import { Metadata } from "next"

const NEXT_PUBLIC_ORIGIN = env("NEXT_PUBLIC_ORIGIN")

/**
* As part of the root layout, this metadata object is site-wide defaults
* Site-wide metadata defaults plus an x-public-env meta tag carrying all
* NEXT_PUBLIC_* values as JSON. NOTES:
* 1. This delivery mechanism for NEXT_PUBLIC_* facilitates runtime env vars (not buildtime)
* 2. Delivery via metadata works even when errors are thrown server-side,
* in which case React SSR is aborted and rendering falls back to a client-side render
* 3. Available to synchronous scripts below <meta x-public-env> or async scripts
* above it, as long as this tag is delivered in the same HTML chunk as the async
* scripts above it. (In practice, this is true for metadata + HTML content
* down to the first suspense boundary.)
*/
export const metadata = {
export const metadata: Metadata = {
metadataBase: NEXT_PUBLIC_ORIGIN ? new URL(NEXT_PUBLIC_ORIGIN) : null,
// raw JSON is fine: the attribute is HTML-escaped when rendered and
// getAttribute() returns it decoded, so JSON.parse round-trips.
other: { "x-public-env": JSON.stringify(publicEnvObject()) },
}

/**
Expand Down
54 changes: 53 additions & 1 deletion frontends/main/src/env.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,63 @@
import { env, requiredEnv } from "@/env"
import { env, requiredEnv, fullEnv } from "@/env"

test("env() reads from window.__ENV", () => {
window.__ENV = { NEXT_PUBLIC_ORIGIN: "https://example.test" }
expect(env("NEXT_PUBLIC_ORIGIN")).toBe("https://example.test")
delete window.__ENV
})

test("env() falls back to the x-public-env <meta> when window.__ENV is unset", () => {
// Error/not-found pages are client-rendered shells where PublicEnvScript never
// runs; env() must read the value from the <meta> the root layout emits.
const meta = document.createElement("meta")
meta.setAttribute("name", "x-public-env")
meta.setAttribute(
"content",
JSON.stringify({ NEXT_PUBLIC_ORIGIN: "https://meta.test" }),
)
document.head.appendChild(meta)

expect(env("NEXT_PUBLIC_ORIGIN")).toBe("https://meta.test")
// cached so later reads skip the DOM query
expect(window.__ENV).toEqual({ NEXT_PUBLIC_ORIGIN: "https://meta.test" })

meta.remove()
delete window.__ENV
})

test("env() ignores a malformed x-public-env <meta> and does not throw", () => {
const meta = document.createElement("meta")
meta.setAttribute("name", "x-public-env")
meta.setAttribute("content", "{not valid json")
document.head.appendChild(meta)

expect(() => env("NEXT_PUBLIC_ORIGIN")).not.toThrow()
expect(window.__ENV).toBeUndefined()

meta.remove()
})

test("fullEnv() returns the full map, bootstrapping from the <meta> like env()", () => {
const meta = document.createElement("meta")
meta.setAttribute("name", "x-public-env")
meta.setAttribute(
"content",
JSON.stringify({
NEXT_PUBLIC_ORIGIN: "https://meta.test",
NEXT_PUBLIC_FEATURE_demo: "True",
}),
)
document.head.appendChild(meta)

expect(fullEnv()).toEqual({
NEXT_PUBLIC_ORIGIN: "https://meta.test",
NEXT_PUBLIC_FEATURE_demo: "True",
})

meta.remove()
delete window.__ENV
})

/*
* Compile-time regression guard for env.ts's schema-derived key unions. Never
* runs; enforced by `yarn typecheck`. If a yup/TS change breaks the InferType
Expand Down
Loading
Loading