Skip to content

Commit d5706ce

Browse files
committed
fix(content): search searchable custom fields
1 parent 09e5b5c commit d5706ce

7 files changed

Lines changed: 55 additions & 14 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
Include schema fields marked searchable in admin content-list search, using the collection FTS index when available and a cross-dialect substring fallback otherwise.

packages/admin/src/lib/api/content.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ export async function fetchContentList(
146146
orderBy?: string;
147147
/** Sort direction; defaults to "desc" on the server. */
148148
order?: "asc" | "desc";
149-
/** Case-insensitive substring search across title/name/slug. */
149+
/** Search across display fields, slug, and searchable custom fields. */
150150
search?: string;
151151
/** Filter to entries authored by this user (the `author_id` column). */
152152
authorId?: string;

packages/core/src/api/handlers/content.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -313,30 +313,33 @@ export interface TrashedContentItem {
313313

314314
/**
315315
* Resolve the columns a content-list search should match against. Always
316-
* includes `slug` (a standard column) and adds the `title`/`name` display
317-
* fields when the collection actually defines them, mirroring the admin's
318-
* item-title resolution (title -> name -> slug). Returning only existing
319-
* columns avoids "no such column" errors on collections without them.
316+
* includes `slug` (a standard column), adds the `title`/`name` display fields
317+
* when they exist, and includes every field explicitly marked searchable.
318+
* Returning only schema-backed columns avoids "no such column" errors.
320319
*/
321320
async function resolveSearchColumns(db: Kysely<Database>, collection: string): Promise<string[]> {
322-
const columns = ["slug"];
323321
const row = await db
324322
.selectFrom("_emdash_collections")
325323
.select("id")
326324
.where("slug", "=", collection)
327325
.executeTakeFirst();
328-
if (!row) return columns;
326+
if (!row) return ["slug"];
329327

330328
const fields = await db
331329
.selectFrom("_emdash_fields")
332-
.select("slug")
330+
.select(["slug", "searchable"])
333331
.where("collection_id", "=", row.id)
332+
.orderBy("sort_order", "asc")
334333
.execute();
334+
const columns = new Set(["slug"]);
335335
const fieldSlugs = new Set(fields.map((f) => f.slug));
336336
for (const candidate of ["title", "name"]) {
337-
if (fieldSlugs.has(candidate)) columns.push(candidate);
337+
if (fieldSlugs.has(candidate)) columns.add(candidate);
338+
}
339+
for (const field of fields) {
340+
if (field.searchable === 1) columns.add(field.slug);
338341
}
339-
return columns;
342+
return [...columns];
340343
}
341344

342345
/**

packages/core/src/api/schemas/content.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export const contentListQuery = cursorPaginationQuery
3232
orderBy: z.string().optional(),
3333
order: z.enum(["asc", "desc"]).optional(),
3434
locale: localeCode.optional(),
35-
/** Case-insensitive substring search across the collection's title/name/slug. */
35+
/** Search across the collection's display fields, slug, and searchable custom fields. */
3636
q: z.string().trim().min(1).max(200).optional(),
3737
/** Filter to entries authored by this user (the `author_id` column). */
3838
authorId: z.string().min(1).max(64).optional(),

packages/core/src/database/repositories/content.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -904,7 +904,7 @@ export class ContentRepository {
904904
eb.or(
905905
columns.map((col) => {
906906
validateIdentifier(col, "search column");
907-
return eb(sql`lower(${sql.ref(col)})`, "like", sql`lower(${pattern}) escape '\\'`);
907+
return sql<boolean>`lower(CAST(${sql.ref(col)} AS TEXT)) LIKE lower(${pattern}) ESCAPE '\\'`;
908908
}),
909909
),
910910
);

packages/core/tests/integration/content/content-list-search-fts.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ async function seedPosts(db: Db) {
2727
}
2828
const needle = await handleContentCreate(db, "posts", {
2929
slug: "the-needle-post",
30-
data: { title: "zzz Needle Headline" },
30+
data: { title: "zzz Needle Headline", ticket_number: "TICKET2179" },
3131
});
3232
if (!needle.success) throw new Error("needle seed failed");
3333

@@ -59,6 +59,12 @@ describe("content list search served by FTS (#1517)", () => {
5959
type: "string",
6060
searchable: true,
6161
});
62+
await registry.createField("posts", {
63+
slug: "ticket_number",
64+
label: "Ticket number",
65+
type: "string",
66+
searchable: true,
67+
});
6268
await seedPosts(db);
6369
await new FTSManager(db).enableSearch("posts");
6470
});
@@ -90,6 +96,11 @@ describe("content list search served by FTS (#1517)", () => {
9096
expect(titlesOf(result)).toContain("zzz Needle Headline");
9197
});
9298

99+
it("finds an entry by a searchable custom field", async () => {
100+
const result = await handleContentList(db, "posts", { q: "ticket217", limit: 20 });
101+
expect(titlesOf(result)).toContain("zzz Needle Headline");
102+
});
103+
93104
it("returns a total that matches the filtered set, not the whole table", async () => {
94105
const result = await handleContentList(db, "posts", { q: "Needle", limit: 20 });
95106
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", () =
144155
type: "text",
145156
searchable: true,
146157
});
158+
await registry.createField("posts", {
159+
slug: "ticket_number",
160+
label: "Ticket number",
161+
type: "string",
162+
searchable: true,
163+
});
147164
await seedPosts(db);
148165
await new FTSManager(db).enableSearch("posts");
149166
});
@@ -158,4 +175,9 @@ describe("content list search falls back to LIKE when FTS cannot serve it", () =
158175
const result = await handleContentList(db, "posts", { q: "eedle", limit: 20 });
159176
expect(titlesOf(result)).toContain("zzz Needle Headline");
160177
});
178+
179+
it("searches custom fields through the LIKE fallback", async () => {
180+
const result = await handleContentList(db, "posts", { q: "CKET21", limit: 20 });
181+
expect(titlesOf(result)).toContain("zzz Needle Headline");
182+
});
161183
});

packages/core/tests/integration/content/content-list-search.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ describeEachDialect("content list server-side search (#1219)", (dialect) => {
2020
const registry = new SchemaRegistry(ctx.db);
2121
await registry.createCollection({ slug: "posts", label: "Posts", labelSingular: "Post" });
2222
await registry.createField("posts", { slug: "title", label: "Title", type: "string" });
23+
await registry.createField("posts", {
24+
slug: "ticket_number",
25+
label: "Ticket number",
26+
type: "integer",
27+
searchable: true,
28+
});
2329

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

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

82+
it("searches custom fields marked searchable", async () => {
83+
const result = await handleContentList(ctx.db, "posts", { q: "2179", limit: 20 });
84+
expect(titlesOf(result)).toContain("zzz Needle Headline");
85+
});
86+
7687
it("treats LIKE wildcards in the query literally", async () => {
7788
// "50%" must match only the "50% off sale" title — not every row (which
7889
// is what an unescaped trailing % wildcard would do).

0 commit comments

Comments
 (0)