From d5706ce1449e7714efbec8fec15b2b378dc6b730 Mon Sep 17 00:00:00 2001 From: logelog <194732487+logelog@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:06:54 +0200 Subject: [PATCH] fix(content): search searchable custom fields --- .changeset/content-list-searchable-fields.md | 5 ++++ packages/admin/src/lib/api/content.ts | 2 +- packages/core/src/api/handlers/content.ts | 21 +++++++++------- packages/core/src/api/schemas/content.ts | 2 +- .../core/src/database/repositories/content.ts | 2 +- .../content/content-list-search-fts.test.ts | 24 ++++++++++++++++++- .../content/content-list-search.test.ts | 13 +++++++++- 7 files changed, 55 insertions(+), 14 deletions(-) create mode 100644 .changeset/content-list-searchable-fields.md diff --git a/.changeset/content-list-searchable-fields.md b/.changeset/content-list-searchable-fields.md new file mode 100644 index 0000000000..3ef88b2c85 --- /dev/null +++ b/.changeset/content-list-searchable-fields.md @@ -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. diff --git a/packages/admin/src/lib/api/content.ts b/packages/admin/src/lib/api/content.ts index 23030c2e97..0aafbe106f 100644 --- a/packages/admin/src/lib/api/content.ts +++ b/packages/admin/src/lib/api/content.ts @@ -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; diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index db0f4a0a6d..6174d1ea27 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -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, collection: string): Promise { - 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]; } /** diff --git a/packages/core/src/api/schemas/content.ts b/packages/core/src/api/schemas/content.ts index fafab339b2..5deb8d983d 100644 --- a/packages/core/src/api/schemas/content.ts +++ b/packages/core/src/api/schemas/content.ts @@ -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(), diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index 351a49538c..439f6d9eaa 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -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`lower(CAST(${sql.ref(col)} AS TEXT)) LIKE lower(${pattern}) ESCAPE '\\'`; }), ), ); diff --git a/packages/core/tests/integration/content/content-list-search-fts.test.ts b/packages/core/tests/integration/content/content-list-search-fts.test.ts index 1584b6aa9a..d9941279a7 100644 --- a/packages/core/tests/integration/content/content-list-search-fts.test.ts +++ b/packages/core/tests/integration/content/content-list-search-fts.test.ts @@ -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"); @@ -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"); }); @@ -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"); @@ -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"); }); @@ -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"); + }); }); diff --git a/packages/core/tests/integration/content/content-list-search.test.ts b/packages/core/tests/integration/content/content-list-search.test.ts index 8e8e206664..189ce97fc3 100644 --- a/packages/core/tests/integration/content/content-list-search.test.ts +++ b/packages/core/tests/integration/content/content-list-search.test.ts @@ -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++) { @@ -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"); @@ -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).