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
7 changes: 4 additions & 3 deletions frontend/src/pages/editArticleView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -935,7 +935,7 @@
}, AUTOSAVE_DELAY_MS)

return () => window.clearTimeout(timer)
}, [articleApiPath, articleID, articleSnapshot, isAutoSaving, isLoading, isNew, isSaving, lockedBy, selectedAuthorIds, selectedCategorySlugs])

Check warning on line 938 in frontend/src/pages/editArticleView.tsx

View workflow job for this annotation

GitHub Actions / frontend

React Hook useEffect has a missing dependency: 'saveArticle'. Either include it or remove the dependency array

const inputClass ="w-full px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary transition"
const selectClass = "w-full px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary transition"
Expand Down Expand Up @@ -1548,7 +1548,8 @@
<span className="flex flex-col gap-0.5">
<span className="font-medium text-foreground">Breaking news</span>
<span className="text-[11px] text-muted-foreground">
Raises the homepage banner with this headline once the article publishes.
Adds this headline to the scrolling homepage banner once the article publishes. Up to three run at
once, newest first.
</span>
</span>
</label>
Expand All @@ -1563,8 +1564,8 @@
<span className="flex flex-col gap-0.5">
<span className="font-medium text-foreground">Featured article</span>
<span className="text-[11px] text-muted-foreground">
Runs as the big lead story on the homepage. Only one article can be
featured, so this replaces the current one.
Pins this story to the top of the homepage. Up to three can be pinned; the newest leads, and pinning
this one leaves the others up.
</span>
</span>
</label>
Expand Down
59 changes: 46 additions & 13 deletions frontend/src/pages/settingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,18 @@ const emptyCarouselSlide = (): CarouselSlide => ({
// Mirrors server/internal/database/settings.go; the API clamps anything larger.
const MAX_BREAKING_WINDOW_HOURS = 24 * 7

type BreakingNewsItem = {
text?: string
article_slug?: string
}

type BreakingNewsResponse = {
enabled?: boolean
text?: string
source?: string
article_slug?: string
article_title?: string
items?: BreakingNewsItem[]
manual?: { enabled?: boolean; text?: string }
window_hours?: number
}
Expand All @@ -53,6 +59,7 @@ type BreakingNewsLive = {
source: string
text: string
articleSlug?: string
items: { text: string; articleSlug?: string }[]
}

// The endpoint returns the resolved banner at the top level and the hand-typed
Expand All @@ -69,6 +76,13 @@ function readBreakingNews(body: BreakingNewsResponse) {
source: String(body.source ?? "none"),
text: String(body.text ?? ""),
articleSlug: body.article_slug,
// The banner scrolls every flagged story, so the screen has to show all
// of them: an editor looking for why their headline is not up needs to
// see the others that are.
items: (body.items ?? []).map((item) => ({
text: String(item.text ?? ""),
articleSlug: item.article_slug,
})),
} satisfies BreakingNewsLive,
// 0 means no time limit, which is the default.
window: String(body.window_hours ?? 0),
Expand Down Expand Up @@ -122,7 +136,7 @@ export default function SettingsPage() {
const [breakingMessage, setBreakingMessage] = useState<string | null>(null)
// What the public site is actually showing: an article can override the
// fields below.
const [breakingLive, setBreakingLive] = useState<BreakingNewsLive>({ source: "none", text: "" })
const [breakingLive, setBreakingLive] = useState<BreakingNewsLive>({ source: "none", text: "", items: [] })
const [carouselSlides, setCarouselSlides] = useState<CarouselSlide[]>([])
const [carouselSaved, setCarouselSaved] = useState<CarouselSlide[]>([])
const [carouselSaving, setCarouselSaving] = useState(false)
Expand Down Expand Up @@ -694,23 +708,42 @@ export default function SettingsPage() {
storageKey="breaking-news"
dirty={breakingDirty}
summary={
breakingLive.source === "article" ? "Live from an article" : breakingLive.source === "manual" ? "Enabled" : "Off"
breakingLive.source === "article"
? breakingLive.items.length > 1
? `Live from ${breakingLive.items.length} articles`
: "Live from an article"
: breakingLive.source === "manual"
? "Enabled"
: "Off"
}
description="Banner across the top of the public homepage. Editors can also raise one by ticking Breaking news on an article."
description="Banner across the top of the public homepage. Editors can also raise one by ticking Breaking news on an article; the banner scrolls through all of them."
>
{breakingLive.source === "article" && (
<div className="rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-foreground">
<span className="font-medium">An article is driving the banner:</span> &ldquo;{breakingLive.text}&rdquo;
<span className="font-medium">
{breakingLive.items.length > 1 ? "Articles on the banner:" : "An article is driving the banner:"}
</span>
<ol className="mt-1 flex flex-col gap-1">
{breakingLive.items.map((item, index) => (
<li key={`${item.articleSlug ?? "manual"}-${index}`}>
&ldquo;{item.text}&rdquo;
{item.articleSlug && (
<>
{" "}
<Link
to={`/articles/${encodeURIComponent(item.articleSlug)}/edit`}
className="text-xs underline text-muted-foreground"
>
edit
</Link>
</>
)}
</li>
))}
</ol>
<div className="mt-1 text-xs text-muted-foreground">
It overrides the banner below. Untick &ldquo;Breaking news&rdquo; on{" "}
{breakingLive.articleSlug ? (
<Link to={`/articles/${encodeURIComponent(breakingLive.articleSlug)}/edit`} className="underline">
that article
</Link>
) : (
"that article"
)}{" "}
to take it down.
These override the banner below, newest first. Untick &ldquo;Breaking news&rdquo; on an article to take it
off.
</div>
</div>
)}
Expand Down
25 changes: 24 additions & 1 deletion server/docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -4981,6 +4981,17 @@ const docTemplate = `{
}
}
},
"models.BreakingNewsItem": {
"type": "object",
"properties": {
"article_slug": {
"type": "string"
},
"text": {
"type": "string"
}
}
},
"models.BreakingNewsSettings": {
"type": "object",
"properties": {
Expand All @@ -4990,6 +5001,12 @@ const docTemplate = `{
"enabled": {
"type": "boolean"
},
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/models.BreakingNewsItem"
}
},
"text": {
"type": "string"
}
Expand Down Expand Up @@ -5021,6 +5038,12 @@ const docTemplate = `{
"enabled": {
"type": "boolean"
},
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/models.BreakingNewsItem"
}
},
"manual": {
"$ref": "#/definitions/models.BreakingNewsSettings"
},
Expand All @@ -5031,7 +5054,7 @@ const docTemplate = `{
"type": "string"
},
"window_hours": {
"description": "WindowHours is 0 when a flagged article holds the banner indefinitely,\nwhich is the default; an admin sets a limit in Settings to opt in.",
"description": "WindowHours is 0 (the default) when a flagged article holds the banner\nindefinitely.",
"type": "integer"
}
}
Expand Down
25 changes: 24 additions & 1 deletion server/docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -4978,6 +4978,17 @@
}
}
},
"models.BreakingNewsItem": {
"type": "object",
"properties": {
"article_slug": {
"type": "string"
},
"text": {
"type": "string"
}
}
},
"models.BreakingNewsSettings": {
"type": "object",
"properties": {
Expand All @@ -4987,6 +4998,12 @@
"enabled": {
"type": "boolean"
},
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/models.BreakingNewsItem"
}
},
"text": {
"type": "string"
}
Expand Down Expand Up @@ -5018,6 +5035,12 @@
"enabled": {
"type": "boolean"
},
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/models.BreakingNewsItem"
}
},
"manual": {
"$ref": "#/definitions/models.BreakingNewsSettings"
},
Expand All @@ -5028,7 +5051,7 @@
"type": "string"
},
"window_hours": {
"description": "WindowHours is 0 when a flagged article holds the banner indefinitely,\nwhich is the default; an admin sets a limit in Settings to opt in.",
"description": "WindowHours is 0 (the default) when a flagged article holds the banner\nindefinitely.",
"type": "integer"
}
}
Expand Down
19 changes: 17 additions & 2 deletions server/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -475,12 +475,23 @@ definitions:
pagination:
$ref: '#/definitions/models.Pagination'
type: object
models.BreakingNewsItem:
properties:
article_slug:
type: string
text:
type: string
type: object
models.BreakingNewsSettings:
properties:
article_slug:
type: string
enabled:
type: boolean
items:
items:
$ref: '#/definitions/models.BreakingNewsItem'
type: array
text:
type: string
type: object
Expand All @@ -501,6 +512,10 @@ definitions:
type: string
enabled:
type: boolean
items:
items:
$ref: '#/definitions/models.BreakingNewsItem'
type: array
manual:
$ref: '#/definitions/models.BreakingNewsSettings'
source:
Expand All @@ -509,8 +524,8 @@ definitions:
type: string
window_hours:
description: |-
WindowHours is 0 when a flagged article holds the banner indefinitely,
which is the default; an admin sets a limit in Settings to opt in.
WindowHours is 0 (the default) when a flagged article holds the banner
indefinitely.
type: integer
type: object
models.CategorySummary:
Expand Down
65 changes: 62 additions & 3 deletions server/internal/database/breaking_news_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package database
import (
"context"
"database/sql"
"fmt"
"os"
"testing"

Expand Down Expand Up @@ -187,7 +188,10 @@ func TestBreakingNewsState_ScheduledArticleWaitsForItsPubDate(t *testing.T) {
}
}

func TestBreakingNewsState_NewestFlaggedArticleWins(t *testing.T) {
// The banner carries every flagged story, newest first, and Text still names
// the newest so a reader that only knows the old single-story fields shows the
// same headline it always would have.
func TestBreakingNewsState_CarriesEveryFlaggedArticleNewestFirst(t *testing.T) {
conn := breakingNewsTestDB(t)
ctx := context.Background()

Expand All @@ -198,8 +202,63 @@ func TestBreakingNewsState_NewestFlaggedArticleWins(t *testing.T) {
if err != nil {
t.Fatalf("get state: %v", err)
}
if state.Text != "Newer breaking story" {
t.Errorf("text = %q, want the newer story", state.Text)
if state.Text != "Newer breaking story" || state.ArticleSlug != "newer" {
t.Errorf("single-story fields = %q/%q, want the newer story", state.Text, state.ArticleSlug)
}
if len(state.Items) != 2 {
t.Fatalf("items = %+v, want both stories", state.Items)
}
if state.Items[0].Text != "Newer breaking story" || state.Items[0].ArticleSlug != "newer" {
t.Errorf("items[0] = %+v, want the newer story", state.Items[0])
}
if state.Items[1].Text != "Older breaking story" || state.Items[1].ArticleSlug != "older" {
t.Errorf("items[1] = %+v, want the older story", state.Items[1])
}
}

// The banner scrolls, so a reader waits through everything ahead of the story
// they came for. The cap is what bounds that wait.
func TestBreakingNewsState_CapsTheNumberOfStories(t *testing.T) {
conn := breakingNewsTestDB(t)
ctx := context.Background()

for i, slug := range []string{"first", "second", "third", "fourth"} {
insertArticle(t, conn, slug, "Story "+slug, true,
fmt.Sprintf("UTC_TIMESTAMP() - INTERVAL %d HOUR", 10-i), "NULL")
}

state, err := GetBreakingNewsState(ctx, conn)
if err != nil {
t.Fatalf("get state: %v", err)
}
if len(state.Items) != maxBreakingNewsItems {
t.Fatalf("items = %d, want the cap of %d", len(state.Items), maxBreakingNewsItems)
}
// Newest first, so the one that falls off the end is the oldest.
if state.Items[0].ArticleSlug != "fourth" || state.Items[2].ArticleSlug != "second" {
t.Errorf("items = %+v, want the three newest stories", state.Items)
}
}

// The manual banner is one story like any other, so a reader can treat Items as
// the whole banner rather than special-casing the hand-typed case.
func TestBreakingNewsState_ManualBannerIsAnItemToo(t *testing.T) {
conn := breakingNewsTestDB(t)
ctx := context.Background()

if err := SetBreakingNews(ctx, conn, models.BreakingNewsSettings{Enabled: true, Text: "Campus closed"}, 0); err != nil {
t.Fatalf("set manual banner: %v", err)
}

state, err := GetBreakingNewsState(ctx, conn)
if err != nil {
t.Fatalf("get state: %v", err)
}
if len(state.Items) != 1 || state.Items[0].Text != "Campus closed" {
t.Fatalf("items = %+v, want just the manual banner", state.Items)
}
if state.Items[0].ArticleSlug != "" {
t.Errorf("manual item carried a slug: %q", state.Items[0].ArticleSlug)
}
}

Expand Down
Loading
Loading