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
6 changes: 6 additions & 0 deletions .changeset/content-picker-mcp-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"emdash": patch
"@emdash-cms/admin": patch
---

Extend the new server-side content search (`?q=`) to two more surfaces. The `ContentPickerModal` (used when linking content from the editor) now pushes its search box to the server instead of filtering only the items already loaded, so it can find entries anywhere in a large collection; it uses `keepPreviousData` so the list doesn't flash to empty between keystrokes and keeps load-more available while searching. The MCP `content_list` tool also gains a `q` parameter, so agents can search a collection server-side instead of post-filtering a page of results.
83 changes: 36 additions & 47 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, useInfiniteQuery, useQuery } from "@tanstack/react-query";
import * as React from "react";

import { fetchCollections, fetchContentList, getDraftStatus } from "../lib/api";
Expand Down Expand Up @@ -38,9 +38,6 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick
const [searchQuery, setSearchQuery] = React.useState("");
const debouncedSearch = useDebouncedValue(searchQuery, 300);
const [selectedCollection, setSelectedCollection] = React.useState<string>("");
const [allItems, setAllItems] = React.useState<ContentItem[]>([]);
const [nextCursor, setNextCursor] = React.useState<string | undefined>();
const [isLoadingMore, setIsLoadingMore] = React.useState(false);

const { data: collections = [] } = useQuery({
queryKey: ["collections"],
Expand All @@ -55,48 +52,42 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick
}
}, [collections, selectedCollection]);

const { data: contentResult, isLoading: contentLoading } = useQuery({
queryKey: ["content-picker", selectedCollection, { limit: 50 }],
queryFn: () => fetchContentList(selectedCollection, { limit: 50 }),
// 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,
isLoading: contentLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
// `search` is part of the key, so switching searches starts a fresh
// page chain rather than appending to the old query's cursor.
queryKey: ["content-picker", selectedCollection, { limit: 50, search: searchParam }],
queryFn: ({ pageParam }) =>
fetchContentList(selectedCollection, { limit: 50, cursor: pageParam, search: searchParam }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
enabled: open && !!selectedCollection,
// Keep the previous query's pages visible while the debounced search
// refetches, so the list doesn't flash to empty between keystrokes.
placeholderData: keepPreviousData,
});

// Sync initial page into accumulated items
React.useEffect(() => {
if (contentResult) {
setAllItems(contentResult.items);
setNextCursor(contentResult.nextCursor);
}
}, [contentResult]);

const handleLoadMore = async () => {
if (!nextCursor || isLoadingMore) return;
setIsLoadingMore(true);
try {
const result = await fetchContentList(selectedCollection, {
limit: 50,
cursor: nextCursor,
});
setAllItems((prev) => [...prev, ...result.items]);
setNextCursor(result.nextCursor);
} finally {
setIsLoadingMore(false);
}
};

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 and paginated from the server. Deriving the
// list straight from the query pages (instead of mirroring into local
// state) means a background refetch can't wipe out loaded pages and a
// search change can't fetch with a stale cursor.
const filteredItems = React.useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data]);

// Reset state when modal opens or collection changes
// Reset transient UI state when the modal opens. The query itself resets
// via its key, so there's no accumulator to clear here.
React.useEffect(() => {
if (open) {
setSearchQuery("");
setSelectedCollection("");
setAllItems([]);
setNextCursor(undefined);
}
}, [open]);

Expand Down Expand Up @@ -147,11 +138,7 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick
</div>
<Select
value={selectedCollection}
onValueChange={(v) => {
setSelectedCollection(v ?? "");
setAllItems([]);
setNextCursor(undefined);
}}
onValueChange={(v) => setSelectedCollection(v ?? "")}
items={Object.fromEntries(collections.map((col) => [col.slug, col.label]))}
aria-label={t`Collection`}
/>
Expand Down Expand Up @@ -220,15 +207,17 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick
</button>
);
})}
{nextCursor && !searchQuery && (
{/* Search is server-side and paginated, so results can span
multiple pages too — keep load-more available while searching. */}
{hasNextPage && (
<div className="pt-2 text-center">
<Button
variant="outline"
size="sm"
onClick={handleLoadMore}
disabled={isLoadingMore}
onClick={() => void fetchNextPage()}
disabled={isFetchingNextPage}
>
{isLoadingMore ? (
{isFetchingNextPage ? (
<>
<Loader size="sm" /> {t`Loading...`}
</>
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 @@ -233,6 +233,7 @@ export interface EmDashHandlers {
orderBy?: string;
order?: "asc" | "desc";
locale?: string;
q?: string;
},
) => Promise<HandlerResponse>;

Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,12 @@ export function createMcpServer(): McpServer {
.string()
.optional()
.describe("Filter by locale (e.g. 'en', 'fr'). Only relevant when i18n is enabled."),
q: z
.string()
.trim()
.max(200)
.optional()
.describe("Case-insensitive substring search across title, name, and slug."),
}),
annotations: { readOnlyHint: true },
},
Expand All @@ -477,6 +483,7 @@ export function createMcpServer(): McpServer {
orderBy: args.orderBy,
order: args.order,
locale: args.locale,
q: args.q,
}),
);
},
Expand Down
19 changes: 19 additions & 0 deletions packages/core/tests/integration/mcp/content-misc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,25 @@ describe("soft-delete visibility", () => {
expect(items).toHaveLength(1);
expect(items[0]?.id).toBe(extractJson<{ item: { id: string } }>(b).item.id);
});

it("content_list filters by the q search parameter", async () => {
const apple = await harness.client.callTool({
name: "content_create",
arguments: { collection: "post", data: { title: "Apple harvest" } },
});
await harness.client.callTool({
name: "content_create",
arguments: { collection: "post", data: { title: "Banana bread" } },
});

const list = await harness.client.callTool({
name: "content_list",
arguments: { collection: "post", q: "apple" },
});
const items = extractJson<{ items: Array<{ id: string }> }>(list).items;
expect(items).toHaveLength(1);
expect(items[0]?.id).toBe(extractJson<{ item: { id: string } }>(apple).item.id);
});
});

describe("edit-while-trashed", () => {
Expand Down
Loading