diff --git a/.changeset/great-eyes-brake.md b/.changeset/great-eyes-brake.md new file mode 100644 index 0000000000..aa0278e0b6 --- /dev/null +++ b/.changeset/great-eyes-brake.md @@ -0,0 +1,20 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +--- + +Adds taxonomy-term filtering to the content list, in the admin and over the API. + +A collection's list could be narrowed by status, author, byline, date range, free text and any indexed custom field, but not by a taxonomy term, so a site organized by categories or tags could not be browsed by them in the screen editors work in. + +The admin now shows one dropdown per taxonomy applied to the collection, beside the existing filters. Nothing is configured per site: a collection already declares which taxonomies apply to it, so a collection with no taxonomy is unchanged and one that gains a taxonomy gains its filter. Hierarchical taxonomies are indented. + +Over the API, `GET /content/{collection}` accepts `termFilters`, a JSON object keyed by taxonomy name: + +``` +?termFilters={"topics":["rodeo","polo"],"places":["kentucky"]} +``` + +An entry matches **any** of a taxonomy's slugs and **every** taxonomy named: OR within a taxonomy, AND across taxonomies. Term slugs resolve through their translation group, so a term matches across locales unless the request is locale-scoped. The filter composes with `fieldFilters` and every existing filter, and `total` reflects it, so cursor pages stay consistent. + +Two cases fail rather than returning a misleading list. A taxonomy that is not applied to the collection is rejected with `VALIDATION_ERROR` instead of being ignored, and a taxonomy given an empty array matches nothing instead of being treated as no filter. Callers that previously passed an unrecognised filter parameter and received an unfiltered list will now see an error where the parameter names a real but unapplied taxonomy. diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index 224d1be872..9d08d2cc0b 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -64,6 +64,7 @@ import { import { LocaleSwitcher } from "./LocaleSwitcher"; import { RouterLinkButton } from "./RouterLinkButton.js"; import { TableToolbar, TableToolbarSearch } from "./TableToolbar.js"; +import { TermFilters, type TermFilterState } from "./TermFilters.js"; /** * Sortable content list columns. The named values map to the server's system @@ -171,6 +172,8 @@ export interface ContentListProps { /** Controlled byline filter state. */ bylineFilter?: BylineFilterState; onBylineFilterChange?: (filter: BylineFilterState) => void; + termFilter?: TermFilterState; + onTermFilterChange?: (filter: TermFilterState) => void; /** * Bulk actions. Each is opt-in: the selection checkboxes only appear when at * least one bulk handler is provided, and each toolbar button renders only @@ -265,6 +268,8 @@ export function ContentList({ onDateFilterChange, bylineFilter = EMPTY_BYLINE_FILTER, onBylineFilterChange, + termFilter, + onTermFilterChange, onBulkPublish, onBulkUnpublish, onBulkDelete, @@ -479,6 +484,9 @@ export function ContentList({ onDateFilterChange={onDateFilterChange} bylineFilter={bylineFilter} onBylineFilterChange={onBylineFilterChange} + collection={collection} + termFilter={termFilter} + onTermFilterChange={onTermFilterChange} locale={activeLocale ?? undefined} /> )} @@ -826,6 +834,10 @@ interface FilterBarProps { onDateFilterChange?: (filter: ContentDateFilter) => void; bylineFilter: BylineFilterState; onBylineFilterChange?: (filter: BylineFilterState) => void; + /** Slug of the collection being listed, so term filters know which taxonomies apply. */ + collection: string; + termFilter?: TermFilterState; + onTermFilterChange?: (filter: TermFilterState) => void; /** Locale the list is showing, so the byline picker offers matching rows. */ locale?: string; } @@ -847,6 +859,9 @@ function FilterBar({ onDateFilterChange, bylineFilter, onBylineFilterChange, + collection, + termFilter, + onTermFilterChange, locale, }: FilterBarProps) { const { t } = useLingui(); @@ -933,6 +948,15 @@ function FilterBar({ )} + {onTermFilterChange && ( + + )} + {showDateFilter && ( <> onSelect(v ?? "")} + items={{ + "": allLabel, + ...Object.fromEntries(options.map(({ term }) => [term.slug, term.label])), + }} + > + {allLabel} + {options.map(({ term, depth }) => ( + + {depth > 0 ? `${"  ".repeat(depth)}${term.label}` : term.label} + + ))} + + ); +} diff --git a/packages/admin/src/lib/api/content.ts b/packages/admin/src/lib/api/content.ts index 92fe705943..6184c6b70a 100644 --- a/packages/admin/src/lib/api/content.ts +++ b/packages/admin/src/lib/api/content.ts @@ -183,6 +183,11 @@ export async function fetchContentList( * explicit credit. Off by default: the filter matches real credits. */ includeInferredBylines?: boolean; + /** + * Taxonomy term slugs keyed by taxonomy name. An entry matches any slug + * within a taxonomy and every taxonomy named: OR within, AND across. + */ + termFilters?: Record; }, ): Promise> { const params = new URLSearchParams(); @@ -194,6 +199,12 @@ export async function fetchContentList( if (options?.order) params.set("order", options.order); if (options?.search) params.set("q", options.search); if (options?.authorId) params.set("authorId", options.authorId); + // Only send taxonomies that actually have a selection: an empty array is a + // filter that matches nothing, which is not what an untouched control means. + if (options?.termFilters) { + const active = Object.entries(options.termFilters).filter(([, slugs]) => slugs.length > 0); + if (active.length > 0) params.set("termFilters", JSON.stringify(Object.fromEntries(active))); + } // A date range is only meaningful with a target field; send all three // together so the server doesn't reject a half-specified filter. if (options?.dateField && (options.dateFrom || options.dateTo)) { diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 07bee287c1..4c75b5deb3 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -71,6 +71,7 @@ import { SetupWizard } from "./components/SetupWizard"; import { Shell } from "./components/Shell"; import { SignupPage } from "./components/SignupPage"; import { TaxonomyManager } from "./components/TaxonomyManager"; +import { EMPTY_TERM_FILTER, type TermFilterState } from "./components/TermFilters.js"; import { ThemeMarketplaceBrowse } from "./components/ThemeMarketplaceBrowse"; import { ThemeMarketplaceDetail } from "./components/ThemeMarketplaceDetail"; import { Widgets } from "./components/Widgets"; @@ -387,6 +388,16 @@ function ContentListPage() { const [authorFilter, setAuthorFilter] = React.useState(""); const [dateFilter, setDateFilter] = React.useState(EMPTY_DATE_FILTER); const [bylineFilter, setBylineFilter] = React.useState(EMPTY_BYLINE_FILTER); + const [termFilter, setTermFilter] = React.useState(EMPTY_TERM_FILTER); + + // Only a taxonomy with a selection belongs in the query key or the request: + // an untouched control filters nothing, and an empty array would filter + // everything out. + const termApiParams = React.useMemo(() => { + const active = Object.entries(termFilter).filter(([, slugs]) => slugs.length > 0); + if (active.length === 0) return undefined; + return { termFilters: Object.fromEntries(active) }; + }, [termFilter]); // Only the parts that change the result set belong in the query key — // `includeInferred` alone, with nothing selected, filters nothing. @@ -433,6 +444,7 @@ function ContentListPage() { author: authorFilter, date: dateApiParams, byline: bylineApiParams, + terms: termApiParams, }, ], queryFn: ({ pageParam }) => @@ -447,6 +459,7 @@ function ContentListPage() { authorId: authorFilter || undefined, ...dateApiParams, ...bylineApiParams, + ...termApiParams, }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.nextCursor, @@ -675,6 +688,8 @@ function ContentListPage() { dateFilter={dateFilter} onDateFilterChange={setDateFilter} bylineFilter={bylineFilter} + termFilter={termFilter} + onTermFilterChange={setTermFilter} onBylineFilterChange={setBylineFilter} onBulkPublish={(ids) => bulkPublishMutation.mutateAsync(ids).then((r) => r.failedIds)} onBulkUnpublish={(ids) => bulkUnpublishMutation.mutateAsync(ids).then((r) => r.failedIds)} diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index aa7ad83f87..a8e465c771 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -5,7 +5,7 @@ import type { Kysely } from "kysely"; import { sql } from "kysely"; -import type { ContentFieldFilters } from "../../content-list-query.js"; +import type { ContentFieldFilters, ContentTermFilters } from "../../content-list-query.js"; import { isSqlite } from "../../database/dialect-helpers.js"; import { BylineRepository } from "../../database/repositories/byline.js"; import type { ContentBylineInput } from "../../database/repositories/byline.js"; @@ -536,6 +536,7 @@ export async function handleContentList( bylinesNone?: boolean; includeInferredBylines?: boolean; fieldFilters?: ContentFieldFilters; + termFilters?: ContentTermFilters; }, ): Promise> { try { @@ -549,6 +550,43 @@ export async function handleContentList( where.fieldFilters = params.fieldFilters; } + // A taxonomy that is not attached to this collection can never match, + // so it is a caller error rather than an empty result. Failing here is + // deliberate: an ignored filter parameter returns a complete, plausible, + // unfiltered list with a 200, which is the hardest kind of bug to see. + if (params.termFilters && Object.keys(params.termFilters).length > 0) { + const names = Object.keys(params.termFilters); + const attached = await db + .selectFrom("_emdash_taxonomy_defs") + .select(["name", "collections"]) + .where("name", "in", names) + .execute(); + const appliesHere = new Set( + attached + .filter((row) => { + if (!row.collections) return false; + try { + const list = JSON.parse(row.collections) as unknown; + return Array.isArray(list) && list.includes(collection); + } catch { + return false; + } + }) + .map((row) => row.name), + ); + const unknown = names.filter((name) => !appliesHere.has(name)); + if (unknown.length > 0) { + return { + success: false, + error: { + code: "VALIDATION_ERROR", + message: `Taxonomy not applied to ${collection}: ${unknown.join(", ")}`, + }, + }; + } + where.termFilters = params.termFilters; + } + const bylineFilter = resolveBylineFilter(params, locale); if (bylineFilter) where.bylineFilter = bylineFilter; diff --git a/packages/core/src/api/schemas/content.ts b/packages/core/src/api/schemas/content.ts index 1abf8c217a..3374c1bfcb 100644 --- a/packages/core/src/api/schemas/content.ts +++ b/packages/core/src/api/schemas/content.ts @@ -123,6 +123,44 @@ export const contentFieldFiltersSchema = z }, ); +/** + * Taxonomy-term filters: OR within a taxonomy, AND across taxonomies. + * + * Bounded the same way as indexed field filters, because both end up as + * operands in one statement: a cap on taxonomies, and a shared operand budget + * over the slugs. + */ +export const contentTermFiltersSchema = z + .record( + z + .string() + .max(63) + .regex(/^[a-z][a-z0-9_]*$/, "must be a safe taxonomy name"), + z.array(z.string().min(1).max(200)).min(1, "a taxonomy filter needs at least one term slug"), + ) + .refine((filters) => Object.keys(filters).length <= 10, { + message: "At most 10 taxonomy filters are allowed", + }) + .refine( + (filters) => + Object.values(filters).reduce((total, slugs) => total + slugs.length, 0) <= + SQL_BATCH_SIZE, + { message: `Taxonomy term filters have a total operand budget of ${SQL_BATCH_SIZE}` }, + ); + +const contentTermFiltersQuery = z + .string() + .max(8192) + .transform((value, ctx): unknown => { + try { + return JSON.parse(value); + } catch { + ctx.addIssue({ code: "custom", message: "must be valid JSON" }); + return z.NEVER; + } + }) + .pipe(contentTermFiltersSchema); + const contentFieldFiltersQuery = z .string() .max(8192) @@ -165,6 +203,12 @@ export const contentListQuery = cursorPaginationQuery includeInferredBylines: booleanParam, /** JSON-encoded indexed custom-field filters, combined with AND semantics. */ fieldFilters: contentFieldFiltersQuery.optional(), + /** + * JSON-encoded taxonomy-term filters keyed by taxonomy name, e.g. + * `{"topics":["rodeo","polo"],"places":["kentucky"]}`. An entry matches + * any slug within a taxonomy and every taxonomy named. + */ + termFilters: contentTermFiltersQuery.optional(), }) .transform(({ bylines, ...rest }) => ({ ...rest, diff --git a/packages/core/src/content-list-query.ts b/packages/core/src/content-list-query.ts index 075fbabc17..2340555025 100644 --- a/packages/core/src/content-list-query.ts +++ b/packages/core/src/content-list-query.ts @@ -27,3 +27,16 @@ export type ContentFieldFilterValue = * performs membership matching, and range bounds can be combined. */ export type ContentFieldFilters = Record; + +/** + * Taxonomy-term filters, keyed by taxonomy name. + * + * An entry matches a key when it carries **any** of that key's term slugs, and + * must match **every** key: OR within a taxonomy, AND across taxonomies. This + * mirrors `ContentFieldFilters`, which ANDs its keys, and matches how the + * filters read in a UI: two terms of one taxonomy widen a search, two + * taxonomies narrow it. + * + * Slugs are resolved per locale, as terms are elsewhere. + */ +export type ContentTermFilters = Record; diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index 2699c2a919..b0dc26f3e7 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -1,7 +1,11 @@ import { sql, type Kysely } from "kysely"; import { ulid } from "ulidx"; -import type { ContentFieldFilterValue, ContentFieldFilters } from "../../content-list-query.js"; +import type { + ContentFieldFilterValue, + ContentFieldFilters, + ContentTermFilters, +} from "../../content-list-query.js"; import { invalidateCollectionCache } from "../../object-cache/index.js"; import { isIndexableFieldType, type FieldType } from "../../schema/types.js"; import { buildFtsPrefixMatch, buildSlugGlobPrefix } from "../../search/match.js"; @@ -728,6 +732,7 @@ export class ContentRepository { query = this.applySearchFilter(query, options.where, type); query = this.applyDateFilter(query, options.where); query = this.applyBylineFilter(query, options.where, type); + query = this.applyTermFilters(query, options.where, type); query = this.applyFieldFilters(query, resolvedFieldFilters); // Handle cursor pagination — decodeCursor throws InvalidCursorError @@ -1428,6 +1433,56 @@ export class ContentRepository { }); } + /** + * Restrict to entries carrying taxonomy terms: OR within a taxonomy, AND + * across taxonomies. + * + * `content_taxonomies.entry_id` stores the ec_* row's **translation_group**, + * not its id, so the correlation is on `translation_group`, unlike the + * byline junction above, which stores the row id. Getting that wrong + * matches nothing rather than erroring. + * + * `taxonomies.name` is the taxonomy, `taxonomies.slug` the term. The join is + * on `taxonomies.translation_group`, which is what the junction stores, so a + * term matches through any of its locale variants; `locale` narrows to one + * when the list is scoped. + */ + private applyTermFilters unknown) => QB }>( + query: QB, + where: { termFilters?: ContentTermFilters; locale?: string } | undefined, + type: string, + ): QB { + const filters = where?.termFilters; + if (!filters) return query; + const entries = Object.entries(filters); + if (entries.length === 0) return query; + + const groupColumn = `${getTableName(type)}.translation_group`; + + for (const [taxonomy, slugs] of entries) { + // A key that resolved to no slugs must match nothing rather than + // silently degrade to "no filter" and return the whole collection, + // the same way an empty byline set does above. + if (slugs.length === 0) { + query = query.where(() => sql`1 = 0`); + continue; + } + query = query.where((eb: any) => { + let sub = eb + .selectFrom("content_taxonomies as ct") + .innerJoin("taxonomies as t", "t.translation_group", "ct.taxonomy_id") + .select("ct.entry_id") + .where("ct.collection", "=", type) + .whereRef("ct.entry_id", "=", groupColumn) + .where("t.name", "=", taxonomy) + .where("t.slug", "in", slugs); + if (where?.locale) sub = sub.where("t.locale", "=", where.locale); + return eb.exists(sub); + }); + } + return query; + } + /** * Count content items */ @@ -1463,6 +1518,7 @@ export class ContentRepository { query = this.applySearchFilter(query, where, type); query = this.applyDateFilter(query, where); query = this.applyBylineFilter(query, where, type); + query = this.applyTermFilters(query, where, type); query = this.applyFieldFilters(query, resolvedFieldFilters); const result = await query.executeTakeFirst(); diff --git a/packages/core/src/database/repositories/types.ts b/packages/core/src/database/repositories/types.ts index fe7f0310ce..15871d0ee2 100644 --- a/packages/core/src/database/repositories/types.ts +++ b/packages/core/src/database/repositories/types.ts @@ -1,4 +1,4 @@ -import type { ContentFieldFilters } from "../../content-list-query.js"; +import type { ContentFieldFilters, ContentTermFilters } from "../../content-list-query.js"; import type { CustomFieldValue } from "../../schema/types.js"; import { encodeBase64, decodeBase64 } from "../../utils/base64.js"; @@ -205,6 +205,11 @@ export interface FindManyOptions { bylineFilter?: ContentBylineFilter; /** AND-combined filters over custom fields explicitly marked as indexed. */ fieldFilters?: ContentFieldFilters; + /** + * Restrict to entries carrying taxonomy terms: OR within a taxonomy, + * AND across taxonomies. Keyed by taxonomy name, valued by term slug. + */ + termFilters?: ContentTermFilters; }; orderBy?: { field: string; diff --git a/packages/core/tests/database/repositories/content.test.ts b/packages/core/tests/database/repositories/content.test.ts index c6bcca8836..bf08c63a93 100644 --- a/packages/core/tests/database/repositories/content.test.ts +++ b/packages/core/tests/database/repositories/content.test.ts @@ -552,6 +552,151 @@ describe("ContentRepository", () => { }); }); + describe("taxonomy term filters", () => { + // The junction stores the ec_* row's translation_group, not its id, + // and points at taxonomies.translation_group rather than the term + // row id. Seeding it by hand is what pins both, since getting either + // wrong matches nothing rather than erroring. + async function seedTerms() { + const seeded = await repo.findMany("post", { + orderBy: { field: "slug", direction: "asc" }, + }); + const terms = [ + { id: "t-rodeo", name: "topics", slug: "rodeo", label: "Rodeo" }, + { id: "t-polo", name: "topics", slug: "polo", label: "Polo" }, + { id: "t-ky", name: "places", slug: "kentucky", label: "Kentucky" }, + ]; + for (const term of terms) { + await db + .insertInto("taxonomies") + .values({ ...term, parent_id: null, translation_group: term.id }) + .execute(); + } + // post-0 rodeo+kentucky, post-1 rodeo, post-2 polo+kentucky + const links: Array<[number, string]> = [ + [0, "t-rodeo"], + [0, "t-ky"], + [1, "t-rodeo"], + [2, "t-polo"], + [2, "t-ky"], + ]; + for (const [index, taxonomyId] of links) { + const item = seeded.items[index]!; + await db + .insertInto("content_taxonomies") + .values({ + collection: "post", + entry_id: item.translationGroup ?? item.id, + taxonomy_id: taxonomyId, + }) + .execute(); + } + return seeded; + } + + it("matches entries carrying any slug within one taxonomy", async () => { + await seedTerms(); + + const result = await repo.findMany("post", { + orderBy: { field: "slug", direction: "asc" }, + where: { termFilters: { topics: ["rodeo"] } }, + }); + + expect(result.items.map((item) => item.slug)).toEqual(["post-0", "post-1"]); + expect(result.total).toBe(2); + }); + + it("ORs within a taxonomy", async () => { + await seedTerms(); + + const result = await repo.findMany("post", { + orderBy: { field: "slug", direction: "asc" }, + where: { termFilters: { topics: ["rodeo", "polo"] } }, + }); + + expect(result.items.map((item) => item.slug)).toEqual(["post-0", "post-1", "post-2"]); + expect(result.total).toBe(3); + }); + + it("ANDs across taxonomies", async () => { + await seedTerms(); + + const result = await repo.findMany("post", { + orderBy: { field: "slug", direction: "asc" }, + where: { termFilters: { topics: ["rodeo"], places: ["kentucky"] } }, + }); + + expect(result.items.map((item) => item.slug)).toEqual(["post-0"]); + expect(result.total).toBe(1); + }); + + // The failure this guards is the one that costs hours: a filter that + // silently matches everything returns a complete, plausible list. + it("matches nothing when a taxonomy filter has no slugs", async () => { + await seedTerms(); + + const result = await repo.findMany("post", { + where: { termFilters: { topics: [] } }, + }); + + expect(result.items).toEqual([]); + expect(result.total).toBe(0); + }); + + it("matches nothing for a slug no entry carries", async () => { + await seedTerms(); + + const result = await repo.findMany("post", { + where: { termFilters: { topics: ["dressage"] } }, + }); + + expect(result.total).toBe(0); + }); + + it("keeps the filtered total stable across cursor pages", async () => { + await seedTerms(); + + const page1 = await repo.findMany("post", { + limit: 1, + orderBy: { field: "slug", direction: "asc" }, + where: { termFilters: { topics: ["rodeo"] } }, + }); + const page2 = await repo.findMany("post", { + limit: 1, + cursor: page1.nextCursor, + orderBy: { field: "slug", direction: "asc" }, + where: { termFilters: { topics: ["rodeo"] } }, + }); + + expect(page1.items.map((item) => item.slug)).toEqual(["post-0"]); + expect(page2.items.map((item) => item.slug)).toEqual(["post-1"]); + expect(page1.total).toBe(2); + expect(page2.total).toBe(2); + }); + + it("combines with an indexed field filter", async () => { + await seedTerms(); + await registry.createField("post", { + slug: "queue", + label: "Queue", + type: "string", + indexed: true, + }); + const seeded = await repo.findMany("post", { + orderBy: { field: "slug", direction: "asc" }, + }); + await repo.update("post", seeded.items[0]!.id, { data: { queue: "urgent" } }); + await repo.update("post", seeded.items[1]!.id, { data: { queue: "normal" } }); + + const result = await repo.findMany("post", { + where: { termFilters: { topics: ["rodeo"] }, fieldFilters: { queue: "urgent" } }, + }); + + expect(result.items.map((item) => item.slug)).toEqual(["post-0"]); + expect(result.total).toBe(1); + }); + }); + describe("indexed field filters", () => { async function seedIndexedFields() { await registry.createField("post", {