Skip to content
Merged
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}_loc_upd` / `idx_{table}_loc_crt`) 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