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
5 changes: 5 additions & 0 deletions .changeset/content-list-searchable-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Include schema fields marked searchable in admin content-list search, using the collection FTS index when available and a cross-dialect substring fallback otherwise.
2 changes: 1 addition & 1 deletion packages/admin/src/lib/api/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ 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 across display fields, slug, and searchable custom fields. */
search?: string;
/** Filter to entries authored by this user (the `author_id` column). */
authorId?: string;
Expand Down
21 changes: 12 additions & 9 deletions packages/core/src/api/handlers/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,30 +313,33 @@ export interface TrashedContentItem {

/**
* 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.
* includes `slug` (a standard column), adds the `title`/`name` display fields
* when they exist, and includes every field explicitly marked searchable.
* Returning only schema-backed columns avoids "no such column" errors.
*/
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;
if (!row) return ["slug"];

const fields = await db
.selectFrom("_emdash_fields")
.select("slug")
.select(["slug", "searchable"])
.where("collection_id", "=", row.id)
.orderBy("sort_order", "asc")
.execute();
const columns = new Set(["slug"]);
const fieldSlugs = new Set(fields.map((f) => f.slug));
for (const candidate of ["title", "name"]) {
if (fieldSlugs.has(candidate)) columns.push(candidate);
if (fieldSlugs.has(candidate)) columns.add(candidate);
}
for (const field of fields) {
if (field.searchable === 1) columns.add(field.slug);
}
return columns;
return [...columns];
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/api/schemas/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ 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. */
/** Search across the collection's display fields, slug, and searchable custom fields. */
q: z.string().trim().min(1).max(200).optional(),
/** Filter to entries authored by this user (the `author_id` column). */
authorId: z.string().min(1).max(64).optional(),
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/database/repositories/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -904,7 +904,7 @@ export class ContentRepository {
eb.or(
columns.map((col) => {
validateIdentifier(col, "search column");
return eb(sql`lower(${sql.ref(col)})`, "like", sql`lower(${pattern}) escape '\\'`);
return sql<boolean>`lower(CAST(${sql.ref(col)} AS TEXT)) LIKE lower(${pattern}) ESCAPE '\\'`;
}),
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ async function seedPosts(db: Db) {
}
const needle = await handleContentCreate(db, "posts", {
slug: "the-needle-post",
data: { title: "zzz Needle Headline" },
data: { title: "zzz Needle Headline", ticket_number: "TICKET2179" },
});
if (!needle.success) throw new Error("needle seed failed");

Expand Down Expand Up @@ -59,6 +59,12 @@ describe("content list search served by FTS (#1517)", () => {
type: "string",
searchable: true,
});
await registry.createField("posts", {
slug: "ticket_number",
label: "Ticket number",
type: "string",
searchable: true,
});
await seedPosts(db);
await new FTSManager(db).enableSearch("posts");
});
Expand Down Expand Up @@ -90,6 +96,11 @@ describe("content list search served by FTS (#1517)", () => {
expect(titlesOf(result)).toContain("zzz Needle Headline");
});

it("finds an entry by a searchable custom field", async () => {
const result = await handleContentList(db, "posts", { q: "ticket217", limit: 20 });
expect(titlesOf(result)).toContain("zzz Needle Headline");
});

it("returns a total that matches the filtered set, not the whole table", async () => {
const result = await handleContentList(db, "posts", { q: "Needle", limit: 20 });
if (!result.success) throw new Error("list failed");
Expand Down Expand Up @@ -144,6 +155,12 @@ describe("content list search falls back to LIKE when FTS cannot serve it", () =
type: "text",
searchable: true,
});
await registry.createField("posts", {
slug: "ticket_number",
label: "Ticket number",
type: "string",
searchable: true,
});
await seedPosts(db);
await new FTSManager(db).enableSearch("posts");
});
Expand All @@ -158,4 +175,9 @@ describe("content list search falls back to LIKE when FTS cannot serve it", () =
const result = await handleContentList(db, "posts", { q: "eedle", limit: 20 });
expect(titlesOf(result)).toContain("zzz Needle Headline");
});

it("searches custom fields through the LIKE fallback", async () => {
const result = await handleContentList(db, "posts", { q: "CKET21", limit: 20 });
expect(titlesOf(result)).toContain("zzz Needle Headline");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ describeEachDialect("content list server-side search (#1219)", (dialect) => {
const registry = new SchemaRegistry(ctx.db);
await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" });
await registry.createField("posts", { slug: "title", label: "Title", type: "string" });
await registry.createField("posts", {
slug: "ticket_number",
label: "Ticket number",
type: "integer",
searchable: true,
});

// Seed 60 ordinary posts plus a "deep" needle far past the first page.
for (let i = 0; i < 60; i++) {
Expand All @@ -31,7 +37,7 @@ describeEachDialect("content list server-side search (#1219)", (dialect) => {
}
const needle = await handleContentCreate(ctx.db, "posts", {
slug: "the-needle-post",
data: { title: "zzz Needle Headline" },
data: { title: "zzz Needle Headline", ticket_number: 2179 },
});
if (!needle.success) throw new Error("needle seed failed");

Expand Down Expand Up @@ -73,6 +79,11 @@ describeEachDialect("content list server-side search (#1219)", (dialect) => {
expect(titlesOf(result)).toContain("zzz Needle Headline");
});

it("searches custom fields marked searchable", async () => {
const result = await handleContentList(ctx.db, "posts", { q: "2179", limit: 20 });
expect(titlesOf(result)).toContain("zzz Needle Headline");
});

it("treats LIKE wildcards in the query literally", async () => {
// "50%" must match only the "50% off sale" title — not every row (which
// is what an unescaped trailing % wildcard would do).
Expand Down
Loading