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
24 changes: 24 additions & 0 deletions frontend/src/pages/editArticleView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
featured_image?: string
featured_image_alt?: string
breaking_news?: boolean
is_featured?: boolean
categories?: Array<{
name?: string
slug?: string
Expand Down Expand Up @@ -63,6 +64,7 @@
photo_url: string
photo_alt: string
breaking_news: boolean
is_featured: boolean
categories: string[]
tags: string[]
authors: number[]
Expand Down Expand Up @@ -263,6 +265,7 @@
const [photoURL, setPhotoURL] = useState("")
const [photoAlt, setPhotoAlt] = useState("")
const [breakingNews, setBreakingNews] = useState(false)
const [isFeatured, setIsFeatured] = useState(false)
const [selectedCategorySlugs, setSelectedCategorySlugs] = useState<string[]>([])
const [sectionSearch, setSectionSearch] = useState("")
const [legacyCategoryTitlesBySlug, setLegacyCategoryTitlesBySlug] = useState<Record<string, string>>({})
Expand Down Expand Up @@ -300,6 +303,7 @@
photoURL,
photoAlt,
breakingNews,
isFeatured,
selectedCategorySlugs,
seoTags,
seoTagDraft,
Expand All @@ -319,6 +323,7 @@
photoURL,
photoAlt,
breakingNews,
isFeatured,
selectedCategorySlugs,
seoTags,
seoTagDraft,
Expand Down Expand Up @@ -394,6 +399,7 @@
setPhotoURL(payload.featured_image ?? "")
setPhotoAlt(payload.featured_image_alt ?? "")
setBreakingNews(Boolean(payload.breaking_news))
setIsFeatured(Boolean(payload.is_featured))
const legacyCategories: Record<string, string> = {}
const categorySlugs = (payload.categories ?? [])
.map((category) => {
Expand Down Expand Up @@ -661,6 +667,7 @@
photo_url: photoURL.trim(),
photo_alt: photoAlt.trim(),
breaking_news: breakingNews,
is_featured: isFeatured,
categories,
tags: seoTagsToSave,
authors: selectedAuthorIds,
Expand Down Expand Up @@ -706,6 +713,7 @@
photo_url: photoURL.trim(),
photo_alt: photoAlt.trim(),
breaking_news: breakingNews,
is_featured: isFeatured,
categories,
tags: seoTagsToSave,
authors: selectedAuthorIds,
Expand Down Expand Up @@ -778,7 +786,7 @@
}, AUTOSAVE_DELAY_MS)

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

Check warning on line 789 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 @@ -1370,6 +1378,22 @@
<span className="font-medium text-foreground">Breaking news</span>
</label>

<label className="flex items-start gap-3 rounded-lg border border-border bg-background px-3 py-3 text-sm">
<input
checked={isFeatured}
className="mt-0.5 h-4 w-4 rounded border-border"
onChange={(e) => setIsFeatured(e.target.checked)}
type="checkbox"
/>
<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.
</span>
</span>
</label>

<div className={labelClass}>
<span className={labelTextClass}>Sections</span>
<div className="relative">
Expand Down
61 changes: 61 additions & 0 deletions server/internal/database/featured_article.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package database

import (
"context"
"database/sql"

"server/internal/models"
)

// The featured article is the one an editor has pinned to the big lead card in
// the middle of the homepage. It is stored in the legacy `priority` column and
// is exclusive: exactly zero or one article carries it at a time, enforced on
// write by ClearFeaturedExcept.

const featuredArticleConditions = "WHERE `priority` = 1 AND `pub_date` IS NOT NULL AND `pub_date` <= UTC_TIMESTAMP() AND `archived_at` IS NULL"

// GetFeaturedArticle returns the featured article, or nil when nothing is
// featured. Unpublished, scheduled and archived rows are skipped so an article
// cannot reach the homepage through the flag alone -- an editor who features a
// draft and forgets to publish it gets the normal newest-first lead, not a
// headline the public should not see yet.
//
// The ORDER BY is a defensive tiebreak: exclusivity is enforced on write, but a
// direct DB edit or an ETL reseed could leave two rows flagged, and the homepage
// must still resolve to one article rather than picking arbitrarily.
func GetFeaturedArticle(ctx context.Context, conn *sql.DB) (*models.Article, error) {
query := searchSelectColumns + featuredArticleConditions + " ORDER BY `pub_date` DESC, `id` DESC LIMIT 1"
rows, err := conn.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()

articles, err := CollectArticles(rows)
if err != nil {
return nil, err
}
if len(articles) == 0 {
return nil, nil
}
return &articles[0], nil
}

// ClearFeaturedExcept unfeatures every article other than the given slug. Call
// it after the write that features an article, never before: if the ordering
// were reversed and the main update then failed, the site would be left with no
// featured article at all instead of the one it had.
func ClearFeaturedExcept(ctx context.Context, conn *sql.DB, slug string) error {
_, err := conn.ExecContext(ctx,
"UPDATE `articles` SET `priority` = 0 WHERE `priority` = 1 AND `slug` <> ?", slug)
return err
}

// ClearFeaturedExceptID is ClearFeaturedExcept for the create path, where the
// slug may have been generated from the title rather than supplied by the
// caller and the insert's id is the only handle on the new row.
func ClearFeaturedExceptID(ctx context.Context, conn *sql.DB, id int64) error {
_, err := conn.ExecContext(ctx,
"UPDATE `articles` SET `priority` = 0 WHERE `priority` = 1 AND `id` <> ?", id)
return err
}
46 changes: 37 additions & 9 deletions server/internal/database/taxonomy_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,25 @@ import (
"context"
"database/sql"
"os"
"strings"
"testing"

_ "github.com/go-sql-driver/mysql"
)

// A section the seed has no defaults for, for the "keeps no aliases" case.
// Which slugs are unseeded changes as orphaned categories get filed -- news
// gained "Paid Post" -- so the choice is guarded by requireUnseeded rather than
// assumed, or the assertion would quietly start testing nothing.
const unseededSlug = "photo"

func requireUnseeded(t *testing.T, slug string) {
t.Helper()
if _, seeded := defaultCategoryAliases[slug]; seeded {
t.Fatalf("%q now has seeded aliases; pick another slug for the no-defaults case", slug)
}
}

// These need a real MariaDB, because the whole point is the column, the seed
// and the cache reload agreeing with each other. They skip unless CMS_TEST_DSN
// is set, so CI without a database stays green.
Expand Down Expand Up @@ -65,33 +79,47 @@ func TestTaxonomyAliasSeedAndCacheRoundTrip(t *testing.T) {
if err := EnsureTaxonomyTable(ctx, conn); err != nil {
t.Fatalf("ensure taxonomy table: %v", err)
}
requireUnseeded(t, unseededSlug)
insertTaxonomyRow(t, conn, 1, "section", "entertainment", "Entertainment")
insertTaxonomyRow(t, conn, 2, "section", "news", "News")
insertTaxonomyRow(t, conn, 2, "section", unseededSlug, "Photo")

if err := EnsureTaxonomyTable(ctx, conn); err != nil {
t.Fatalf("second ensure: %v", err)
}

// entertainment must have picked up its seeded default...
// entertainment must have picked up its seeded defaults. Asserted against
// defaultCategoryAliases rather than a literal list: the map grows every
// time another orphaned category is filed, and a test that pins the
// contents fails on the filing rather than on anything being broken.
patterns := CategoryMatchPatterns("entertainment")
if !containsPattern(patterns, `%"arts & entertainment"%`) {
t.Errorf("entertainment patterns %v missing the seeded alias", patterns)
for _, alias := range defaultCategoryAliases["entertainment"] {
want := `%"` + strings.ToLower(alias) + `"%`
if !containsPattern(patterns, want) {
t.Errorf("entertainment patterns %v missing the seeded alias %s", patterns, want)
}
}
// ...and it must not be HTML-escaped in the column, or it would never
// match the articles it names.
// The aliases must not be HTML-escaped in the column, or one holding an
// ampersand would never match the articles it names.
var stored string
if err := conn.QueryRowContext(ctx,
"SELECT category_aliases FROM site_taxonomy WHERE slug = 'entertainment'",
).Scan(&stored); err != nil {
t.Fatalf("read stored aliases: %v", err)
}
if want := `["Arts & Entertainment"]`; stored != want {
want, err := MarshalCategoryJSON(defaultCategoryAliases["entertainment"])
if err != nil {
t.Fatalf("marshal expected aliases: %v", err)
}
if stored != want {
t.Errorf("stored aliases = %s, want %s", stored, want)
}
if strings.Contains(stored, "\\u0026") {
t.Errorf("stored aliases are HTML-escaped: %s", stored)
}

// A slug with no default keeps no aliases.
if got := CategoryMatchPatterns("news"); len(got) != 1 {
t.Errorf("news patterns = %v, want just its own", got)
if got := CategoryMatchPatterns(unseededSlug); len(got) != 1 {
t.Errorf("%s patterns = %v, want just its own", unseededSlug, got)
}
}

Expand Down
Loading
Loading