feat: improve Wagtail admin editor experience for articles #40

Merged
mark merged 6 commits from feature/improve-editor-experience into main 2026-03-03 20:46:31 +00:00
14 changed files with 623 additions and 18 deletions

View File

@@ -118,6 +118,7 @@ jobs:
-e DEFAULT_FROM_EMAIL=hello@nohypeai.com \ -e DEFAULT_FROM_EMAIL=hello@nohypeai.com \
-e NEWSLETTER_PROVIDER=buttondown \ -e NEWSLETTER_PROVIDER=buttondown \
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \ -e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
-e E2E_MODE=1 \
"$CI_IMAGE" \ "$CI_IMAGE" \
sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000" sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000"
for i in $(seq 1 40); do for i in $(seq 1 40); do
@@ -188,6 +189,7 @@ jobs:
-e DEFAULT_FROM_EMAIL=hello@nohypeai.com \ -e DEFAULT_FROM_EMAIL=hello@nohypeai.com \
-e NEWSLETTER_PROVIDER=buttondown \ -e NEWSLETTER_PROVIDER=buttondown \
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \ -e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
-e E2E_MODE=1 \
"$CI_IMAGE" \ "$CI_IMAGE" \
sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000" sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000"
for i in $(seq 1 40); do for i in $(seq 1 40); do

View File

@@ -16,7 +16,7 @@ class AllArticlesFeed(Feed):
return None return None
def items(self): def items(self):
return ArticlePage.objects.live().order_by("-first_published_at")[:20] return ArticlePage.objects.live().order_by("-published_date")[:20]
def item_title(self, item: ArticlePage): def item_title(self, item: ArticlePage):
return item.title return item.title
@@ -25,7 +25,7 @@ class AllArticlesFeed(Feed):
return item.summary return item.summary
def item_pubdate(self, item: ArticlePage): def item_pubdate(self, item: ArticlePage):
return item.first_published_at return item.published_date or item.first_published_at
def item_author_name(self, item: ArticlePage): def item_author_name(self, item: ArticlePage):
return item.author.name return item.author.name
@@ -47,7 +47,7 @@ class TagArticlesFeed(AllArticlesFeed):
return f"No Hype AI — {obj.name}" return f"No Hype AI — {obj.name}"
def items(self, obj): def items(self, obj):
return ArticlePage.objects.live().filter(tags=obj).order_by("-first_published_at")[:20] return ArticlePage.objects.live().filter(tags=obj).order_by("-published_date")[:20]
class CategoryArticlesFeed(AllArticlesFeed): class CategoryArticlesFeed(AllArticlesFeed):
@@ -59,4 +59,4 @@ class CategoryArticlesFeed(AllArticlesFeed):
return f"No Hype AI — {obj.name}" return f"No Hype AI — {obj.name}"
def items(self, obj): def items(self, obj):
return ArticlePage.objects.live().filter(category=obj).order_by("-first_published_at")[:20] return ArticlePage.objects.live().filter(category=obj).order_by("-published_date")[:20]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.11 on 2026-03-03 13:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0002_category_articlepage_category'),
]
operations = [
migrations.AddField(
model_name='articlepage',
name='published_date',
field=models.DateTimeField(blank=True, help_text='Display date for this article. Auto-set on first publish if left blank.', null=True),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.2.11 on 2026-03-03 13:59
from django.db import migrations
def backfill_published_date(apps, schema_editor):
schema_editor.execute(
"UPDATE blog_articlepage SET published_date = p.first_published_at "
"FROM wagtailcore_page p "
"WHERE blog_articlepage.page_ptr_id = p.id "
"AND blog_articlepage.published_date IS NULL "
"AND p.first_published_at IS NOT NULL"
)
class Migration(migrations.Migration):
dependencies = [
('blog', '0003_add_published_date'),
]
operations = [
migrations.RunPython(backfill_published_date, migrations.RunPython.noop),
]

View File

@@ -11,10 +11,11 @@ from django.shortcuts import get_object_or_404
from modelcluster.contrib.taggit import ClusterTaggableManager from modelcluster.contrib.taggit import ClusterTaggableManager
from modelcluster.fields import ParentalKey from modelcluster.fields import ParentalKey
from taggit.models import Tag, TaggedItemBase from taggit.models import Tag, TaggedItemBase
from wagtail.admin.panels import FieldPanel, PageChooserPanel from wagtail.admin.panels import FieldPanel, ObjectList, PageChooserPanel, TabbedInterface
from wagtail.contrib.routable_page.models import RoutablePageMixin, route from wagtail.contrib.routable_page.models import RoutablePageMixin, route
from wagtail.fields import RichTextField, StreamField from wagtail.fields import RichTextField, StreamField
from wagtail.models import Page from wagtail.models import Page
from wagtail.search import index
from wagtailseo.models import SeoMixin from wagtailseo.models import SeoMixin
from apps.blog.blocks import ARTICLE_BODY_BLOCKS from apps.blog.blocks import ARTICLE_BODY_BLOCKS
@@ -38,7 +39,7 @@ class HomePage(Page):
.public() .public()
.select_related("author", "category") .select_related("author", "category")
.prefetch_related("tags__metadata") .prefetch_related("tags__metadata")
.order_by("-first_published_at") .order_by("-published_date")
) )
articles = list(articles_qs[:5]) articles = list(articles_qs[:5])
ctx["featured_article"] = self.featured_article ctx["featured_article"] = self.featured_article
@@ -64,7 +65,7 @@ class ArticleIndexPage(RoutablePageMixin, Page):
.live() .live()
.select_related("author", "category") .select_related("author", "category")
.prefetch_related("tags__metadata") .prefetch_related("tags__metadata")
.order_by("-first_published_at") .order_by("-published_date")
) )
def get_category_url(self, category): def get_category_url(self, category):
@@ -191,23 +192,50 @@ class ArticlePage(SeoMixin, Page):
tags = ClusterTaggableManager(through="blog.ArticleTag", blank=True) tags = ClusterTaggableManager(through="blog.ArticleTag", blank=True)
read_time_mins = models.PositiveIntegerField(editable=False, default=1) read_time_mins = models.PositiveIntegerField(editable=False, default=1)
comments_enabled = models.BooleanField(default=True) comments_enabled = models.BooleanField(default=True)
published_date = models.DateTimeField(
null=True,
blank=True,
help_text="Display date for this article. Auto-set on first publish if left blank.",
)
parent_page_types = ["blog.ArticleIndexPage"] parent_page_types = ["blog.ArticleIndexPage"]
subpage_types: list[str] = [] subpage_types: list[str] = []
content_panels = Page.content_panels + [ content_panels = [
FieldPanel("category"), FieldPanel("title"),
FieldPanel("author"),
FieldPanel("hero_image"),
FieldPanel("summary"), FieldPanel("summary"),
FieldPanel("body"), FieldPanel("body"),
]
metadata_panels = [
FieldPanel("category"),
FieldPanel("author"),
FieldPanel("tags"), FieldPanel("tags"),
FieldPanel("hero_image"),
FieldPanel("comments_enabled"), FieldPanel("comments_enabled"),
] ]
promote_panels = Page.promote_panels + SeoMixin.seo_panels publishing_panels = [
FieldPanel("published_date"),
FieldPanel("go_live_at"),
FieldPanel("expire_at"),
]
search_fields = Page.search_fields edit_handler = TabbedInterface(
[
ObjectList(content_panels, heading="Content"),
ObjectList(metadata_panels, heading="Metadata"),
ObjectList(publishing_panels, heading="Publishing"),
ObjectList(
Page.promote_panels + SeoMixin.seo_panels,
heading="SEO",
),
]
)
search_fields = Page.search_fields + [
index.SearchField("summary"),
]
def save(self, *args: Any, **kwargs: Any) -> None: def save(self, *args: Any, **kwargs: Any) -> None:
if not self.category_id: if not self.category_id:
@@ -215,6 +243,8 @@ class ArticlePage(SeoMixin, Page):
slug="general", slug="general",
defaults={"name": "General", "description": "General articles", "colour": "neutral"}, defaults={"name": "General", "description": "General articles", "colour": "neutral"},
) )
if not self.published_date and self.first_published_at:
self.published_date = self.first_published_at
self.read_time_mins = self._compute_read_time() self.read_time_mins = self._compute_read_time()
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
@@ -239,14 +269,14 @@ class ArticlePage(SeoMixin, Page):
.filter(tags__in=tag_ids) .filter(tags__in=tag_ids)
.exclude(pk=self.pk) .exclude(pk=self.pk)
.distinct() .distinct()
.order_by("-first_published_at")[:count] .order_by("-published_date")[:count]
) )
if len(related) < count: if len(related) < count:
exclude_ids = [a.pk for a in related] + [self.pk] exclude_ids = [a.pk for a in related] + [self.pk]
fallback = list( fallback = list(
ArticlePage.objects.live() ArticlePage.objects.live()
.exclude(pk__in=exclude_ids) .exclude(pk__in=exclude_ids)
.order_by("-first_published_at")[: count - len(related)] .order_by("-published_date")[: count - len(related)]
) )
return related + fallback return related + fallback
return related return related

View File

@@ -37,6 +37,7 @@ class ArticlePageFactory(wagtail_factories.PageFactory):
summary = "Summary" summary = "Summary"
body = [("rich_text", "<p>Hello world</p>")] body = [("rich_text", "<p>Hello world</p>")]
first_published_at = factory.LazyFunction(timezone.now) first_published_at = factory.LazyFunction(timezone.now)
published_date = factory.LazyFunction(timezone.now)
class LegalIndexPageFactory(wagtail_factories.PageFactory): class LegalIndexPageFactory(wagtail_factories.PageFactory):

View File

@@ -0,0 +1,275 @@
from datetime import timedelta
import pytest
from django.test import override_settings
from django.utils import timezone
from apps.blog.models import ArticleIndexPage, ArticlePage
from apps.blog.tests.factories import AuthorFactory
@pytest.mark.django_db
def test_published_date_auto_set_on_first_publish(home_page):
"""published_date should be auto-populated from first_published_at on first publish."""
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(
title="Auto Date",
slug="auto-date",
author=author,
summary="summary",
body=[("rich_text", "<p>body</p>")],
)
index.add_child(instance=article)
article.save_revision().publish()
article.refresh_from_db()
assert article.published_date is not None
assert article.published_date == article.first_published_at
@pytest.mark.django_db
def test_published_date_preserved_when_explicitly_set(home_page):
"""An explicitly set published_date should not be overwritten on save."""
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
custom_date = timezone.now() - timedelta(days=30)
article = ArticlePage(
title="Custom Date",
slug="custom-date",
author=author,
summary="summary",
body=[("rich_text", "<p>body</p>")],
published_date=custom_date,
)
index.add_child(instance=article)
article.save_revision().publish()
article.refresh_from_db()
assert article.published_date == custom_date
@pytest.mark.django_db
def test_homepage_orders_articles_by_published_date(home_page):
"""HomePage context should list articles ordered by -published_date."""
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
older = ArticlePage(
title="Older",
slug="older",
author=author,
summary="s",
body=[("rich_text", "<p>body</p>")],
published_date=timezone.now() - timedelta(days=10),
)
index.add_child(instance=older)
older.save_revision().publish()
newer = ArticlePage(
title="Newer",
slug="newer",
author=author,
summary="s",
body=[("rich_text", "<p>body</p>")],
published_date=timezone.now(),
)
index.add_child(instance=newer)
newer.save_revision().publish()
ctx = home_page.get_context(type("Req", (), {"GET": {}})())
titles = [a.title for a in ctx["latest_articles"]]
assert titles.index("Newer") < titles.index("Older")
@pytest.mark.django_db
def test_article_index_orders_by_published_date(home_page, rf):
"""ArticleIndexPage.get_articles should order by -published_date."""
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
old = ArticlePage(
title="Old",
slug="old",
author=author,
summary="s",
body=[("rich_text", "<p>b</p>")],
published_date=timezone.now() - timedelta(days=5),
)
index.add_child(instance=old)
old.save_revision().publish()
new = ArticlePage(
title="New",
slug="new",
author=author,
summary="s",
body=[("rich_text", "<p>b</p>")],
published_date=timezone.now(),
)
index.add_child(instance=new)
new.save_revision().publish()
articles = list(index.get_articles())
assert articles[0].title == "New"
assert articles[1].title == "Old"
@pytest.mark.django_db
def test_feed_uses_published_date(article_page):
"""RSS feed item_pubdate should use published_date."""
from apps.blog.feeds import AllArticlesFeed
feed = AllArticlesFeed()
assert feed.item_pubdate(article_page) == article_page.published_date
@pytest.mark.django_db
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
def test_articles_listing_viewset_loads(client, django_user_model, home_page):
"""The Articles PageListingViewSet index page should load."""
admin = django_user_model.objects.create_superuser(
username="admin", email="admin@example.com", password="admin-pass"
)
client.force_login(admin)
response = client.get("/cms/articles/")
assert response.status_code == 200
@pytest.mark.django_db
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
def test_articles_listing_shows_articles(client, django_user_model, home_page):
"""The Articles listing should show existing articles."""
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(
title="Listed Article",
slug="listed-article",
author=author,
summary="summary",
body=[("rich_text", "<p>body</p>")],
)
index.add_child(instance=article)
article.save_revision().publish()
admin = django_user_model.objects.create_superuser(
username="admin", email="admin@example.com", password="admin-pass"
)
client.force_login(admin)
response = client.get("/cms/articles/")
assert response.status_code == 200
assert "Listed Article" in response.content.decode()
@pytest.mark.django_db
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
def test_dashboard_panel_renders(client, django_user_model, home_page):
"""The Wagtail admin dashboard should include the articles summary panel."""
admin = django_user_model.objects.create_superuser(
username="admin", email="admin@example.com", password="admin-pass"
)
client.force_login(admin)
response = client.get("/cms/")
assert response.status_code == 200
content = response.content.decode()
assert "Articles overview" in content
@pytest.mark.django_db
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
def test_dashboard_panel_shows_drafts(client, django_user_model, home_page):
"""Dashboard panel should list draft articles."""
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
draft = ArticlePage(
title="My Draft Post",
slug="draft-post",
author=author,
summary="summary",
body=[("rich_text", "<p>body</p>")],
)
index.add_child(instance=draft)
draft.save_revision() # save revision but don't publish
admin = django_user_model.objects.create_superuser(
username="admin", email="admin@example.com", password="admin-pass"
)
client.force_login(admin)
response = client.get("/cms/")
content = response.content.decode()
assert "My Draft Post" in content
@pytest.mark.django_db
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
def test_article_edit_page_has_tabbed_interface(client, django_user_model, home_page):
"""ArticlePage editor should have tabbed panels (Content, Metadata, Publishing, SEO)."""
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(
title="Tabbed",
slug="tabbed",
author=author,
summary="summary",
body=[("rich_text", "<p>body</p>")],
)
index.add_child(instance=article)
article.save_revision().publish()
admin = django_user_model.objects.create_superuser(
username="admin", email="admin@example.com", password="admin-pass"
)
client.force_login(admin)
response = client.get(f"/cms/pages/{article.pk}/edit/")
content = response.content.decode()
assert response.status_code == 200
assert "Content" in content
assert "Metadata" in content
assert "Publishing" in content
assert "SEO" in content
@pytest.mark.django_db
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
def test_articles_listing_has_status_filter(client, django_user_model, home_page):
"""The Articles listing should accept status filter parameter."""
admin = django_user_model.objects.create_superuser(
username="admin", email="admin@example.com", password="admin-pass"
)
client.force_login(admin)
response = client.get("/cms/articles/?status=live")
assert response.status_code == 200
@pytest.mark.django_db
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
def test_articles_listing_has_tag_filter(client, django_user_model, home_page):
"""The Articles listing should accept tag filter parameter."""
admin = django_user_model.objects.create_superuser(
username="admin", email="admin@example.com", password="admin-pass"
)
client.force_login(admin)
response = client.get("/cms/articles/?tag=1")
assert response.status_code == 200
@pytest.mark.django_db
def test_article_listing_default_ordering():
"""ArticlePageListingViewSet should default to -published_date ordering."""
from apps.blog.wagtail_hooks import ArticlePageListingViewSet
assert ArticlePageListingViewSet.default_ordering == "-published_date"
@pytest.mark.django_db
def test_article_search_fields_include_summary():
"""ArticlePage.search_fields should index the summary field."""
field_names = [
f.field_name for f in ArticlePage.search_fields if hasattr(f, "field_name")
]
assert "summary" in field_names

View File

@@ -1,7 +1,22 @@
import django_filters
from taggit.models import Tag
from wagtail import hooks
from wagtail.admin.filters import WagtailFilterSet
from wagtail.admin.ui.components import Component
from wagtail.admin.ui.tables import Column, DateColumn
from wagtail.admin.ui.tables.pages import BulkActionsColumn, PageStatusColumn, PageTitleColumn
from wagtail.admin.viewsets.pages import PageListingViewSet
from wagtail.snippets.models import register_snippet from wagtail.snippets.models import register_snippet
from wagtail.snippets.views.snippets import SnippetViewSet from wagtail.snippets.views.snippets import SnippetViewSet
from apps.blog.models import Category, TagMetadata from apps.authors.models import Author
from apps.blog.models import ArticlePage, Category, TagMetadata
STATUS_CHOICES = [
("live", "Published"),
("draft", "Draft"),
("scheduled", "Scheduled"),
]
class TagMetadataViewSet(SnippetViewSet): class TagMetadataViewSet(SnippetViewSet):
@@ -22,3 +37,95 @@ class CategoryViewSet(SnippetViewSet):
register_snippet(CategoryViewSet) register_snippet(CategoryViewSet)
# ── Articles page listing ────────────────────────────────────────────────────
class StatusFilter(django_filters.ChoiceFilter):
def filter(self, qs, value): # noqa: A003
if value == "live":
return qs.filter(live=True)
if value == "draft":
return qs.filter(live=False, go_live_at__isnull=True)
if value == "scheduled":
return qs.filter(live=False, go_live_at__isnull=False)
return qs
class ArticleFilterSet(WagtailFilterSet):
category = django_filters.ModelChoiceFilter(
queryset=Category.objects.all(),
empty_label="All categories",
)
author = django_filters.ModelChoiceFilter(
queryset=Author.objects.all(),
empty_label="All authors",
)
status = StatusFilter(
choices=STATUS_CHOICES,
empty_label="All statuses",
)
tag = django_filters.ModelChoiceFilter(
field_name="tags",
queryset=Tag.objects.all(),
empty_label="All tags",
)
class Meta:
model = ArticlePage
fields = []
class ArticlePageListingViewSet(PageListingViewSet):
model = ArticlePage
icon = "doc-full"
menu_label = "Articles"
menu_order = 200
add_to_admin_menu = True
name = "articles"
columns = [
BulkActionsColumn("bulk_actions"),
PageTitleColumn("title", classname="title"),
Column("author", label="Author", sort_key="author__name"),
Column("category", label="Category"),
DateColumn("published_date", label="Published", sort_key="published_date"),
PageStatusColumn("status", sort_key="live"),
]
filterset_class = ArticleFilterSet
default_ordering = "-published_date"
@hooks.register("register_admin_viewset")
def register_article_listing():
return ArticlePageListingViewSet("articles")
# ── Dashboard panel ──────────────────────────────────────────────────────────
class ArticlesSummaryPanel(Component):
name = "articles_summary"
template_name = "blog/panels/articles_summary.html"
order = 110
def get_context_data(self, parent_context):
context = super().get_context_data(parent_context)
context["drafts"] = (
ArticlePage.objects.not_live()
.order_by("-latest_revision_created_at")[:5]
)
context["scheduled"] = (
ArticlePage.objects.filter(go_live_at__isnull=False, live=False)
.order_by("go_live_at")[:5]
)
context["recent"] = (
ArticlePage.objects.live()
.order_by("-published_date")[:5]
)
return context
@hooks.register("construct_homepage_panels")
def add_articles_summary_panel(request, panels):
panels.append(ArticlesSummaryPanel())

View File

@@ -1,5 +1,8 @@
from __future__ import annotations from __future__ import annotations
import os
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from taggit.models import Tag from taggit.models import Tag
from wagtail.models import Page, Site from wagtail.models import Page, Site
@@ -10,6 +13,8 @@ from apps.comments.models import Comment
from apps.core.models import NavigationMenuItem, SiteSettings, SocialMediaLink from apps.core.models import NavigationMenuItem, SiteSettings, SocialMediaLink
from apps.legal.models import LegalIndexPage, LegalPage from apps.legal.models import LegalIndexPage, LegalPage
User = get_user_model()
class Command(BaseCommand): class Command(BaseCommand):
help = "Seed deterministic content for E2E checks." help = "Seed deterministic content for E2E checks."
@@ -17,6 +22,8 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
import datetime import datetime
from django.utils import timezone
root = Page.get_first_root_node() root = Page.get_first_root_node()
home = HomePage.objects.child_of(root).first() home = HomePage.objects.child_of(root).first()
@@ -40,6 +47,9 @@ class Command(BaseCommand):
) )
# Primary article — comments enabled, used by nightly journey test # Primary article — comments enabled, used by nightly journey test
# published_date is set explicitly to ensure deterministic ordering
# (most recent first) so this article appears at the top of listings.
now = timezone.now()
article = ArticlePage.objects.child_of(article_index).filter(slug="nightly-playwright-journey").first() article = ArticlePage.objects.child_of(article_index).filter(slug="nightly-playwright-journey").first()
if article is None: if article is None:
article = ArticlePage( article = ArticlePage(
@@ -49,9 +59,12 @@ class Command(BaseCommand):
summary="Seeded article for nightly browser journey.", summary="Seeded article for nightly browser journey.",
body=[("rich_text", "<p>Seeded article body for nightly browser checks.</p>")], body=[("rich_text", "<p>Seeded article body for nightly browser checks.</p>")],
comments_enabled=True, comments_enabled=True,
published_date=now,
) )
article_index.add_child(instance=article) article_index.add_child(instance=article)
article.save_revision().publish() article.save_revision().publish()
# Ensure deterministic ordering — primary article always newest
ArticlePage.objects.filter(pk=article.pk).update(published_date=now)
# Seed one approved top-level comment on the primary article for reply E2E tests # Seed one approved top-level comment on the primary article for reply E2E tests
if not Comment.objects.filter(article=article, author_name="E2E Approved Commenter").exists(): if not Comment.objects.filter(article=article, author_name="E2E Approved Commenter").exists():
@@ -75,9 +88,13 @@ class Command(BaseCommand):
summary="An article with tags for E2E filter tests.", summary="An article with tags for E2E filter tests.",
body=[("rich_text", "<p>This article is tagged with AI Tools.</p>")], body=[("rich_text", "<p>This article is tagged with AI Tools.</p>")],
comments_enabled=True, comments_enabled=True,
published_date=now - datetime.timedelta(hours=1),
) )
article_index.add_child(instance=tagged_article) article_index.add_child(instance=tagged_article)
tagged_article.save_revision().publish() tagged_article.save_revision().publish()
ArticlePage.objects.filter(pk=tagged_article.pk).update(
published_date=now - datetime.timedelta(hours=1)
)
tagged_article.tags.add(tag) tagged_article.tags.add(tag)
tagged_article.save() tagged_article.save()
@@ -91,6 +108,7 @@ class Command(BaseCommand):
summary="An article with comments disabled.", summary="An article with comments disabled.",
body=[("rich_text", "<p>Comments are disabled on this one.</p>")], body=[("rich_text", "<p>Comments are disabled on this one.</p>")],
comments_enabled=False, comments_enabled=False,
published_date=now - datetime.timedelta(hours=2),
) )
article_index.add_child(instance=no_comments_article) article_index.add_child(instance=no_comments_article)
# Explicitly persist False after add_child (which internally calls save()) # Explicitly persist False after add_child (which internally calls save())
@@ -98,6 +116,9 @@ class Command(BaseCommand):
ArticlePage.objects.filter(pk=no_comments_article.pk).update(comments_enabled=False) ArticlePage.objects.filter(pk=no_comments_article.pk).update(comments_enabled=False)
no_comments_article.comments_enabled = False no_comments_article.comments_enabled = False
no_comments_article.save_revision().publish() no_comments_article.save_revision().publish()
ArticlePage.objects.filter(pk=no_comments_article.pk).update(
published_date=now - datetime.timedelta(hours=2)
)
# About page # About page
if not AboutPage.objects.child_of(home).filter(slug="about").exists(): if not AboutPage.objects.child_of(home).filter(slug="about").exists():
@@ -181,4 +202,12 @@ class Command(BaseCommand):
] ]
) )
# Admin user for E2E admin tests — only when E2E_MODE is set
if os.environ.get("E2E_MODE") and not User.objects.filter(username="e2e-admin").exists():
User.objects.create_superuser(
username="e2e-admin",
email="e2e-admin@example.com",
password="e2e-admin-pass",
)
self.stdout.write(self.style.SUCCESS("Seeded E2E content.")) self.stdout.write(self.style.SUCCESS("Seeded E2E content."))

View File

@@ -32,7 +32,7 @@ def test_nightly_playwright_journey() -> None:
article_url = article_href if article_href.startswith("http") else f"{base_url}{article_href}" article_url = article_href if article_href.startswith("http") else f"{base_url}{article_href}"
page.goto(article_url, wait_until="networkidle") page.goto(article_url, wait_until="networkidle")
expect(page.get_by_role("heading", name="Comments")).to_be_visible() expect(page.get_by_role("heading", name="Comments", exact=True)).to_be_visible()
expect(page.get_by_role("button", name="Post comment")).to_be_visible() expect(page.get_by_role("button", name="Post comment")).to_be_visible()
page.goto(f"{base_url}/feed/", wait_until="networkidle") page.goto(f"{base_url}/feed/", wait_until="networkidle")

View File

@@ -24,6 +24,7 @@ services:
EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend
DEFAULT_FROM_EMAIL: hello@nohypeai.com DEFAULT_FROM_EMAIL: hello@nohypeai.com
NEWSLETTER_PROVIDER: buttondown NEWSLETTER_PROVIDER: buttondown
E2E_MODE: "1"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View File

@@ -0,0 +1,56 @@
"""E2E tests for Wagtail admin editor experience improvements."""
from __future__ import annotations
import pytest
from playwright.sync_api import Page, expect
def admin_login(page: Page, base_url: str) -> None:
"""Log in to the Wagtail admin using the seeded E2E admin user."""
page.goto(f"{base_url}/cms/login/", wait_until="networkidle")
page.fill('input[name="username"]', "e2e-admin")
page.fill('input[name="password"]', "e2e-admin-pass")
page.click('button[type="submit"]')
page.wait_for_load_state("networkidle")
@pytest.mark.e2e
def test_articles_menu_item_visible(page: Page, base_url: str) -> None:
"""The admin sidebar should contain an 'Articles' menu item."""
admin_login(page, base_url)
sidebar = page.locator("#wagtail-sidebar")
articles_link = sidebar.get_by_role("link", name="Articles")
expect(articles_link).to_be_visible()
@pytest.mark.e2e
def test_articles_listing_page_loads(page: Page, base_url: str) -> None:
"""Clicking 'Articles' should load the articles listing with seeded articles."""
admin_login(page, base_url)
page.goto(f"{base_url}/cms/articles/", wait_until="networkidle")
expect(page.get_by_role("heading").first).to_be_visible()
# Seeded articles should appear
expect(page.get_by_text("Nightly Playwright Journey")).to_be_visible()
@pytest.mark.e2e
def test_dashboard_has_articles_panel(page: Page, base_url: str) -> None:
"""The admin dashboard should include the articles summary panel."""
admin_login(page, base_url)
page.goto(f"{base_url}/cms/", wait_until="networkidle")
expect(page.get_by_text("Articles overview")).to_be_visible()
@pytest.mark.e2e
def test_article_editor_has_tabs(page: Page, base_url: str) -> None:
"""The article editor should have Content, Metadata, Publishing, and SEO tabs."""
admin_login(page, base_url)
page.goto(f"{base_url}/cms/articles/", wait_until="networkidle")
# Click the first article title link to edit it
page.get_by_role("link", name="Nightly Playwright Journey").first.click()
page.wait_for_load_state("networkidle")
expect(page.get_by_role("tab", name="Content")).to_be_visible()
expect(page.get_by_role("tab", name="Metadata")).to_be_visible()
expect(page.get_by_role("tab", name="Publishing")).to_be_visible()
expect(page.get_by_role("tab", name="SEO")).to_be_visible()

View File

@@ -0,0 +1,62 @@
{% load wagtailadmin_tags %}
<section class="nice-padding">
<h2 class="visuallyhidden">Articles overview</h2>
{% if drafts %}
<div class="w-mb-4">
<h3><svg class="icon icon-doc-empty" aria-hidden="true"><use href="#icon-doc-empty"></use></svg> Drafts</h3>
<table class="listing">
<tbody>
{% for page in drafts %}
<tr>
<td class="title">
<a href="{% url 'wagtailadmin_pages:edit' page.pk %}">{{ page.title }}</a>
</td>
<td>{{ page.latest_revision_created_at|timesince }} ago</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if scheduled %}
<div class="w-mb-4">
<h3><svg class="icon icon-time" aria-hidden="true"><use href="#icon-time"></use></svg> Scheduled</h3>
<table class="listing">
<tbody>
{% for page in scheduled %}
<tr>
<td class="title">
<a href="{% url 'wagtailadmin_pages:edit' page.pk %}">{{ page.title }}</a>
</td>
<td>{{ page.go_live_at|date:"N j, Y H:i" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if recent %}
<div class="w-mb-4">
<h3><svg class="icon icon-doc-full" aria-hidden="true"><use href="#icon-doc-full"></use></svg> Recently published</h3>
<table class="listing">
<tbody>
{% for page in recent %}
<tr>
<td class="title">
<a href="{% url 'wagtailadmin_pages:edit' page.pk %}">{{ page.title }}</a>
</td>
<td>{{ page.published_date|timesince }} ago</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if not drafts and not scheduled and not recent %}
<p>No articles yet. <a href="{% url 'articles:choose_parent' %}">Create one</a>.</p>
{% endif %}
</section>

File diff suppressed because one or more lines are too long