Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-content-list-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"emdash": patch
"@emdash-cms/admin": patch
---

Make content list search work on large collections (#1219). The admin content list previously filtered only the rows already loaded on the current page, so an entry far back in a big collection could not be found until you navigated near it. The list endpoint now accepts a `q` parameter and performs a case-insensitive substring search across the collection's title/name/slug columns server-side (LIKE wildcards in the query are escaped), and the admin search box drives that query (debounced) instead of filtering in memory. Also adds locale-aware composite indexes (`idx_{table}_deleted_locale_updated_id` / `_created_id`) so locale-filtered content lists stay index-served on large, i18n-enabled tables.
39 changes: 31 additions & 8 deletions packages/admin/src/components/ContentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Link } from "@tanstack/react-router";
import * as React from "react";

import type { ContentItem, TrashedContentItem } from "../lib/api";
import { useDebouncedValue } from "../lib/hooks.js";
import { contentUrl } from "../lib/url.js";
import { cn } from "../lib/utils";
import { CaretNext, CaretPrev } from "./ArrowIcons.js";
Expand Down Expand Up @@ -68,6 +69,13 @@ export interface ContentListProps {
* growing as more API pages are fetched.
*/
total?: number;
/**
* When provided, search is performed server-side: the (debounced) query is
* reported here so the caller can refetch, and `items`/`total` are assumed
* to already reflect the filter. Without it, the list falls back to
* filtering the loaded page client-side (legacy behavior).
*/
onSearchChange?: (q: string) => void;
}

type ViewTab = "all" | "trash";
Expand Down Expand Up @@ -111,29 +119,41 @@ export function ContentList({
sort,
onSortChange,
total,
onSearchChange,
}: ContentListProps) {
const { t } = useLingui();
const [activeTab, setActiveTab] = React.useState<ViewTab>("all");
const [searchQuery, setSearchQuery] = React.useState("");
const [page, setPage] = React.useState(0);

// Server-side search mode: the caller refetches based on the (debounced)
// query, so `items`/`total` already reflect the filter and we must not
// re-filter client-side (that would re-introduce the "only matches the
// loaded page" bug for non-title columns).
const serverSearch = !!onSearchChange;
const debouncedSearch = useDebouncedValue(searchQuery, 300);
React.useEffect(() => {
if (onSearchChange) onSearchChange(debouncedSearch.trim());
}, [debouncedSearch, onSearchChange]);

// Reset page when search changes
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
setPage(0);
};

const filteredItems = React.useMemo(() => {
if (!searchQuery) return items;
if (serverSearch || !searchQuery) return items;
const query = searchQuery.toLowerCase();
return items.filter((item) => getItemTitle(item).toLowerCase().includes(query));
}, [items, searchQuery]);
}, [items, searchQuery, serverSearch]);

// When the server reports a total, it's the source of truth for the
// denominator. Otherwise fall back to the size of the (possibly partial)
// client list, matching pre-refactor behavior. Client-side search always
// defers to `filteredItems` because `total` reflects the unfiltered set.
const effectiveTotal = typeof total === "number" && !searchQuery ? total : filteredItems.length;
// denominator. In server-search mode that total already reflects the query,
// so we use it even while searching; in client mode an active query falls
// back to the filtered client count.
const effectiveTotal =
typeof total === "number" && (serverSearch || !searchQuery) ? total : filteredItems.length;
const totalPages = Math.max(1, Math.ceil(effectiveTotal / PAGE_SIZE));

// Clamp the current page in case filters collapse the count (user was on
Expand All @@ -154,12 +174,15 @@ export function ContentList({
// The router wires this to TanStack Query's `fetchNextPage`, which is
// idempotent while a fetch is in flight.
React.useEffect(() => {
if (!hasMore || !onLoadMore || searchQuery) return;
// In client-search mode we skip auto-fetch while a query is active
// (filtering can collapse the list). In server-search mode the loaded
// items already are the matches, so paging forward should keep fetching.
if (!hasMore || !onLoadMore || (!serverSearch && searchQuery)) return;
const loadedPages = Math.ceil(filteredItems.length / PAGE_SIZE);
if (clampedPage >= loadedPages - 1) {
onLoadMore();
}
}, [clampedPage, filteredItems.length, hasMore, onLoadMore, searchQuery]);
}, [clampedPage, filteredItems.length, hasMore, onLoadMore, searchQuery, serverSearch]);

return (
<div className="space-y-4">
Expand Down
3 changes: 3 additions & 0 deletions packages/admin/src/lib/api/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ export async function fetchContentList(
orderBy?: string;
/** Sort direction; defaults to "desc" on the server. */
order?: "asc" | "desc";
/** Case-insensitive substring search across title/name/slug. */
search?: string;
},
): Promise<FindManyResult<ContentItem>> {
const params = new URLSearchParams();
Expand All @@ -152,6 +154,7 @@ export async function fetchContentList(
if (options?.locale) params.set("locale", options.locale);
if (options?.orderBy) params.set("orderBy", options.orderBy);
if (options?.order) params.set("order", options.order);
if (options?.search) params.set("q", options.search);

const url = `${API_BASE}/content/${collection}${params.toString() ? `?${params}` : ""}`;
const response = await apiFetch(url);
Expand Down
8 changes: 7 additions & 1 deletion packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -307,16 +307,21 @@ function ContentListPage() {
direction: "desc",
});

// Server-side search term (debounced inside ContentList). Part of the query
// key so a new term restarts the cursor chain from a filtered first page.
const [searchTerm, setSearchTerm] = React.useState("");

const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error } =
useInfiniteQuery({
queryKey: ["content", collection, { locale: activeLocale, sort }],
queryKey: ["content", collection, { locale: activeLocale, sort, search: searchTerm }],
queryFn: ({ pageParam }) =>
fetchContentList(collection, {
locale: activeLocale,
cursor: pageParam,
limit: 100,
orderBy: sort.field,
order: sort.direction,
search: searchTerm || undefined,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
Expand Down Expand Up @@ -441,6 +446,7 @@ function ContentListPage() {
sort={sort}
onSortChange={setSort}
total={total}
onSearchChange={setSearchTerm}
/>
);
}
Expand Down
26 changes: 26 additions & 0 deletions packages/admin/tests/components/ContentList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,32 @@ describe("ContentList", () => {

await expect.element(screen.getByText(NO_RESULTS_PATTERN)).toBeInTheDocument();
});

// #1219: when the caller opts into server-side search it reports the
// (debounced) query and must NOT also filter the loaded page client-side
// (the server already returned the matching rows).
it("reports the query upward and does not client-filter in server mode", async () => {
const onSearchChange = vi.fn();
const items = [
makeItem({ id: "1", data: { title: "Alpha post" } }),
makeItem({ id: "2", data: { title: "Beta post" } }),
];
const screen = await render(
<ContentList {...defaultProps} items={items} onSearchChange={onSearchChange} />,
);

await screen.getByRole("searchbox").fill("beta");

// Debounced callback fires with the typed term.
await vi.waitFor(() => {
expect(onSearchChange).toHaveBeenCalledWith("beta");
});

// Server mode shows whatever `items` the caller supplied — it does not
// hide "Alpha post" by filtering locally.
await expect.element(screen.getByText("Alpha post")).toBeInTheDocument();
await expect.element(screen.getByText("Beta post")).toBeInTheDocument();
});
});

describe("pagination", () => {
Expand Down
42 changes: 41 additions & 1 deletion packages/core/src/api/handlers/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,34 @@ export interface TrashedContentItem {
deletedAt: string;
}

/**
* Resolve the columns a content-list search should match against. Always
* includes `slug` (a standard column) and adds the `title`/`name` display
* fields when the collection actually defines them, mirroring the admin's
* item-title resolution (title -> name -> slug). Returning only existing
* columns avoids "no such column" errors on collections without them.
*/
async function resolveSearchColumns(db: Kysely<Database>, collection: string): Promise<string[]> {
const columns = ["slug"];
const row = await db
.selectFrom("_emdash_collections")
.select("id")
.where("slug", "=", collection)
.executeTakeFirst();
if (!row) return columns;

const fields = await db
.selectFrom("_emdash_fields")
.select("slug")
.where("collection_id", "=", row.id)
.execute();
const fieldSlugs = new Set(fields.map((f) => f.slug));
for (const candidate of ["title", "name"]) {
if (fieldSlugs.has(candidate)) columns.push(candidate);
}
return columns;
}

/**
* Create content list handler
*/
Expand All @@ -308,14 +336,26 @@ export async function handleContentList(
orderBy?: string;
order?: "asc" | "desc";
locale?: string;
q?: string;
},
): Promise<ApiResult<ContentListResponse>> {
try {
const repo = new ContentRepository(db);
const where: { status?: string; locale?: string } = {};
const where: {
status?: string;
locale?: string;
q?: string;
searchColumns?: string[];
} = {};
if (params.status) where.status = params.status;
if (params.locale) where.locale = params.locale;

const q = params.q?.trim();
if (q) {
where.q = q;
where.searchColumns = await resolveSearchColumns(db, collection);
}

const result = await repo.findMany(collection, {
cursor: params.cursor,
limit: params.limit || 50,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/api/schemas/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export const contentListQuery = cursorPaginationQuery
orderBy: z.string().optional(),
order: z.enum(["asc", "desc"]).optional(),
locale: localeCode.optional(),
/** Case-insensitive substring search across the collection's title/name/slug. */
q: z.string().trim().min(1).max(200).optional(),
})
.meta({ id: "ContentListQuery" });

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Kysely } from "kysely";
import { sql } from "kysely";

import { listTablesLike } from "../dialect-helpers.js";

/**
* Migration: locale-aware composite indexes for content list queries.
*
* Addresses GitHub issue #1219. When i18n is enabled the admin content list
* filters by `locale` and orders by `updated_at`/`created_at`. The existing
* composite indexes (033/034) cover `(deleted_at, updated_at DESC, id DESC)`
* etc. but omit `locale`, so a locale-filtered ordered list can't be served
* by a single index on large tables. These indexes restore index-only paging
* for the locale-scoped case.
*
* Forward-only and idempotent (`IF NOT EXISTS`).
*/
export async function up(db: Kysely<unknown>): Promise<void> {
const tableNames = await listTablesLike(db, "ec_%");

for (const tableName of tableNames) {
await sql`
CREATE INDEX IF NOT EXISTS ${sql.ref(`idx_${tableName}_deleted_locale_updated_id`)}
ON ${sql.ref(tableName)} (deleted_at, locale, updated_at DESC, id DESC)
`.execute(db);

await sql`
CREATE INDEX IF NOT EXISTS ${sql.ref(`idx_${tableName}_deleted_locale_created_id`)}
ON ${sql.ref(tableName)} (deleted_at, locale, created_at DESC, id DESC)
`.execute(db);
}
}

export async function down(db: Kysely<unknown>): Promise<void> {
const tableNames = await listTablesLike(db, "ec_%");

for (const tableName of tableNames) {
await sql`DROP INDEX IF EXISTS ${sql.ref(`idx_${tableName}_deleted_locale_updated_id`)}`.execute(
db,
);
await sql`DROP INDEX IF EXISTS ${sql.ref(`idx_${tableName}_deleted_locale_created_id`)}`.execute(
db,
);
}
}
2 changes: 2 additions & 0 deletions packages/core/src/database/migrations/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import * as m037 from "./037_credential_algorithm.js";
import * as m038 from "./038_registry_plugin_state.js";
import * as m039 from "./039_fix_fts5_triggers.js";
import * as m040 from "./040_byline_i18n.js";
import * as m041 from "./041_content_locale_list_index.js";

const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
"001_initial": m001,
Expand Down Expand Up @@ -83,6 +84,7 @@ const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
"038_registry_plugin_state": m038,
"039_fix_fts5_triggers": m039,
"040_byline_i18n": m040,
"041_content_locale_list_index": m041,
});

/** Total number of registered migrations. Exported for use in tests. */
Expand Down
46 changes: 43 additions & 3 deletions packages/core/src/database/repositories/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import { EmDashValidationError, encodeCursor, decodeCursor } from "./types.js";
// Regex pattern for ULID validation
const ULID_PATTERN = /^[0-9A-Z]{26}$/;

// LIKE wildcards that must be escaped so user search input is matched literally.
const LIKE_WILDCARD_RE = /[\\%_]/g;

/**
* System columns that exist in every ec_* table
*/
Expand Down Expand Up @@ -489,6 +492,8 @@ export class ContentRepository {
query = query.where("locale" as any, "=", options.where.locale);
}

query = this.applySearchFilter(query, options.where);

// Handle cursor pagination — decodeCursor throws InvalidCursorError
// on malformed input; let it propagate so handlers surface a
// structured INVALID_CURSOR rather than silently returning page 1.
Expand Down Expand Up @@ -519,8 +524,8 @@ export class ContentRepository {
.limit(limit + 1);

// Run the page fetch and the unbounded count together — the UI needs
// both to render a stable denominator, and issuing them in parallel
// on SQLite is essentially free.
// both to render a stable denominator (kept on every page intentionally),
// and issuing them in parallel on SQLite is essentially free.
const [rows, total] = await Promise.all([query.execute(), this.count(type, options.where)]);
const hasMore = rows.length > limit;
const items = rows.slice(0, limit);
Expand Down Expand Up @@ -753,12 +758,45 @@ export class ContentRepository {
return Number(result?.count || 0);
}

/**
* Apply the optional case-insensitive `q` substring filter across the
* handler-resolved `searchColumns` (OR'd). User input is treated literally
* (LIKE wildcards escaped) and `lower()` is applied on both sides for
* SQLite/Postgres case-insensitive parity.
*/
private applySearchFilter<QB extends { where: (cb: (eb: any) => unknown) => QB }>(
query: QB,
where?: { q?: string; searchColumns?: string[] },
): QB {
const term = where?.q?.trim();
const columns = where?.searchColumns;
if (!term || !columns || columns.length === 0) return query;

const escaped = term.replace(LIKE_WILDCARD_RE, (c) => `\\${c}`);
const pattern = `%${escaped}%`;

return query.where((eb) =>
eb.or(
columns.map((col) => {
validateIdentifier(col, "search column");
return eb(sql`lower(${sql.ref(col)})`, "like", sql`lower(${pattern}) escape '\\'`);
}),
),
);
}

/**
* Count content items
*/
async count(
type: string,
where?: { status?: string; authorId?: string; locale?: string },
where?: {
status?: string;
authorId?: string;
locale?: string;
q?: string;
searchColumns?: string[];
},
): Promise<number> {
const tableName = getTableName(type);

Expand All @@ -779,6 +817,8 @@ export class ContentRepository {
query = query.where("locale" as any, "=", where.locale);
}

query = this.applySearchFilter(query, where);

const result = await query.executeTakeFirst();
return Number(result?.count || 0);
}
Expand Down
Loading
Loading