Skip to content

feat: filter the content list by taxonomy term - #3046

Open
eisenbruch wants to merge 5 commits into
emdash-cms:mainfrom
eisenbruch:feat/content-list-term-filters
Open

feat: filter the content list by taxonomy term#3046
eisenbruch wants to merge 5 commits into
emdash-cms:mainfrom
eisenbruch:feat/content-list-term-filters

Conversation

@eisenbruch

@eisenbruch eisenbruch commented Sep 10, 2026

Copy link
Copy Markdown

What does this PR do?

Adds termFilters to the content list and a dropdown per applied taxonomy in the admin, so a collection organized by a taxonomy can be filtered by it, in the screen editors work in, and over the API.

Related: #3043 (closed in favor of Discussion #3055)

Why this shape

fieldFilters already ANDs its keys over indexed columns. Terms are not columns, so this is a sibling parameter rather than an extension of that one: it keeps "column" and "term" distinguishable, and avoids a collision when a field and a taxonomy share a name.

?termFilters={"topics":["rodeo","polo"],"places":["kentucky"]}

OR within a taxonomy, AND across taxonomies, which is how the controls read: two terms of one taxonomy widen a search, two taxonomies narrow it.

Core

One EXISTS subquery per key, applied in both findMany and countWithResolvedFilters so totals and pages agree.

Two details the junction imposes, both of which match nothing rather than erroring when got wrong, so they are worth stating:

  • content_taxonomies.entry_id stores the ec_* row's translation_group, not its id, unlike _emdash_content_bylines.content_id alongside it, which stores the row id.
  • it points at taxonomies.translation_group, so a term matches through any of its locale variants unless the list is locale-scoped.

Two deliberate failure choices

Both are the same principle: a filter that silently matches everything returns a complete, plausible, unfiltered list with a 200, which is the hardest kind of wrong answer to notice.

  • A taxonomy not attached to the collection is a VALIDATION_ERROR, not an empty result and not a no-op.
  • A key that resolves to no slugs matches nothing, rather than degrading to "no filter". This mirrors the existing byline filter, which already does this for an empty id set.

At the API-client and UI layers the same idea appears as: "no selection" is the absence of a key, never an empty array.

Admin

Nothing is configured per site. A collection already declares which taxonomies apply to it, so the controls derive from that: a collection with no taxonomy renders nothing new, and one that gains a taxonomy gains its filter. Terms load per taxonomy and only for those on screen. A hierarchical taxonomy is flattened depth-first and indented, matching the entry editor's picker.

Not included

Sorting by a term, and multi-select within a taxonomy. Both fit the shape (the parameter already takes an array), but neither is needed for the filtering case and I would rather not guess at the UI for them.

Discussion

Discussion: #3055. I opened this PR before it, which was the wrong order; the Discussion sets out the shape, the two failure choices, how this relates to #1928 and #2799, and a separate question about unknown query parameters being stripped.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion): see the Discussion above
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes: packages/core and packages/admin both clean. packages/admin has one pre-existing error in src/components/editor/ordered-list.ts, present identically on a clean origin/main in my environment.
  • pnpm lint passes: oxlint --type-aware clean on every changed file
  • pnpm test passes (or targeted tests for my change): see the note below
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation: the two new strings use useLingui; no messages.po changes included
  • I have added and reviewed the user-facing changeset
  • New features link to a Discussion: Filter the content list by taxonomy term #3055
  • I have included screenshots below if this PR changes the UI

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.6 (Claude Code)

Screenshots / test output

The new control, on a collection with the category taxonomy applied, and opened to show that taxonomy's terms. Captured on pnpm ux:site:start with the editorial-small profile.

Posts list toolbar showing, left to right: a search box, All statuses, All bylines, All categories, Created, and Date range. All categories is the new control and matches the styling of the others. The All categories dropdown opened over the Posts list, listing All categories with a check beside it, then News, Tutorials and Opinion, the terms of the category taxonomy seeded by the editorial-small profile.

Verified against a running site (pnpm ux:site:start, editorial-small, patched core and admin), which is the more useful evidence anyway:

GET /content/posts?limit=1
  → {"success":true,"data":{"items":[…],"total":3}}

GET /content/posts?limit=1&termFilters={"category":["opinion"]}
  → {"success":true,"data":{"items":[],"total":0}}
    (correct: the profile seeds terms but assigns none)

GET /content/posts?limit=1&termFilters={"nosuchtaxonomy":["x"]}
  → {"success":false,"error":{"code":"VALIDATION_ERROR",
     "message":"Taxonomy not applied to posts: nosuchtaxonomy"}}

The admin request carries it correctly: …&termFilters=%7B%22category%22%3A%5B%22opinion%22%5D%7D → 200.

Worth recording that the middle case is exactly the bug being fixed: before rebuilding core, that same request returned all three rows, because the unknown parameter was dropped silently.

Unit tests: seven added, covering OR within a taxonomy, AND across taxonomies, an empty filter matching nothing, an unknown slug matching nothing, totals staying stable across cursor pages, and combining with an indexed field filter. They seed content_taxonomies by hand precisely to pin the two junction details above.

tests/database/repositories/content.test.ts   77 passed
tests/unit/api/schemas.test.ts + content       128 passed (both files)

On the wider suite, stated plainly rather than hidden: tests/unit tests/database reports 36 failed files / 458 failed tests on this branch, and exactly the same 36 / 458 on a clean origin/main in my environment, from what looks like a local better-sqlite3 native module problem. Same run: 3,954 passing here against 3,947 on main, i.e. the seven added. I could not get a green baseline to compare against, so I am flagging it rather than claiming green.

tests/database/repositories/content.test.ts also passes on Node 22.23.2 (77 passed), after a follow-up commit that moves applyTermFilters below applyBylineFilter so the byline method keeps its own doc comment.

A collection's list can be filtered by status, author, byline, date range,
free text and any indexed custom field, but not by a taxonomy term — in the
admin or over the API — even where a taxonomy is attached and every entry
carries one.

Adds `termFilters`, keyed by taxonomy name and valued by term slugs: OR within
a taxonomy, AND across taxonomies. That mirrors `fieldFilters`, which ANDs its
keys, and matches how the controls read in a UI, where two terms of one
taxonomy widen a search and two taxonomies narrow it.

Resolution is an EXISTS subquery per key, applied in both `findMany` and
`countWithResolvedFilters` so totals and pages agree. Two details the junction
imposes, both of which match nothing rather than erroring when got wrong:
`content_taxonomies.entry_id` stores the ec_* row's translation_group rather
than its id, unlike the byline junction alongside it; and it points at
`taxonomies.translation_group`, so a term matches through any locale variant
unless the list is locale-scoped.

A taxonomy not attached to the collection is rejected as a validation error
rather than ignored, and a key that resolves to no slugs matches nothing rather
than degrading to "no filter". Both are deliberate: a silently dropped filter
returns a complete, plausible, unfiltered list with a 200, which is the hardest
kind of wrong answer to notice.

Refs emdash-cms#3043
Adds one dropdown per taxonomy applied to the collection, beside the status,
author and byline filters, driving the `termFilters` parameter added to the
content list in the previous commit.

Nothing is configured per site. A collection already declares which taxonomies
apply to it, so the controls are derived from that: a collection with no
taxonomy renders nothing new, and one that gains a taxonomy gains its filter.
Terms load per taxonomy and only for the ones on screen, so a site with several
does not pay for all of them at once. A hierarchical taxonomy is flattened
depth-first and indented, matching how the entry editor's picker reads.

"No selection" is represented by the absence of a key rather than an empty
array, at every layer. An empty array means "match nothing" to the server, and
an untouched control must not mean that.

Refs emdash-cms#3043
@changeset-bot

changeset-bot Bot commented Sep 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e13a7f6

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
create-emdash Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/release-service Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 506 lines across 11 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

applyTermFilters had been inserted between applyBylineFilter's doc
comment and the method, so the byline docs sat on the wrong function.
Also tidies punctuation and spelling in the new comments and changeset.
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Sep 11, 2026
@pkg-pr-new

pkg-pr-new Bot commented Sep 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@3046

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@3046

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@3046

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@3046

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@3046

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@3046

emdash

npm i https://pkg.pr.new/emdash@3046

create-emdash

npm i https://pkg.pr.new/create-emdash@3046

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@3046

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@3046

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@3046

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@3046

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@3046

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/@emdash-cms/registry-moderation@3046

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@3046

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@3046

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@3046

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@3046

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@3046

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@3046

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@3046

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@3046

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@3046

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@3046

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@3046

commit: e13a7f6

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 11, 2026
github-actions Bot pushed a commit that referenced this pull request Sep 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Visual regression: 10 screens changed

These admin screens render differently on this PR. Confirm every change below is intended before accepting.

Screen Image Compare
content-list-byline-filter-ltr expected · actual
content-list-byline-filter-rtl expected · actual
content-list-date-field-filter-ltr expected · actual
content-list-date-field-filter-rtl expected · actual
content-list-date-range-filter-ltr expected · actual
content-list-date-range-filter-rtl expected · actual
content-list-ltr expected · actual
content-list-rtl expected · actual
content-list-status-filter-ltr expected · actual
content-list-status-filter-rtl expected · actual

Maintainers: if every change above is intended, comment /accept-baselines
to commit the regenerated Linux baselines to this PR.

Measured head: e13a7f6257346341eb7b76af5388be100e26c078. /accept-baselines accepts the snapshots for this commit.

@eisenbruch

Copy link
Copy Markdown
Author

Two notes on the CI run, to save a reviewer the digging:

  • The 10 visual-regression diffs are all the new "All categories" control in the content-list filter bar; the fixture's posts collection has the category taxonomy applied, so it now renders there. Nothing else on those screens moved.
  • The D1 Tests failure is migrations-d1.test.ts > replays every migration written since the guarded-DDL pattern timing out at 30s. This PR adds no migrations, and the same job failed on main yesterday (22:52 UTC) and passed on later runs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants