Skip to content

Story 2378: Webpage Integration: Posts Feed - #2568

Open
ycanales wants to merge 3 commits into
jc/2376-post-detail-pagefrom
cy/2378-posts-feed
Open

Story 2378: Webpage Integration: Posts Feed#2568
ycanales wants to merge 3 commits into
jc/2376-post-detail-pagefrom
cy/2378-posts-feed

Conversation

@ycanales

@ycanales ycanales commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Issue: #2378

Summary & Context

Makes the posts feed's controls actually work: search, the library filter, a header that reflects the filter state, URL-driven state, an empty state, and a user card wired to real data. Before this PR the search box sat outside the form and did nothing, the library dropdown was fed a context variable no view set (so it rendered empty), the post-type pills navigated with window.location.assign and discarded every other query parameter, and zero results rendered a bare heading with nothing under it.

Stacked on #2562 (jc/2376-post-detail-page), which moves tags onto PostPage. Review that one first.

Changes

Search

  • Add search_fields to PostPage covering body text, summary, post type, tag name and slug, and the author's display name. title is already indexed by Page.search_fields.
  • Add PostPage.search_body, which runs the StreamField through get_searchable_content() and strips markup. Indexing the raw block would store HTML, so a search for "p" would match every rich-text post through its <p> tags.
  • Filters are applied as plain queryset filters and, when a term is present, handed to Wagtail search through a pk subquery. The backend rejects a StreamField lookup with AttributeError: 'KeyTransform' object has no attribute 'target' and a tag lookup with FilterFieldError, and .search() returns SearchResults rather than a queryset, so select_related / prefetch_related have to be applied before it.
  • A blank or whitespace-only term skips the search path entirely. The backend turns an empty query into zero results, not into "everything".

Filters and feed state

  • Add pages/feed.py holding the content-type table (moved out of pages/models.py) and PostFeedFilters, a frozen dataclass that parses and validates ?q=, ?type=, ?library= and ?author=. Every value is resolved to a real object there, so nothing raw from the querystring can reach the feed header. Unknown values degrade to the unfiltered feed.
  • Generate the filter pills from POST_CONTENT_TYPES, resolving the # TODO that was already in the file. _PostContentType gains label (plural, for the pill) and header_label (singular, for the header), because neither matches content_type: the header would otherwise read "Blogpost Posts".
  • Feed header follows the AC precedence: search, then author, then post type, then library, then "Latest Posts".
  • Render the out-of-scope pills (Discussions, Achievements, Issues) as disabled. They currently navigate to a bare URL, which silently behaves like "All".
  • Extract library_filter_options() into libraries/utils.py, shared with EntryListView's old copy. Labelled with display_name so the dropdown matches the header wording ("Boost.Beast", not "Beast").

Template

  • One GET form wraps search, pills and dropdown. The empty action drops the current querystring, which is what resets pagination: page is not a form field, so any submit returns to page 1.
  • Delete the inline window.location.assign block. Submission is now declarative Alpine: field-change and pill change call requestSubmit(), and Enter submits explicitly (the search field's own submit button disables itself on an empty box, so without that a user could not clear a search).
  • The field-change submit is deferred with $nextTick. The dropdown dispatches the event as it sets selected, before Alpine has written it to the hidden input the form submits, so submitting immediately sent an empty library=. Caught by the E2E spec, not by the unit tests.
  • Keep the no-JS path: the <noscript> Filter button and the native <select> fallback both still submit.
  • Carry an active ?author= in a hidden input, so the first pill click does not silently drop it.

Empty state

  • Add templates/v3/includes/_post_empty_state.html and the {% else %} branch that _post_list_card.html never had, plus .post-empty-state styles ported from the library page's treatment.
  • On zero results with a library selected, suggest up to three posts from that library with the search term dropped. Without a library there is no suggestion: the widest fallback would be the whole feed, which reads as "no results, here is everything" rather than as a suggestion.

User card

  • Logged in: drop the hardcoded badge_name='Bug Catcher' and role='Contributor', and drop badge_icon_src, which _user_card.html does not accept and so never rendered. Pass role=u.role instead.
  • Logged out: the CTA pointed at #. It now reads Sign Up Now and opens the signup page. Heading and copy come from the include's own defaults, which already match the AC verbatim.

Routing

  • Point the header nav at the Wagtail feed. It pointed at /news/, the legacy Entry list, which rendered the same v3 template from a different context; the two would have had to share a context contract. EntryListView drops back to its v2 template and keeps serving legacy entries.
  • posts_feed_url(request) passes the request through to get_url so the nav renders a path rather than the fully qualified URL Page.url falls back to once a second Site exists.

Tests

  • 68 tests in pages/: search per indexed field, each filter, every filter combination, the six header strings, pagination, the empty state and its fallback, URL-driven state, the user card in both auth states, and v3 flag gating. Plus a query-count guard, since each card reads item.author and item.tag.
  • Add pages/tests/fixtures.py (registered in conftest.py) with the page tree, a default Site and a PostPage factory. The factory indexes each page explicitly: indexing is queued with transaction.on_commit, which never runs under the django_db fixture, so every search test would otherwise return zero rows with no error.
  • E2E: tests/posts-feed.spec.ts in website-v2-e2e, 16 tests at desktop and mobile. The corpus cannot be created through the UI (the create form submits a draft into moderation, and a draft never renders in the feed), so npm run seed now also writes it into the page tree.

Rejected alternatives

  • Adding FilterFields so the tag filter survives .search(). The pk subquery needs none, and a FilterField on tags would make a post-search .filter() merely pass validation, reintroducing join duplicates. DISTINCT is not available as a fix either: it conflicts with the rank ORDER BY the backend injects.
  • A plain icontains Q-filter instead of Wagtail search, as libraries/api.py does. It needs no index rebuild, but gives no relevance ranking and cannot reach StreamField body text, which is the bulk of what people search for. The issue's dev note asks for qs.search(...) and that is what this does.
  • tags__slug for the library filter. tagged_items__tag__slug is used instead: same SQL, but it resolves against the concrete PostPage table under either tag arrangement, so it survives a merge with branches where TaggedContent.content_object still points at wagtailcore.Page (there tags__slug raises ProgrammingError: column pages_postpage.id does not exist).
  • Caching the nav's feed-page lookup. It costs one indexed LIMIT 1 per request, and a cache would need invalidating on slug change and would leak between tests through the shared Redis. The query-count guard in news/tests/test_views.py moves from 10 to 11 for it.

‼️ Risks & Considerations ‼️

  • ./manage.py update_index must run once per environment on deploy. Signals only index on post_save, so every PostPage created before this lands is invisible to search until it does.
  • The header nav moves to /pages/posts/. /news/ still exists and still serves legacy Entry posts, now on the v2 template. Worth a second opinion on whether /news/ should redirect.
  • Two ACs have no data behind them yet. User.role on this branch is still a hardcoded "Contributor" stub and there is no badge or org-affiliation source, so the card cannot show a real role, badge or affiliation today. It is wired to u.role so it improves for free when Story 2443: User Profile Integration – User Roles  #2527 lands.
  • Postgres indexes Boost.SQLite as one lexeme, so searching the bare SQLite does not match it by title. Library-tagged posts are still reachable through the tag index. There is a test documenting this.
  • Running the test suite writes a test Site into Wagtail's shared site-root-path cache. The new fixture clears it on teardown; if page URLs ever come back as None in dev, that cache is the first thing to flush.
  • The Figma frame covers the default state only. The empty-state copy and illustration borrow the library page's treatment and are worth a design check.

Peer-Testing Guidelines

Setup

  1. Check out the branch (it stacks on jc/2376-post-detail-page) and run docker compose exec web python manage.py migrate.
  2. Turn the v3 waffle flag to Everyone at http://localhost:8000/admin/. This is also how the logged-out state is tested.
  3. Get posts into the feed. Either create a few through Create Post (they need approving before they go live), or convert the legacy entries: docker compose exec web python manage.py convert_news_entries.
  4. Make them searchable: docker compose exec web python manage.py update_index.
  5. Give at least two posts a related library on creation, using two different libraries. That is what produces the library tag the filter matches.

Search

  1. Open http://localhost:8000/pages/posts/ and search a word from a post title, then a word that appears only in a body. Both should return that post, with the header reading Results for "<term>".
  2. Search an author's display name, then a library name or slug. Both should return the matching posts.
  3. Clear the box and press Enter to get the full feed back. The submit arrow is deliberately disabled on an empty box, so Enter is the way out of a search.

Filters

  1. Click each post type pill. The header should read News Posts, Blog Posts, Video Posts, Link Posts, and All should clear it. Discussions, Achievements and Issues should be visibly disabled.
  2. Pick a library from the dropdown. The header should read Boost.<Library> Posts. Combine it with a post type: the header shows the post type only, by design.
  3. Combine a search term with either filter, then go to page 2 and change a filter. You should land back on page 1 of the new result set.

URL state

  1. Open http://localhost:8000/pages/posts/?library=beast in a fresh tab (substitute a library you tagged). The dropdown, the header and the search box should all match the URL.
  2. Try ?type=nonsense, ?library=nope, ?author=abc and ?page=999. Each should degrade quietly to a sensible feed, never a 500.

Empty state and user card

  1. Search something with no matches: illustration plus message, not an empty column. Select a library first and search the same term: up to three "Related Posts" from that library should appear below it.
  2. Signed in, the card shows your name, Member Since <year> and a Create Post button. In a private window it shows "Create an account" and Sign Up Now.
  3. Disable JavaScript and reload. A native <select> and a visible Filter button appear, and submitting still applies search, pills and library.

Screenshots

Default Search Library filter
Empty state Empty state with related posts Logged-out card

Self-review Checklist

  • Tag at least one team member from each team to review this PR
  • Link this PR to the related GitHub Project ticket

Frontend

  • UI implementation matches Figma design
  • Tested in light and dark mode
  • Responsive / mobile verified
  • Accessibility checked (keyboard navigation, etc.)
  • Ensure design tokens are used for colors, spacing, typography, etc. - No hardcoded values
  • Test without JavaScript (if applicable)
  • No console errors or warnings

ycanales added 3 commits July 31, 2026 18:07
The page-tree fixture was local to test_commands.py. Register pages.tests.fixtures
so the feed tests can reuse it, and add the two pieces they need on top: a default
Site (pytest runs with --no-migrations, so Wagtail's initial data never exists and
page.url returns None without one) and a PostPage factory.

The factory indexes each page explicitly because indexing is queued with
transaction.on_commit, which never runs under the django_db fixture.
…o the posts feed

The feed rendered posts and post type pills, but the search box sat outside the
form and the library dropdown was fed a context variable no view set, so neither
did anything. The pills navigated with window.location.assign, discarding every
other query parameter, and zero results rendered nothing at all.

Search, pills, library and author are now one GET form writing to ?q=, ?type=,
?library= and ?author=, parsed and validated by PostFeedFilters. Filters run as
plain queryset filters and, when a term is present, are handed to Wagtail search
through a pk subquery: the backend rejects StreamField lookups outright and tag
lookups without a FilterField, and returns SearchResults rather than a queryset.
PostPage gains search_fields covering body text, summary, post type, tags and
author name; body text is stripped of markup so a search for "p" does not match
every rich text post.

Zero results render an empty state, plus up to three posts from the selected
library with the search term dropped. Submitting the form drops ?page=, which is
what resets pagination.

The header nav now points at the Wagtail feed. EntryListView keeps serving legacy
Entry rows on the v2 template rather than sharing a context contract with it.

Existing posts need ./manage.py update_index once per environment before they are
searchable.
The dropdown dispatches field-change while setting `selected`, before Alpine has
written it to the hidden input the form submits, so choosing a library submitted
an empty `library=` and the feed came back unfiltered. Defer the submit a tick.

Also pass the request to the nav's feed-page lookup: without it Page.url falls
back to a fully qualified URL once a second Site exists, so the nav rendered an
absolute href where every other link is a path.

Both found by the E2E spec, neither visible to the view tests.
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 244cef38-0c52-4e57-9380-6431b3f6cdce

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ycanales ycanales linked an issue Aug 1, 2026 that may be closed by this pull request
@ycanales ycanales changed the title Cy/2378 posts feed Story 2378: Webpage Integration: Posts Feed Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Webpage Integration: Posts Feed

1 participant