Skip to content

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

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

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

Conversation

@eisenbruch

@eisenbruch eisenbruch commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

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.

@changeset-bot

changeset-bot Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 934bb36

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

This PR includes changesets to release 12 packages
Name Type
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/cloudflare Minor
@emdash-cms/plugin-test Patch
@emdash-cms/sandbox-workerd 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/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.

@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-test

npm i https://pkg.pr.new/@emdash-cms/plugin-test@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-loader

npm i https://pkg.pr.new/@emdash-cms/registry-loader@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: 934bb36

@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

github-actions Bot commented Sep 11, 2026 •

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: 934bb36c70c696cc8f6fa30d906806ab33a2f678. /accept-baselines accepts the snapshots for this commit.

@eisenbruch

Copy link
Copy Markdown
Contributor 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.

@eisenbruch
eisenbruch marked this pull request as draft September 15, 2026 17:14
@github-actions github-actions Bot added needs-rebase and removed review/needs-review No maintainer or bot review yet labels Sep 15, 2026
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Sep 23, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds termFilters to the content list API and admin UI, aligned with the shape approved in Discussion #3055. The approach is sound: termFilters is a sibling to fieldFilters (avoiding name collisions), applies OR within a taxonomy and AND across taxonomies via correlated EXISTS subqueries, and is mirrored in countWithResolvedFilters so cursor totals stay stable. The handler correctly rejects taxonomies not attached to the collection instead of silently ignoring them, and the changeset documents the new parameter and failure behavior well.

I checked the diff, AGENTS.md, and related code. The core implementation is clean and the new tests cover the junction semantics. Two admin issues need fixing before merge: FilterBar does not treat term filters as active filters, so the clear button can be hidden and won't clear term filters; and TermFilters lowercases taxonomy labels before passing them to Lingui, which is English-specific and harms other locales. TermFilters also indents hierarchical terms with literal non-breaking spaces, which is not RTL-safe per AGENTS.md. The verbose justifying comment in the handler can be trimmed once the changeset/PR cover it.

Conclusion: approach LGTM, but fix the admin filter-state handling and i18n/RTL issues.


Findings

  • [needs fixing] packages/admin/src/components/ContentList.tsx:888-904

    hasActiveFilter ignores termFilter and handleClear never resets it. As a result, when only a term filter is selected the Clear filters button (rendered from hasActiveFilter at line 986) is hidden, and when other filters are present the button leaves the term filter applied after clearing everything else.

    Add a helper to check for active term selections and reset them in handleClear:

    	const isTermFilterActive = (filter: TermFilterState) =>
    		Object.values(filter).some((slugs) => slugs.length > 0);
    
    	const hasActiveFilter =
    		statusFilter !== "all" ||
    		authorFilter !== "" ||
    		!!dateFilter.from ||
    		!!dateFilter.to ||
    		isBylineFilterActive(bylineFilter) ||
    		isTermFilterActive(termFilter ?? {});
    
    	const handleClear = () => {
    		onStatusFilterChange("all");
    		onAuthorFilterChange?.("");
    		onDateFilterChange?.(EMPTY_DATE_FILTER);
    		// Clearing drops the selection but keeps the inferred-byline
    		// preference, which is a display choice rather than an active filter.
    		onBylineFilterChange?.({
    			...EMPTY_BYLINE_FILTER,
    			includeInferred: bylineFilter.includeInferred,
    		});
    		onTermFilterChange?.({});
    	};
    
  • [needs fixing] packages/admin/src/components/TermFilters.tsx:74-75

    Lowercasing taxonomy labels before passing them into Lingui placeholders is English-specific and makes the message wrong for locales where taxonomy names keep capitals (or follow other casing rules). The translators only see the already-lowercased placeholder value.

    Drop the .toLowerCase() calls and let the original label be the placeholder:

    					allLabel={t`All ${def.label}`}
    					ariaLabel={t`Filter by ${def.label}`}
    
  • [needs fixing] packages/admin/src/components/TermFilters.tsx:123

    Hierarchical indentation is done with literal non-breaking-space characters inside the option text. That is physical indentation and breaks RTL layout expectations: AGENTS.md requires RTL-safe logical Tailwind (ps-*, padding-inline-start, etc.) in admin UI.

    Use an element with logical start padding instead, for example:

    			{options.map(({ term, depth }) => (
    				<Select.Option key={term.id} value={term.slug}>
    					<span
    						className={depth > 0 ? `ps-${Math.min(depth * 4, 12)}` : undefined}
    						style={depth > 0 ? { paddingInlineStart: `${depth * 1}rem` } : undefined}
    					>
    						{term.label}
    					</span>
    				</Select.Option>
    			))}
    
  • [suggestion] packages/core/src/api/handlers/content.ts:553-556

    This comment is justification addressed to the reviewer rather than a future reader of the code. The same rationale and failure choice already appear in the PR description and the changeset, which are the right places for it. Once this block is merged, the behavior is clear from the validation code itself; trim the comment to the single line that states the invariant, or remove it.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 23, 2026
@github-actions github-actions Bot mentioned this pull request Sep 24, 2026
11 of 18 tasks
@eisenbruch
eisenbruch force-pushed the feat/content-list-term-filters branch from e13a7f6 to dca23a1 Compare September 24, 2026 15:39
@eisenbruch

Copy link
Copy Markdown
Contributor Author

Rebased onto main after #3364 (bulk tagging), and the review findings addressed in dca23a12:

  • Clear filters and term filters. FilterBar now counts a term selection as an active filter (isTermFilterActive, exported from TermFilters.tsx) and handleClear resets it. New test: with only a term selected, Clear filters is offered and resets it to {}; with none selected, it is not offered. The first test fails on the previous commit and passes on this one.
  • Labels. The taxonomy label goes to Lingui as written (All ${label}, Filter by ${label}), no toLowerCase().
  • Indentation. Child terms are indented with paddingInlineStart in rem, the same pattern TaxonomySidebar uses, instead of literal non-breaking spaces.
  • The handler comment on the unattached-taxonomy rejection is cut to one line.

The rebase conflicted only on the imports in repositories/content.ts (the datetime normalization import added on main). Checked: ContentList.test.tsx and TaxonomySidebar.test.tsx (108 tests), core repositories/content.test.ts and api/schemas.test.ts (133), admin tsgo --noEmit, core tsc --noEmit, oxfmt --ignore-path .gitignore --check, oxlint --type-aware --deny-warnings.

github-actions Bot pushed a commit that referenced this pull request Sep 24, 2026
@eisenbruch
eisenbruch force-pushed the feat/content-list-term-filters branch from dca23a1 to 8520217 Compare September 24, 2026 18:02
@eisenbruch

Copy link
Copy Markdown
Contributor Author

CI on dca23a12, two failures, neither in the change itself:

  • Smoke Isolated Install / Tests: isolated-install.test.ts:383 "The symbol fetchWithServerOutput has already been declared". That duplicate was on main when I rebased and was removed by test(core): remove duplicate smoke request helper #3414. Rebased again (85202179).
  • Visual Regression, 10 content-list screens: the only difference is the new "All Categories" term filter between the bylines and date-field filters, in both LTR and RTL. I checked the expected, actual and diff images. The label is untranslated in the RTL pseudo-locale because it is a new string and the catalogs are extracted on merge. These are intended, so a maintainer's /accept-baselines is what's needed there.

github-actions Bot pushed a commit that referenced this pull request Sep 24, 2026
@eisenbruch
eisenbruch force-pushed the feat/content-list-term-filters branch from 8520217 to 16f91a9 Compare October 2, 2026 17:30
github-actions Bot pushed a commit that referenced this pull request Oct 2, 2026
eisenbruch and others added 6 commits October 3, 2026 12:23
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
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.
@eisenbruch

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (4c5aa726) and pushed 934bb36c. One conflict, in router.tsx: main moved the content list from an infinite cursor query to numbered pages (usePagedQuery). The term filters now ride on the paged query, in its query key and in the fetchContentList call, alongside the byline and date filters. On the server a numbered page goes through the same handleContentList path, so termFilters reach it unchanged.

Checked on the rebased branch: content.test.ts (79) and ContentList.test.tsx (82) pass; tsgo --noEmit is clean for emdash and @emdash-cms/admin; oxfmt --ignore-path .gitignore --check is clean; oxlint --type-aware --deny-warnings is clean on the 11 changed files. Visual Regression will still differ on the content-list screens for the reason given above: the new term filter sits in the filter bar.

@eisenbruch
eisenbruch force-pushed the feat/content-list-term-filters branch from 16f91a9 to 934bb36 Compare October 3, 2026 16:32
github-actions Bot pushed a commit that referenced this pull request Oct 3, 2026
@MA2153

MA2153 commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

Hi! There is a new feature process in place: https://github.com/emdash-cms/emdash/blob/main/proposals/README.md

Could you please submit a Design PR first? Thank you!

@eisenbruch

Copy link
Copy Markdown
Contributor Author

Thanks. Design PR is up as #4087, a retrospective feature plan for this one.

@github-actions

Copy link
Copy Markdown
Contributor

This PR is waiting on its author because it is still a draft, and it has had no activity for 14 days. It will be closed in 7 days unless there is activity: push an update or leave a comment to keep it open.

@github-actions github-actions Bot added the stale label Oct 24, 2026
@eisenbruch

Copy link
Copy Markdown
Contributor Author

Still active: this stays a draft while its design PR, #4087, waits for review, as proposals/README.md asks. I'll rebase it once the plan is accepted.

@github-actions github-actions Bot removed the stale label Oct 25, 2026
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.

4 participants