Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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.
84 changes: 62 additions & 22 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,46 @@ 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]);

// The query the current `items` reflect: server-side filtering lags behind
// typing by the debounce, so the empty-state message must use the debounced
// term; client-side filtering is immediate, so it uses the live query.
const activeSearch = serverSearch ? debouncedSearch.trim() : searchQuery;

// 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 +179,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 Expand Up @@ -188,7 +216,7 @@ export function ContentList({
</div>

{/* Search */}
{items.length > 0 && (
{(serverSearch || items.length > 0) && (
<div className="relative max-w-sm">
<MagnifyingGlass className="absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
<Input
Expand Down Expand Up @@ -276,21 +304,27 @@ export function ContentList({
) : items.length === 0 ? (
<tr>
<td colSpan={i18n ? 5 : 4} className="px-4 py-8 text-center text-kumo-subtle">
{t`No ${collectionLabel.toLowerCase()} yet.`}{" "}
<Link
to="/content/$collection/new"
params={{ collection }}
search={{ locale: activeLocale }}
className="text-kumo-brand underline"
>
{t`Create your first one`}
</Link>
{activeSearch ? (
t`No results for "${activeSearch}"`
) : (
<>
{t`No ${collectionLabel.toLowerCase()} yet.`}{" "}
<Link
to="/content/$collection/new"
params={{ collection }}
search={{ locale: activeLocale }}
className="text-kumo-brand underline"
>
{t`Create your first one`}
</Link>
</>
)}
</td>
</tr>
) : paginatedItems.length === 0 ? (
<tr>
<td colSpan={i18n ? 5 : 4} className="px-4 py-8 text-center text-kumo-subtle">
{t`No results for "${searchQuery}"`}
{t`No results for "${activeSearch}"`}
</td>
</tr>
) : (
Expand All @@ -315,10 +349,11 @@ export function ContentList({
<div className="flex items-center justify-between">
<span className="text-sm text-kumo-subtle">
{renderItemCount({
searchQuery,
searchQuery: activeSearch,
filteredCount: filteredItems.length,
total,
hasMore,
serverSearch,
})}
</span>
<div className="flex items-center gap-2">
Expand Down Expand Up @@ -485,7 +520,9 @@ function SortableTh({ field, sort, onSortChange, label }: SortableThProps) {

/**
* Render the row-count line above pagination. The rules are:
* - A search query always wins — say how many matches there are.
* - A search query always wins — say how many matches there are. In
* server-search mode the server reports the full match count via `total`;
* `filteredCount` is only the loaded page, so it would undercount.
* - When the server reported a total, use it (no `+` suffix needed —
* we know the count).
* - Otherwise fall back to the pre-refactor behavior: loaded count,
Expand All @@ -496,14 +533,17 @@ function renderItemCount({
filteredCount,
total,
hasMore,
serverSearch,
}: {
searchQuery: string;
filteredCount: number;
total: number | undefined;
hasMore: boolean | undefined;
serverSearch: boolean;
}): string {
if (searchQuery) {
return plural(filteredCount, {
const matchCount = serverSearch && typeof total === "number" ? total : filteredCount;
return plural(matchCount, {
one: `# item matching "${searchQuery}"`,
other: `# items matching "${searchQuery}"`,
});
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
74 changes: 74 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,80 @@ 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();
});

// #1219: in server mode a zero-match query empties `items`. The search box
// must stay mounted so the user can clear the query.
it("keeps the search input mounted in server mode when there are no items", async () => {
const onSearchChange = vi.fn();
const screen = await render(
<ContentList {...defaultProps} items={[]} onSearchChange={onSearchChange} />,
);
await expect
.element(screen.getByRole("searchbox", { name: "Search posts" }))
.toBeInTheDocument();
});

// #1219: a zero-match server search must not show "Create your first one"
// (there is content, it just doesn't match), it must report the query.
it("shows a no-results message, not the empty state, for a zero-match server search", async () => {
const onSearchChange = vi.fn();
const screen = await render(
<ContentList {...defaultProps} items={[]} total={0} onSearchChange={onSearchChange} />,
);

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

await expect.element(screen.getByText(NO_RESULTS_PATTERN)).toBeInTheDocument();
expect(screen.getByText("Create your first one").query()).toBeNull();
});

// #1219: the match count must come from the server `total`, not the loaded
// page length, otherwise it undercounts when matches span multiple pages.
it("counts server-search matches using total, not the loaded page", async () => {
const onSearchChange = vi.fn();
const items = Array.from({ length: 20 }, (_, i) =>
makeItem({ id: `item_${i}`, data: { title: `Post ${i}` } }),
);
const screen = await render(
<ContentList
{...defaultProps}
items={items}
total={143}
hasMore={true}
onSearchChange={onSearchChange}
/>,
);

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

await expect.element(screen.getByText(/143 items matching "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
Loading
Loading