Skip to content

fix(admin): make content list search work across the whole collection#752

Open
edrpls wants to merge 3 commits into
emdash-cms:mainfrom
edrpls:fix/content-list-search
Open

fix(admin): make content list search work across the whole collection#752
edrpls wants to merge 3 commits into
emdash-cms:mainfrom
edrpls:fix/content-list-search

Conversation

@edrpls
Copy link
Copy Markdown
Contributor

@edrpls edrpls commented Apr 24, 2026

What does this PR do?

Fixes the admin content list search so it actually finds existing content. Steps to reproduce on main:

  1. Open /_emdash/admin/content/{collection} for any collection with more entries than fit in a single API fetch (default limit: 100).
  2. Type a substring that matches the title or slug of an entry that exists past that first page boundary.
  3. Expected: the entry appears. Actual: zero results.

Root cause: the admin's search input filters items already in the client-side accumulator (items.filter(i => getItemTitle(i).toLowerCase().includes(q))). The API is called with limit: 100 and no search parameter, so any entry past the first 100-item fetch is invisible to search until the user manually pages forward enough to load it. From the user's perspective, "search is broken."

Fix: server-side search via a new q query parameter.

Split from the (now closed) #749 — this is the third of three independent PRs. The other two are #750 sortable headers and #751 stable pagination denominator.

Changes

  • GET /_emdash/api/content/{collection} accepts q. ContentRepository.findMany introspects the collection's table columns via a new listTableColumns helper (pragma_table_info on SQLite, information_schema.columns on Postgres), intersects with a hardcoded ["title", "name", "slug"] whitelist, and builds LOWER(col) LIKE LOWER(?) ESCAPE '\\'. %, _, and \ in the input are escaped; input is trimmed and capped at 200 chars.
  • count() honors the same search filter, so if a later change (or fix(admin): stable pagination denominator via total on the list response #751) reads the count for a search UI, it matches.
  • Admin list debounces the input by 300ms and includes q in the infinite-query key so switching queries resets the cursor chain. <ContentList> now has a controlled-search contract (searchQuery + onSearchChange); legacy callers that don't opt in keep the original client-side filter.
  • ContentPickerModal migrated to the same server-side search — previously it had the identical client-side bug. Added placeholderData: keepPreviousData so the dropdown doesn't flash to empty between keystrokes.
  • MCP content_list tool accepts q, so agents don't have to post-filter either. Same content:read scope gate; no auth-boundary change.

Security notes

  • pragma_table_info(?) on SQLite is parameterized by better-sqlite3 12.x (verified). validateIdentifier(tableName) is belt-and-braces.
  • sql.ref(col) wraps columns from the compile-time constant SEARCHABLE_COLUMNS. No user-supplied strings reach identifier position.
  • LIKE escape order (\\ before % and _) is correct.
  • The query-budget cost is one LIKE per list request; authenticated admin-only. No regression vs. the existing count() call pattern.

Type of change

  • Bug fix
  • Feature
  • Refactor
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes (admin + core)
  • pnpm lint passes (no new findings)
  • Targeted tests pass: 60 repo tests, 22 handler tests, 34 ContentList component tests
  • pnpm format has been run
  • Added regression tests — substring match, case insensitivity, slug-only collection (no title/name columns), LIKE escape, empty-input handling, pathological input truncation, handler-level q, and controlled-search callback assertion
  • No new admin strings; locale catalogs only show line-number shifts
  • Added a changeset (patch for both packages)

AI-generated code disclosure

  • This PR includes AI-generated code

Produced in a Claude Code session. Reviewed by Code Reviewer, Security Engineer, and Accessibility Auditor sub-agents against the combined branch before splitting; Security Engineer specifically verified the pragma_table_info parameterization and LIKE escape correctness.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 24, 2026

🦋 Changeset detected

Latest commit: 9f581b8

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

This PR includes changesets to release 13 packages
Name Type
emdash Patch
@emdash-cms/admin Patch
@emdash-cms/cloudflare Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@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 1,912 lines across 29 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

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
packages/admin/src/locales/ar/messages.po Localization changed, will be marked as complete.
packages/admin/src/locales/de/messages.po Localization changed, will be marked as complete.
packages/admin/src/locales/en/messages.po Source changed, localizations will be marked as outdated.
packages/admin/src/locales/es-419/messages.po Localization changed, will be marked as complete.
packages/admin/src/locales/eu/messages.po Localization changed, will be marked as complete.
packages/admin/src/locales/fa/messages.po Localization changed, will be marked as complete.
packages/admin/src/locales/fr/messages.po Localization changed, will be marked as complete.
packages/admin/src/locales/ja/messages.po Localization changed, will be marked as complete.
packages/admin/src/locales/ko/messages.po Localization changed, will be marked as complete.
packages/admin/src/locales/pseudo/messages.po Localization changed, will be marked as complete.
packages/admin/src/locales/pt-BR/messages.po Localization changed, will be marked as complete.
packages/admin/src/locales/zh-CN/messages.po Localization changed, will be marked as complete.
packages/admin/src/locales/zh-TW/messages.po Localization changed, will be marked as complete.
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 24, 2026

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

emdash

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

create-emdash

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

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

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 9f581b8

@github-actions
Copy link
Copy Markdown
Contributor

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

This PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity.

If you're still working on this, please push an update or leave a comment.

@edrpls
Copy link
Copy Markdown
Contributor Author

edrpls commented May 16, 2026

Rebased on upstream main (f28eb7b). Resolved orthogonal conflicts with #750 (sort headers, since merged) — <ContentList> now takes both sort/onSortChange and searchQuery/onSearchChange props; router threads sort + debounced search through the same TanStack Query key; fetchContentList accepts both orderBy/order and q; describe("orderBy"), describe("search"), and describe("sortable headers") test blocks coexist. Locale .po files reset to upstream for Lunaria re-extraction.

Local test runs after rebase, on top of f28eb7b:

  • packages/core/tests/database/repositories/content.test.ts: 51/51 pass (includes 6 new search tests)
  • packages/core/tests/unit/api/content-handlers.test.ts: 33/33 pass
  • packages/admin/tests/components/ContentList.test.tsx: 38/38 pass

Bumping out of stale-warning territory.

edrpls and others added 2 commits May 18, 2026 13:52
The admin search input filtered `items` already in memory — so any entry
past the first 100-item API fetch was invisible to search until the
user paged forward enough to load it. From the user's perspective
typing "Bleeding" on a 143-entry `films` collection returned zero
results even when a published `Bleeding` film existed on page 2.

Changes:
- `GET /_emdash/api/content/{collection}` accepts a `q` query parameter.
  Case-insensitive LIKE against whichever of `title`, `name`, `slug`
  actually exist on the collection's table. Columns are introspected
  at query time via `pragma_table_info` / `information_schema.columns`;
  LIKE wildcards are escaped; input is trimmed and capped at 200 chars.
- Admin list debounces the input by 300ms and pushes `q` through the
  infinite-query key so switching queries resets the cursor chain.
- `<ContentList>` gains a controlled-search API (`searchQuery` +
  `onSearchChange`). Legacy callers that don't opt in keep the original
  client-side filter.
- `ContentPickerModal` migrated to the same server-side search; uses
  `keepPreviousData` so the dropdown doesn't flash to empty between
  keystrokes.
- MCP `content_list` tool accepts `q` too, so agents don't have to
  post-filter either.

Regression tests cover substring match, case insensitivity, slug-only
collections, LIKE escape, empty-input handling, pathological input
truncation, and an admin-level controlled-search callback assertion.
@edrpls edrpls force-pushed the fix/content-list-search branch from b8f394c to 6704774 Compare May 18, 2026 19:54
@edrpls
Copy link
Copy Markdown
Contributor Author

edrpls commented May 18, 2026

Rebased on upstream main (aaf021c) to clear conflicts after #751 merged.

Conflicts resolved (all orthogonal to #751's now-landed total/pagination machinery — same files, adjacent additions):

  • packages/admin/src/components/ContentList.tsx — kept total + clampedPage/paginatedItems from main alongside new searchQuery/onSearchChange props. The auto-fetch effect now bails on client-side search only (!serverSideSearch && searchQuery), so server-driven search can still page forward into the filtered set.
  • packages/admin/src/router.tsx — both total={total} and searchQuery/onSearchChange props pass through; the existing useInfiniteQuery key already threads sort + q: debouncedSearch so cache invalidation is correct.
  • packages/core/src/database/repositories/content.ts — auto-merged. findMany now runs count(type, where) in parallel via Promise.all, and count itself resolves searchSpec so the denominator respects an active query.
  • packages/core/tests/database/repositories/content.test.ts + tests/unit/api/content-handlers.test.ts — kept both describe("total" / "list total") and describe("search" / "list search") blocks side-by-side instead of choosing one.

Local verification against aaf021c:

Suite Result
packages/core repo + handler tests (content.test.ts + content-handlers.test.ts) 88 / 88
packages/core dialect tests (dialect-compat + dialect-runtime-imports) 17 / 17
packages/admin ContentList.test.tsx 39 / 39
pnpm --filter emdash --filter @emdash-cms/admin typecheck clean

@github-actions github-actions Bot mentioned this pull request May 22, 2026
17 tasks
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.

1 participant