feat: filter the admin content list by byline - #2312
Conversation
🦋 Changeset detectedLatest commit: b74cb65 The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
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 |
Scope checkThis PR changes 674 lines across 12 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. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
Adds byline filtering alongside the existing status, author, and date filters. Selecting several bylines matches entries credited to any of them; "No byline assigned" matches entries with no credit. Credits inferred from an entry's author (rendered when an entry has no explicit credit) are excluded unless opted into, so the filter matches assigned bylines by default. No migration is required. The UNIQUE(collection_slug, content_id, byline_id) index from migration 031 covers every filter shape: EXPLAIN QUERY PLAN shows a covering seek for the include, exclude, and no-byline probes while the outer query keeps its sort-ordered composite index, so LIMIT still short-circuits without a temp B-tree. Correlating EXISTS from the content table is what makes that hold — driving from the pivot side cannot use the index for the byline and forces a temp sort — hence the note in applyBylineFilter. Filter values are translation_groups (what the junction has stored since migration 040), so a selection matches a byline across every locale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Temporary tooling for exercising the byline filter by hand. Not intended to ship with the feature -- revert this commit before the PR goes up. Adds a "Set byline" picker to the existing bulk-selection toolbar. The picked bylines replace each selected entry's credit set rather than merging into it: list items hydrate credits with strict locale matching, so an entry whose byline has no row in the entry's locale comes back with an empty `bylines` array, and a client-side merge silently drops those credits on write. Requests fan out through runBulkAction like the other bulk actions, so failed ids stay selected for a retry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This reverts commit 28101af.
The inferred-byline branch of the content-list filter matched on `author_id` alone, against a set of users resolved from every locale row of the selected bylines. Byline hydration is strict per locale, so an entry whose author owns a byline with no row at the list's locale renders as uncredited while still matching a filter on that byline -- and matched "No byline" at the same time, since that branch did correlate on locale. Resolve the credit the same way the list renders it: a correlated EXISTS on `_emdash_bylines` scoped to the locale the list is showing, falling back to each entry's own locale when the list spans locales. This drops the pre-resolution query the handler ran for `includeInferredBylines`, so the opt-in no longer costs an extra round-trip. Also pluralize the selected-byline count through Lingui's `plural` rather than a bare interpolation, which only reads correctly in languages with a single plural form. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
28101af to
b9d991f
Compare
There was a problem hiding this comment.
This PR adds a byline filter to the admin content list. The approach is sound: it filters on the same translation_group values stored in _emdash_content_bylines, uses a correlated EXISTS so the outer query keeps its sort-ordered index, and resolves inferred credits at the list locale so the filter agrees with what the list actually renders. The 12 integration tests cover the important cases (OR matching, no-byline, inferred opt-in/out, locale-scoped inference, status composition, the empty-selection guard, and total). No new queries are added to logged-out routes, and the changeset covers the affected packages.
The main blocker is policy, not code: AGENTS.md requires maintainer-approved Discussion for new features, and the PR itself notes that Discussion #2347 is still pending approval and the PR is a draft. That approval should land before merge.
There is one AGENTS.md convention issue: an over-narrative doc comment in the repository layer that justifies the SQL shape, describes a rejected alternative, and references migration numbers. Comments should state non-obvious invariants concisely; everything else belongs in the PR description or commit message. Fixing that is the only code-level change I'd ask for.
Drop the rejected-alternative narrative and the migration reference; keep why the EXISTS correlates from the content table and why "none" tests the junction rather than primary_byline_id. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
The byline-filter approach is sound: it extends the existing content-list filter set, uses the same translation_group values stored in _emdash_content_bylines, resolves inferred credits at the list locale so the filter matches what the list renders, and keeps the query shaped as a correlated EXISTS from the content table so the outer sort index survives. The 12 integration tests cover the important cases (OR matching, no-byline, inferred opt-in/out, locale-scoped inference, status composition, total, and the empty-selection guard), and no new queries are added to any logged-out route.
The over-narrative repository comment flagged in the prior review has been trimmed to just the two invariants a future reader needs, which fixes that AGENTS.md issue.
Remaining item: one minor AGENTS.md convention note below on a justification-style sentence in the new ContentBylineFilter docstring.
Reminder: the PR itself notes that Discussion #2347 is still awaiting maintainer approval, and the feature checklist item is intentionally unticked. That policy approval should land before merge; the code-level review does not override that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
The byline-filter approach is sound and sits well in EmDash's architecture: it extends the existing server-side content-list filters, uses the same translation_group values stored in _emdash_content_bylines, resolves inferred credits at the list locale so filtering matches what the list renders, and keeps the query shaped as a correlated EXISTS from the content table so the outer sort index survives. The 12 integration tests cover the important cases (OR matching, no-byline, inferred opt-in/out, locale-scoped inference, status composition, total, and the empty-selection guard), and no new queries are added to logged-out routes.
The two AGENTS.md comment-discipline notes from the prior review have been addressed: the repository comment on applyBylineFilter is now limited to the two invariants a reader needs, and the ContentBylineFilter type docstring no longer contains a justification sentence.
One minor comment-discipline item remains in the new BylineFilter component docstring, which still narrates the rejected exhaustive-list alternative rather than only stating what the component does. Otherwise the code is clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
The byline-filter approach remains sound: it extends the existing server-side content-list filters, matches on translation_group values stored in _emdash_content_bylines, and resolves inferred credits at the list locale so filtering agrees with what the list renders. The query is shaped as a correlated EXISTS from the content table, the IN clause is bounded by schema validation, and the 12 integration tests cover OR matching, no-byline, inferred opt-in/out, locale-scoped inference, status composition, total, and the empty-selection guard. No new queries are added to logged-out routes.
The two comment-discipline items from the last review (applyBylineFilter repository comment and ContentBylineFilter type docstring) are now trimmed to behavior-only statements. One minor AGENTS.md comment-discipline item remains in a docstring the PR edits: a stale GitHub issue reference in FilterBar. That's the only code-level note remaining.
Per the PR description, Discussion #2347 is still unapproved, so merging remains gated on maintainer direction there. Absent that process gate, this is close to an LGTM.
Co-authored-by: emdashbot[bot] <273199577+emdashbot[bot]@users.noreply.github.com>
There was a problem hiding this comment.
This is the right change for the problem described in Discussion #2347 (still pending approval). It extends the admin content list with a server-side byline filter, matches on translation_group values stored in _emdash_content_bylines, and resolves inferred credits at the list locale so filtering agrees with what the list renders. The correlated-EXISTS shape preserves the outer sort index, the 25-id schema cap avoids D1 bind-parameter blow-up, inferred/explicit semantics are handled consistently, and the 12 integration tests cover OR matching, no-byline, inferred opt-in/out, locale-scoped inference, status composition, and totals. No new queries are added to logged-out routes.
I re-read the diff, the full changed files, and relevant call sites/siblings. The previous comment-discipline finding in FilterBar is fixed. One stale GitHub issue reference remains, now in router.tsx directly above the newly-extended filter-state block. That’s the only code-level note. Fix it and this is a code-level LGTM. Merge remains gated on maintainer direction in Discussion #2347, not on the code review.
Findings
-
[needs fixing]
packages/admin/src/router.tsx:352-353Comment still references a GitHub issue (
#1288) in the filter-state block the PR extends. AGENTS.md says comments are for future readers, not PR/issue references. TheFilterBardocstring already dropped this reference; remove it here too.// Filter state. All are part of the query key so changing any of // them restarts the cursor chain from a filtered first page.
|
Ok I am done with the bot nitpicks. |
What does this PR do?
Adds byline filtering to the admin content list, alongside the existing status, author, and date filters. Selecting one or more bylines matches entries credited to any of them (OR); a No byline assigned option matches entries with no credit at all.
Bylines are searched server-side in the picker rather than listed exhaustively, so the filter works across the whole byline directory rather than one page.
Inferred bylines are excluded by default. An entry with no explicit credit still renders the byline linked to its author (see
hydrateBylinesMany), but filtering usually means "who is credited", not "whose name happens to show". A single Include inferred bylines switch opts into the wider behaviour, and it widens consistently: with it on, "No byline assigned" means nothing is rendered, so entries whose author resolves to a byline drop out too.Filter values are
translation_groups — what_emdash_content_bylines.byline_idhas stored since migration 040 — so a selection matches a byline across every locale it exists in.Important
Proposed in Discussion #2347 (Ideas). Please don't merge until a maintainer approves the direction there.
Discussion: #2347
Inferred credits resolve at the list's locale
The inferred branch originally matched on
author_idalone, against a set of users resolved from every locale row of the selected bylines. Byline hydration is strict per locale, so that disagreed with what the list actually rendered: an entry whose author owns a byline with no row at the list's locale displays as uncredited, yet still matched a filter on that byline — and matched No byline assigned at the same time, since that branch did correlate on locale. The same entry could appear under two mutually exclusive filters.It now resolves the credit the same way the list renders it: a correlated
EXISTSon_emdash_bylinesscoped to the locale the list is showing, falling back to each entry's own locale when the list spans locales. Byline translations start life with a nulluser_id, so a group translated into a locale but not re-linked correctly resolves to no credit.This also removes a query: the handler no longer pre-resolves author ids for
includeInferredBylines, so the opt-in costs no extra round-trip.No migration required
Verified with
EXPLAIN QUERY PLANagainst a freshly-migrated DB rather than by inspection, and re-measured after the locale change above. TheUNIQUE(collection_slug, content_id, byline_id)constraint from migration 031 already creates an index of exactly the right shape, and the inferred branch lands on the existing byline uniques:Every probe is an indexed seek, and the outer query keeps its sort-ordered composite index in all four shapes, so
LIMITstill short-circuits with noUSE TEMP B-TREE FOR ORDER BY.Two consequences are worth spelling out, since neither is visible from the query alone. The full reasoning lives here; the code comment states only the two invariants a future reader needs:
EXISTSshape matters. Driving from the pivot side (FROM _emdash_content_bylines JOIN ec_*) cannot use that index for the byline and addsUSE TEMP B-TREE FOR ORDER BY. Written as anEXISTSfrom the content table, no new index is needed; written the other way, no index rescues it.primary_byline_id. The two agree — both junction write paths stamp the column in the same call — but not atomically (D1 has no transactions), so the junction stays authoritative, mirroring how migration 051 treats the denormalized taxonomy columns as advisory and re-checks on read.Known limitation, deliberately not addressed: the
EXISTSplan walks the collection's sort index and probes per row, so a byline matching very few entries in a very large collection reads a lot before fillingLIMIT— the shape #1834/migration 051 fixed for taxonomies. Making that seek-optimal needs denormalization, i.e. a migration. This is the authenticated admin list rather than the logged-out hot path, so it didn't seem worth paying now. Happy to revisit.No queries were added to any logged-out route.
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.AI-generated code disclosure
Screenshots / test output
Rebased onto
main(currently215f36eb); the only conflict was an import block inContentList.tsxthat #2340 and this branch both added to, resolved by keeping both.Verified in the browser against
demos/simple: the filter renders in the existing filter bar as an "All bylines" dropdown containing a search box, the exclusive "No byline assigned" checkbox, the byline list, and the "Include inferred bylines" switch.Also checked in Arabic per the RTL guidance —
document.documentElement.dirisrtl, and the popover, checkboxes, switch, and caret all mirror correctly with no broken directionality. The selected-byline count goes through Lingui'spluralrather than a bare interpolation, which only reads correctly in languages with one plural form. (New strings render in English until the extraction workflow picks them up on merge; nomessages.pochanges are included here.)Screenshots can be attached on request — omitted here since I can't upload to GitHub's CDN from the CLI.
Tests
12 integration tests (
content-list-byline-filter.test.ts), run against both dialects viadescribeEachDialect, covering single/multi-byline OR matching, the no-byline filter, inferred credits on and off, an explicit credit suppressing author inference, inferred credits resolving at the locale the list is scoped to, composition with the status filter, andtotalreflecting the filter.Two of them caught real bugs during development:
eb.val(false), which better-sqlite3 refuses to bind (SQLite3 can only bind numbers, strings, bigints, buffers, and null). It now emits a literal1 = 0predicate instead.Behaviour verified end-to-end against
demos/simple, filtering a 16-entry collection:totalbylines=<A>bylines=<A>,<B>bylines=nonebylines=<unknown-id>bylines=<A>&status=published6 + 10 = 16 — the include and no-byline filters partition the collection exactly.
Wire contract:
includeInferredBylines=1/true/0/falseincludeInferredBylines=yesVALIDATION_ERRORbylines=(empty)VALIDATION_ERRORThe 25-id cap keeps the
IN (...)clause clear of D1's bound-parameter ceiling once the rest of the list query's placeholders are counted.Review responses
pullrequestreview-4867919957Over-narrative doc comment on
applyBylineFilter— fixed in10f415d4. Agreed, and taken essentially as suggested. The comment now states only the two invariants: that theEXISTScorrelates from the content table so the outer sort index survives, and thatmode: "none"tests the junction rather thanprimary_byline_idbecause the two aren't written atomically. The rejected pivot-side shape, theEXPLAIN QUERY PLANoutput, and the migration 051 precedent stay in this description, where they don't go stale in the source tree. The paragraph above that introduced them has been reworded, since it claimed those details were recorded in comments.Discussion approval. Correct — #2347 is still unapproved and the Discussion checklist item is unticked, so this shouldn't merge until a maintainer weighs in on the direction. Earlier revisions of this description said the PR "stays a draft"; that was inaccurate, it is open for review rather than in draft state. Corrected above.
pullrequestreview-4868375437Justification sentence in the
ContentBylineFilterdocstring — fixed in763e417b. Taken as suggested; the docstring now ends at whatincludeInferreddoes, and the rationale for the default lives in this description.A request on review process. This is the second review that confirms the approach, confirms the tests, confirms the query counts, and then holds approval for a single sentence in a comment. Both sentences were in the diff at the time of the first review — the second one wasn't introduced by the fix for the first. Reviewing one comment per round turns a two-line docs change into a multi-day round-trip for no added signal.
Please review the whole diff each time and give every objection in one go, ordered by severity, including the ones you consider minor. If a nit is genuinely not worth blocking on, raise it as a non-blocking note and approve rather than withholding approval for it. Absent a new finding of substance, this should be approved on the next pass.
Discussion approval stands as noted: #2347 is still unapproved, the checklist item stays unticked, and this shouldn't merge until a maintainer weighs in on the direction. That's a merge gate, not a review gate — it isn't a reason to withhold code-level approval.
pullrequestreview-4872763040Rationale paragraph in the
BylineFiltercomponent docstring — fixed in6d39c21a. Taken as suggested; the docstring now states only what the component does.On the previous round's request, which wasn't followed. That comment has been in the diff unchanged since
a3a13b52on 31 July — the original commit of this branch. It was there forpullrequestreview-4867919957, forpullrequestreview-4868375437, and for this one. Same for the two comments raised before it: every one of the three was present in the first version reviewed, and none was introduced by a fix for an earlier round.So three review rounds have produced three separate single-nit findings of the same class, from the same unchanged files, each one held as the reason not to approve. The previous round asked explicitly for all objections in one pass ordered by severity. That request was acknowledged in neither the review body nor its scope.
Concretely, for the next pass: re-read every file in the diff, not just the ones touched since the last review, and list every remaining objection at once — including ones you'd rate minor. If nothing of substance remains, approve and attach any residual nits as non-blocking notes. Another round that surfaces one more pre-existing comment nit and withholds approval on it is not a useful review, and I'll ask a maintainer to take the code-level pass instead.
Discussion approval is unchanged: #2347 is still unapproved and this shouldn't merge until a maintainer weighs in on the direction. That remains a merge gate, not a code-review gate.