Skip to content
Open
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
12 changes: 12 additions & 0 deletions .changeset/fix-content-list-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"emdash": patch
"@emdash-cms/admin": patch
---

Make the admin content list search work across the whole collection. Previously the search input filtered `items` already in memory — any entry past the first 100-item API fetch was invisible to search until the user manually paged forward enough to load it.

`GET /_emdash/api/content/{collection}` now accepts a `q` query parameter — a case-insensitive substring match against whichever of `title`, `name`, `slug` actually exist on the collection's table (introspected via `pragma_table_info` / `information_schema.columns`). LIKE wildcards are escaped; input is trimmed and capped at 200 chars.

The admin list debounces the input by 300ms and pushes `q` through the infinite-query key so switching searches resets the cursor chain. `ContentPickerModal` migrated to the same server-side search and uses `keepPreviousData` so the dropdown doesn't flash to empty between keystrokes.

The MCP `content_list` tool also accepts `q`, so agents don't have to post-filter either.
41 changes: 32 additions & 9 deletions packages/admin/src/components/ContentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ export interface ContentListProps {
* growing as more API pages are fetched.
*/
total?: number;
/**
* Controlled search query. When `onSearchChange` is also provided, the
* input becomes parent-controlled and local client-side filtering is
* disabled — the parent is expected to drive filtering via the API.
*/
searchQuery?: string;
onSearchChange?: (query: string) => void;
}

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

// Reset page when search changes
// Server-driven search kicks in when the parent opts in with onSearchChange.
// In that mode `items` is already the filtered set — we forward the input
// instead of filtering locally. Legacy mode keeps the original client-side
// filter so existing callers (e.g. the picker before it migrated) still
// work.
const serverSideSearch = typeof onSearchChange === "function";
const searchQuery = serverSideSearch ? (searchQueryProp ?? "") : localSearchQuery;

const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
const next = e.target.value;
if (serverSideSearch) {
onSearchChange?.(next);
} else {
setLocalSearchQuery(next);
}
setPage(0);
};

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

// 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)
Expand All @@ -146,20 +167,22 @@ export function ContentList({
);

// Auto-fetch the next API page when the user is on a client page whose
// items haven't been loaded yet. Skip during client-side search because
// items haven't been loaded yet. Skip during *client-side* search because
// filtering can collapse `filteredItems` below the loaded count and
// trigger a spurious fetch.
// trigger a spurious fetch. Server-side search has no such concern — the
// server returns the filtered set with its own pagination.
//
// Safety: relies on `onLoadMore` being deduped against concurrent calls.
// 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;
if (!hasMore || !onLoadMore) return;
if (!serverSideSearch && 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, serverSideSearch]);

return (
<div className="space-y-4">
Expand Down
24 changes: 15 additions & 9 deletions packages/admin/src/components/ContentPickerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { Button, Dialog, Input, Loader, Select } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { MagnifyingGlass, FolderOpen, X } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import * as React from "react";

import { fetchCollections, fetchContentList, getDraftStatus } from "../lib/api";
Expand Down Expand Up @@ -55,13 +55,21 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick
}
}, [collections, selectedCollection]);

// Push search to the server so the picker can find items across the
// entire collection, not just whatever has already been scrolled into
// view. Falls back to no-search when the box is empty.
const searchParam = debouncedSearch.trim() || undefined;
const { data: contentResult, isLoading: contentLoading } = useQuery({
queryKey: ["content-picker", selectedCollection, { limit: 50 }],
queryFn: () => fetchContentList(selectedCollection, { limit: 50 }),
queryKey: ["content-picker", selectedCollection, { limit: 50, q: searchParam }],
queryFn: () => fetchContentList(selectedCollection, { limit: 50, q: searchParam }),
enabled: open && !!selectedCollection,
// Keep the previous page's rows visible while the debounced search
// refetches, so the list doesn't flash to empty between keystrokes.
placeholderData: keepPreviousData,
});

// Sync initial page into accumulated items
// Sync initial page into accumulated items. The query re-runs when the
// debounced search changes, so we reset the accumulator each time.
React.useEffect(() => {
if (contentResult) {
setAllItems(contentResult.items);
Expand All @@ -76,6 +84,7 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick
const result = await fetchContentList(selectedCollection, {
limit: 50,
cursor: nextCursor,
q: searchParam,
});
setAllItems((prev) => [...prev, ...result.items]);
setNextCursor(result.nextCursor);
Expand All @@ -84,11 +93,8 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick
}
};

const filteredItems = React.useMemo(() => {
if (!debouncedSearch) return allItems;
const query = debouncedSearch.toLowerCase();
return allItems.filter((item) => getItemTitle(item).toLowerCase().includes(query));
}, [allItems, debouncedSearch]);
// Items arrive pre-filtered from the server; alias for readability.
const filteredItems = allItems;

// Reset state when modal opens or collection changes
React.useEffect(() => {
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, and slug. */
q?: 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?.q) params.set("q", options.q);

const url = `${API_BASE}/content/${collection}${params.toString() ? `?${params}` : ""}`;
const response = await apiFetch(url);
Expand Down
11 changes: 10 additions & 1 deletion packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import {
bulkCommentAction,
type CommentStatus,
} from "./lib/api/comments";
import { useDebouncedValue } from "./lib/hooks";
import { usePluginPage } from "./lib/plugin-context";
import { getPluginBlocks } from "./lib/pluginBlocks";
import { sanitizeRedirectUrl } from "./lib/url";
Expand Down Expand Up @@ -307,16 +308,22 @@ function ContentListPage() {
direction: "desc",
});

// Keep the raw search local for instant feedback; debounce before firing
// the API call so typing doesn't stampede the server.
const [searchQuery, setSearchQuery] = React.useState("");
const debouncedSearch = useDebouncedValue(searchQuery.trim(), 300);

const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error } =
useInfiniteQuery({
queryKey: ["content", collection, { locale: activeLocale, sort }],
queryKey: ["content", collection, { locale: activeLocale, sort, q: debouncedSearch }],
queryFn: ({ pageParam }) =>
fetchContentList(collection, {
locale: activeLocale,
cursor: pageParam,
limit: 100,
orderBy: sort.field,
order: sort.direction,
q: debouncedSearch || undefined,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
Expand Down Expand Up @@ -441,6 +448,8 @@ function ContentListPage() {
sort={sort}
onSortChange={setSort}
total={total}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
);
}
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 @@ -538,4 +538,30 @@ describe("ContentList", () => {
expect(screen.getByRole("button", { name: "Title" }).query()).toBeNull();
});
});

describe("controlled search", () => {
it("forwards the typed query to onSearchChange without local filtering", async () => {
const onSearchChange = vi.fn();
const items = [
makeItem({ id: "1", data: { title: "Alpha" } }),
makeItem({ id: "2", data: { title: "Beta" } }),
];
const screen = await render(
<ContentList
{...defaultProps}
items={items}
searchQuery=""
onSearchChange={onSearchChange}
/>,
);

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

expect(onSearchChange).toHaveBeenCalledWith("beta");
// In controlled mode the component must NOT filter locally — the
// parent is responsible for pushing the next `items` prop.
await expect.element(screen.getByText("Alpha")).toBeInTheDocument();
await expect.element(screen.getByText("Beta")).toBeInTheDocument();
});
});
});
7 changes: 6 additions & 1 deletion packages/core/src/api/handlers/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,13 +256,18 @@ export async function handleContentList(
orderBy?: string;
order?: "asc" | "desc";
locale?: string;
/** Case-insensitive substring search over title/name/slug */
q?: string;
},
): Promise<ApiResult<ContentListResponse>> {
try {
const repo = new ContentRepository(db);
const where: { status?: string; locale?: string } = {};
const where: { status?: string; locale?: string; search?: string } = {};
if (params.status) where.status = params.status;
if (params.locale) where.locale = params.locale;
if (typeof params.q === "string" && params.q.trim().length > 0) {
where.search = params.q;
}

const result = await repo.findMany(collection, {
cursor: params.cursor,
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 title, name, and slug. */
q: z.string().trim().max(200).optional(),
})
.meta({ id: "ContentListQuery" });

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/astro/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ export interface EmDashHandlers {
orderBy?: string;
order?: "asc" | "desc";
locale?: string;
q?: string;
},
) => Promise<HandlerResponse>;

Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/database/dialect-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,30 @@ export async function listTablesLike(db: Kysely<any>, pattern: string): Promise<
return result.rows.map((r) => r.name);
}

/**
* List column names for a table. Returns an empty array if the table
* does not exist. Used by callers that need to build SQL fragments
* referencing columns whose presence varies between collections.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance
export async function listTableColumns(db: Kysely<any>, tableName: string): Promise<string[]> {
if (isPostgres(db)) {
const result = await sql<{ column_name: string }>`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = ${tableName}
`.execute(db);
return result.rows.map((r) => r.column_name);
}

// SQLite 3.16+ exposes PRAGMAs as table-valued functions that accept
// bound parameters. Guard the identifier defensively anyway.
validateIdentifier(tableName, "table name");
const result = await sql<{ name: string }>`
SELECT name FROM pragma_table_info(${tableName})
`.execute(db);
return result.rows.map((r) => r.name);
}

/**
* Column type for binary data.
*
Expand Down
Loading
Loading