Skip to content
Open
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
8 changes: 8 additions & 0 deletions .changelog/unreleased/626-changelog-foundations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
type: added
area: internal
pr: 626
breaking: false
---

The changelog now has stable release links, semantic category badges, and an on-request what's-new summary from the application navbar.
31 changes: 19 additions & 12 deletions apps/web/src/features/changelog/changelog.search.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { describe, it, expect } from "vitest"
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { highlightMatches, findMatches, matchesQuery } from "./utils.search"
import { describe, expect, it } from "vitest"
import { render } from "@testing-library/react"
import { findMatches, highlightMatches, matchesQuery } from "./utils.search"
import { HighlightedText } from "./components/HighlightedText"

describe("Search utilities", () => {
Expand Down Expand Up @@ -80,9 +79,7 @@ describe("Search utilities", () => {

describe("HighlightedText component", () => {
it("renders text without highlighting when no query", () => {
const { container } = render(
<HighlightedText text="hello world" />
)
const { container } = render(<HighlightedText text="hello world" />)
expect(container.textContent).toBe("hello world")
})

Expand Down Expand Up @@ -129,15 +126,25 @@ describe("HighlightedText component", () => {
describe("Search in changelog context", () => {
it("composes search with other filters", () => {
const entries = [
{ type: "added" as const, area: "trade" as const, text: "Trigger orders", pr: 1, breaking: false },
{ type: "fixed" as const, area: "pools" as const, text: "Pool APY calculation", pr: 2, breaking: false },
{
type: "added" as const,
area: "trade" as const,
text: "Trigger orders",
pr: 1,
breaking: false,
},
{
type: "fixed" as const,
area: "pools" as const,
text: "Pool APY calculation",
pr: 2,
breaking: false,
},
]

// Filter by type AND search
const filtered = entries.filter(
(e) =>
e.type === "added" &&
matchesQuery(e.text, "trigger")
(e) => e.type === "added" && matchesQuery(e.text, "trigger")
)

expect(filtered).toHaveLength(1)
Expand Down
121 changes: 90 additions & 31 deletions apps/web/src/features/changelog/changelog.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { describe, it, expect, vi, beforeAll, afterEach, afterAll } from "vitest"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { http, HttpResponse } from "msw"
import { setupServer } from "msw/node"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { HttpResponse, http } from "msw"
import { ChangelogPage } from "./components/ChangelogPage"
import { server } from "@/test/msw/server"

vi.mock("@/ui/Navbar", () => ({
Navbar: () => <nav aria-label="Primary" />,
}))

const mockChangelogData = {
releases: [
Expand All @@ -24,26 +29,33 @@ const mockChangelogData = {
],
}

const server = setupServer(
http.get("/changelog.json", () => {
return HttpResponse.json(mockChangelogData)
})
)
beforeEach(() => {
server.use(
http.get("/changelog.json", () => HttpResponse.json(mockChangelogData))
)
})

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return render(
<QueryClientProvider client={queryClient}>
<ChangelogPage />
</QueryClientProvider>
)
}

describe("ChangelogPage - States", () => {
it("renders skeleton while loading", async () => {
it("renders skeleton while loading", () => {
server.use(
http.get("/changelog.json", async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
return HttpResponse.json(mockChangelogData)
})
)

render(<ChangelogPage />)
renderPage()

const status = screen.getByRole("status")
expect(status).toBeInTheDocument()
Expand All @@ -57,7 +69,7 @@ describe("ChangelogPage - States", () => {
})
)

render(<ChangelogPage />)
renderPage()

await waitFor(() => {
const alert = screen.getByRole("alert")
Expand All @@ -80,7 +92,7 @@ describe("ChangelogPage - States", () => {
)

const user = userEvent.setup()
render(<ChangelogPage />)
renderPage()

// Wait for error
await waitFor(() => {
Expand All @@ -97,7 +109,7 @@ describe("ChangelogPage - States", () => {
expect(screen.getByText("0.4.0")).toBeInTheDocument()
})

expect(callCount).toBe(2)
expect(callCount).toBeGreaterThanOrEqual(2)
})

it("renders empty state for zero releases", async () => {
Expand All @@ -107,7 +119,7 @@ describe("ChangelogPage - States", () => {
})
)

render(<ChangelogPage />)
renderPage()

await waitFor(() => {
expect(screen.getByText("No releases yet")).toBeInTheDocument()
Expand All @@ -121,7 +133,7 @@ describe("ChangelogPage - States", () => {
})
)

render(<ChangelogPage />)
renderPage()

await waitFor(() => {
const alert = screen.getByRole("alert")
Expand All @@ -137,7 +149,7 @@ describe("ChangelogPage - States", () => {
})
)

render(<ChangelogPage />)
renderPage()

await waitFor(() => {
const alert = screen.getByRole("alert")
Expand All @@ -147,21 +159,69 @@ describe("ChangelogPage - States", () => {
})

it("displays loaded content after successful fetch", async () => {
render(<ChangelogPage />)
renderPage()

await waitFor(() => {
expect(screen.getByText("0.4.0")).toBeInTheDocument()
expect(screen.getByText("Trigger orders on the trade panel.")).toBeInTheDocument()
expect(
screen.getByText("Trigger orders on the trade panel.")
).toBeInTheDocument()
})
})

it("preserves header across all states", async () => {
// Error state
it("bounds the initial page to the ten newest releases", async () => {
server.use(
http.get("/changelog.json", () => HttpResponse.error())
http.get("/changelog.json", () =>
HttpResponse.json({
releases: Array.from({ length: 11 }, (_, index) => ({
version: `1.${10 - index}.0`,
date: "2026-08-24",
yanked: false,
entries: [
{
type: "added",
area: "trade",
text: `Release ${10 - index}.`,
pr: index + 1,
breaking: false,
},
],
})),
})
)
)

renderPage()

expect(await screen.findByText("1.10.0")).toBeInTheDocument()
expect(screen.queryByText("1.0.0")).not.toBeInTheDocument()
})

it("copies an absolute release permalink", async () => {
const user = userEvent.setup()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
})
renderPage()

await user.click(
await screen.findByRole("button", {
name: "Copy permalink for version 0.4.0",
})
)

const { rerender } = render(<ChangelogPage />)
expect(writeText).toHaveBeenCalledWith(
"http://localhost:3000/changelog#v0-4-0"
)
})

it("preserves header across all states", async () => {
// Error state
server.use(http.get("/changelog.json", () => HttpResponse.error()))

const { unmount } = renderPage()
await waitFor(() => screen.getByRole("alert"))

let heading = screen.getByRole("heading", { name: /changelog/i })
Expand All @@ -170,27 +230,26 @@ describe("ChangelogPage - States", () => {
// Empty state
server.resetHandlers()
server.use(
http.get("/changelog.json", () =>
HttpResponse.json({ releases: [] })
)
http.get("/changelog.json", () => HttpResponse.json({ releases: [] }))
)

rerender(<ChangelogPage />)
unmount()
renderPage()
await waitFor(() => screen.getByText("No releases yet"))

heading = screen.getByRole("heading", { name: /changelog/i })
expect(heading).toBeInTheDocument()
})

it("skeleton has aria attributes for accessibility", async () => {
it("skeleton has aria attributes for accessibility", () => {
server.use(
http.get("/changelog.json", async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
return HttpResponse.json(mockChangelogData)
})
)

render(<ChangelogPage />)
renderPage()

const status = screen.getByRole("status")
expect(status).toHaveAttribute("aria-live", "polite")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest"
import { render, screen } from "@testing-library/react"
import { typeToVariant } from "../utils"
import { ChangelogCategoryBadge } from "./ChangelogCategoryBadge"

describe("changelog category badges", () => {
it.each([
["added", "success"],
["changed", "info"],
["fixed", "info-subtle"],
["deprecated", "muted"],
["removed", "danger-subtle"],
["security", "warning"],
] as const)("maps %s to %s", (type, variant) => {
expect(typeToVariant(type)).toBe(variant)
})

it("composes the breaking marker with a category", () => {
render(<ChangelogCategoryBadge type="added" breaking />)

expect(screen.getByText("Added")).toBeInTheDocument()
expect(screen.getByText("Breaking")).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { StatusBadge } from "@workspace/ui/components/status-badge"
import { typeLabel, typeToVariant } from "../utils"

interface ChangelogCategoryBadgeProps {
type: string
breaking?: boolean
}

export function ChangelogCategoryBadge({
type,
breaking = false,
}: ChangelogCategoryBadgeProps) {
return (
<span className="inline-flex flex-wrap items-center gap-1.5">
<StatusBadge variant={typeToVariant(type)}>{typeLabel(type)}</StatusBadge>
{breaking && <StatusBadge variant="danger-subtle">Breaking</StatusBadge>}
</span>
)
}
Loading