Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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);
Comment on lines +18 to +36
}
}

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